From d9e169142eb64095160c7a49f390390828e1e54f Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Fri, 13 Feb 2026 16:06:29 -0800 Subject: [PATCH 01/20] 2.7.8 Working Changes (#1589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump version * Stop and resume DD Session Replay on app backgrounding and activation to prevent crashes * TAK server -> TestFlight (#1567) * Bump version * Message list performance fixes into 2.7.6 (#1475) * Remove extra want config call when adding a contact * App badge and unnecessary notification fixes (#1455) * - Fix for app badge not going to zero if a message arrives while you have that chat open - Fix for push notifications popping up when a message is received while that chat is open * Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message * Fix: Channels help grammer fix (#1452) * remove outdated TCP not available on Apple devices (#1450) * Update initial onboarding view * remove toggle gating for mac * Update crash reporting opt out in real time * Update onboarding text * Use mDNS text records for node name * TCP IP and port on the connection screen * Hide app icon chooser on mac * Infinite loop hang bugfixes and performance improvements for both `UserMessageList` and `ChannelMessageList` (#1465) * 2.7.5 Working Changes (#1460) * Remove extra want config call when adding a contact * App badge and unnecessary notification fixes (#1455) * - Fix for app badge not going to zero if a message arrives while you have that chat open - Fix for push notifications popping up when a message is received while that chat is open * Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message * Fix: Channels help grammer fix (#1452) * remove outdated TCP not available on Apple devices (#1450) * Update initial onboarding view * remove toggle gating for mac * Update crash reporting opt out in real time * Update onboarding text --------- Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> * UserEntity: add mostRecentMessage and unreadMessages with early exit when lastMessage is nil, and fetch 1 row (not N) otherwise * UserList: replace 5 slow calls to user.messageList with new fast calls * NodeList: always put the connected node at the top of list (if it matches the node filters) * ChannelEntity: add faster mostRecentPrivateMessage and unreadMessages which fetch 1 row (not N) * ChannelList: replace 5 calls to channel.allPrivateMessage with new fast calls * Fix incorrect appState.unreadDirectMessages calculations * MyInfoEntity: also fix unreadMessages count here to be fast, and use it for appState.unreadChannelMessages * UserMessageList: use @FetchRequest to prevent the N^2 behavior that was happening in calls to allPrivateMessages * Refactor ChannelEntityExtension and MyInfoEntityExtension to be more similar to UserEntityExtension * Remove SwiftUI-infinite-loop-causing `.id(redrawTapbacksTrigger)` in ChannelMessageList and UserMessageList (duplicate row ids) * MyInfoEntityExtension: exclude emoji tapbacks (which never get marked as read anyway) from unread message count * Add SaveChannelLinkData so MessageText and MeshtasticApp can use .sheet(item: ...) and avoid infinite loop hang due to Binding rebuild * ChannelMessageList and UserMessageList: switch to stable messageId for ForEach SwiftUI row identity * ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification * ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear * ChannelMessageList and UserMessageList: block spurious markMessagesAsRead when this View is not active --------- Co-authored-by: Garth Vander Houwen Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> * message-list-performance: revert scrolling changes (#1472) * Revert e0f0b4a0f749d2e83946f2c1297e5c97c9fdf46e (ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear) * Revert "ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification" This reverts commit ee1a7c44157eb6970ec2111fc1ac4d67a44a8238. --------- Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> Co-authored-by: Jake-B Co-authored-by: Mike Robbins * Explicitly set unmessagable, seems unnessary * Add back missing mesh map features * Fix: "Retrieving nodes" significantly slower after reconnect extracted from #1424 (#1477) * Fix: "Retrieving nodes" significantly slower after reconnect (#1424) The node database retrieval was calling context.save() for every single NodeInfo packet received (250 saves for 250 nodes). This caused severe performance degradation on reconnect when CoreData had accumulated state. Root Cause: - nodeInfoPacket() called context.save() immediately for each node - With 250 nodes, this meant 250 individual CoreData save operations - On first connection, CoreData is fresh and fast - On reconnect, CoreData has accumulated change tracking, undo management, and memory pressure, making each save progressively slower - This resulted in 10+ second retrieval times vs 1-2 seconds initially Solution: - Added deferSave parameter to nodeInfoPacket() function - During database retrieval (.retrievingDatabase state), defer all saves - Perform a single batch save when database retrieval completes (when NONCE_ONLY_DB configCompleteID is received) - This reduces 250 saves to 1 save Performance Impact: - Eliminates N individual saves during node database sync - Reduces database retrieval time back to 1-2 seconds on reconnect - Matches first-connection performance consistently Fixes #1424 * Revert *MessageListUnified files --------- Co-authored-by: Martin Bogomolni Co-authored-by: Jake-B * Hide route lines filter from mesh map * Mesh Map: fuzz imprecise locations so they're distinguishable and clickable at the highest zoom levels (#1478) Co-authored-by: Garth Vander Houwen * Fix bad merge * Update Meshtastic/Extensions/CoreData/UserEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Keep list of previous manual connections (#1484) * Keep list of previous manual connections * More descriptive manual connection rows * Merge fixes and new way to show IP on Connect view --------- Co-authored-by: Jake-B * Show who relayed messages (#1486) * Add identification for node that relayed text messages and add count for ammount of relayers of your message * Ack Relays * upsertPositionPacket: don't use future timestamps to set node's lastHeard (#1488) * R1 NEO * Neo * Update Meshtastic/Views/Settings/AppSettings.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove bad if * Git rid of extra environment variable * Update Meshtastic/Accessory/Transports/TCP/TCPTransport.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * MeshMap performance: quick wins (#1490) * MeshMap: change onMapCameraChange frequency to .onEnd so that zooming doesn't cause continuous SwiftUI reevaluation on every frame * MeshMapContent: factor out reducedPrecisionMapCircles into a separate function * MeshMapContent: when multiple reducedPrecisionCircles have the same (lat,lon,radius), just draw one (big perf boost in dense areas) * NodeMap performance improvements for high # positions history (#1480) * NodeMapContent: move Route Lines out of ForEach * NodeMapContent: move Convex Hull out of ForEach * NodeMapContent: Replace `position.nodePosition?` with `node` * NodeMapContent: drop unnecessary LazyVStack in showNodeHistory * NodeMapContent: hoist out nodeColorSwift * Move lineCoords, loraCoords calculations within showRouteLines, showConvexHull respectively * Hoist out repeated node.metadata?.positionFlags lookups / PositionFlags creation * NodeMapContent: remove unused @State * NodeMapSwiftUI: add NodeMapContentEquatableWrapper and NodeMapContentSignature to prevent frequent NodeMapContent recomputation and infinite render loops * NodeMapSwiftUI: disable animation during SwiftUI transactions * NodeMapContent: hoist nodeBorderColor and set allowsHitTesting(false) on history point views * NodeMapContent: prerenderHistoryPointCircle and prerenderHistoryPointArrow to avoid thousands of vector draw operations * NodeMapContent: Shared coordinate list for Route Lines and Convex Hull * NodeMapContent.prerenderHistoryPointArrow: add .frame(width: 16, height: 16) * Fix wantRangeTestPackets to correctly follow rangeTestConfig.enabled (#1489) * Fix interval drop down formatter * Clean up channel qr code functionality. * perferredPeripheralId fix * Set opt in * Retry once 5 second timer. dont throw the error * Queue for peripherals * Fix: hoplimit of dms would always fallback to hops away of the node even when configured hops was higher (#1495) * fix hops setting in dms * Fix hops for exchange position * Final fix * Don't favorite client base * Update device hardware * Prevent nil environment metrics * Bump datadog sdk * fix setting device telemetry enabled (#1515) * Update Muzi R1 Neo to actively supported * fix setting device telemetry enabled --------- Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors * Don't subscribe to mqtt topic if downlink is not on (#1501) * Dont sub if no downlink * moved reload mqtt connect config * Preview enabled in connected devices (#1509) * Update Muzi R1 Neo to actively supported * Preview enabled in connected devices * Fixing indentation * Fixing indentation --------- Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors * UpdateCoreData.updateAnyPacketFrom: mirror firmware's lastHeard/snr/rssi/hopsAway update logic from NodeDB::updateFrom (#1492) * `CLIENT_BASE` add-favorite/role-change confirmation dialog (#1493) * FavoriteNodeButton: refactor task out * AccessoryManager.connectedDeviceRole helper * FavoriteNodeButton: show confirmation dialog when a CLIENT_BASE is trying to add a favorite * addContactFromURL: add comment referencing upcoming change in https://github.com/meshtastic/firmware/pull/8495 * DeviceConfig: role picker: show a warning when selecting CLIENT_BASE, similar to warning shown for ROUTER * Adjust device configuration Client Base warning text * Compass view (#1521) * Added compass view * Added Compass View * Node colors in compass * Update Muzi R1 Neo to actively supported * Update PositionPopover.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update CompassView.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update CompassView.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Views/Helpers/CompassView.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Views/Helpers/CompassView.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove discovery queue * revert problematic retry functionalliy * format file * Update & improve zh-Hans translation (#1523) * Update Muzi R1 Neo to actively supported * update & improve zh-Hans translation rt --------- Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors * Update protobufs to 2.7.1 * Add long-turbo preset * Disable Range Test module when primary channel is public/unsecured (#1512) * Update Muzi R1 Neo to actively supported * Disable Range Test module when primary channel is public/unsecured Updated RangeTestConfig.swift to determine whether the primary channel (index 0) is operating without encryption or with a 1-byte minimal PSK. Disabled Range Test UI controls when on a public/default channel to prevent user interaction. Added safety enforcement in the save operation: Range Test enabled flag is automatically forced to false before sending updates to the device. Introduced a computed property isPrimaryChannelPublic following existing code patterns and security indicators (e.g., hexDescription PSK length). Matches the behavior implemented in the Android client for consistent policy across platforms. --------- Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors * Add new device images * Remove print statement, get rid of cut and paste error * Bump version * update the translations (#1540) update the translations * Don't alert (with sound: .default) when updating Live Activity (#1536) * Fix adding channels (#1532) * Full translation into Spanish (#1529) * tapback with any emoji (#1538) * Call clearStaleNodes at start of sendWantConfig (#1535) * NFC Tag contact (#1537) * Accessorymanager background discovery (#1542) * Don't add new BLE devices to the device list in the backgournd * Bump version * Update Meshtastic/MeshtasticApp.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/MeshtasticApp.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Revert "Full translation into Spanish (#1529)" (#1543) This reverts commit f25fdfb89fba70d22cf1d281c62f956f94d6343c. * Revert "update the translations (#1540)" (#1544) This reverts commit cb2fd8cc15185f6b9ce8a940d8ca8d11a32a2f80. * Revert "NFC Tag contact (#1537)" (#1545) This reverts commit 5c22b8b6e0176f4927bfc79234dabe109b215edf. * Update Muzi R1 Neo to actively supported * Revert "Update Muzi R1 Neo to actively supported" * 2.7.6 Working Changes (#1479) * Bump version * Message list performance fixes into 2.7.6 (#1475) * Remove extra want config call when adding a contact * App badge and unnecessary notification fixes (#1455) * - Fix for app badge not going to zero if a message arrives while you have that chat open - Fix for push notifications popping up when a message is received while that chat is open * Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message * Fix: Channels help grammer fix (#1452) * remove outdated TCP not available on Apple devices (#1450) * Update initial onboarding view * remove toggle gating for mac * Update crash reporting opt out in real time * Update onboarding text * Use mDNS text records for node name * TCP IP and port on the connection screen * Hide app icon chooser on mac * Infinite loop hang bugfixes and performance improvements for both `UserMessageList` and `ChannelMessageList` (#1465) * 2.7.5 Working Changes (#1460) * Remove extra want config call when adding a contact * App badge and unnecessary notification fixes (#1455) * - Fix for app badge not going to zero if a message arrives while you have that chat open - Fix for push notifications popping up when a message is received while that chat is open * Fix for cancelling notifications, works now. And scroll to bottom of conversation upon new message * Fix: Channels help grammer fix (#1452) * remove outdated TCP not available on Apple devices (#1450) * Update initial onboarding view * remove toggle gating for mac * Update crash reporting opt out in real time * Update onboarding text --------- Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> * UserEntity: add mostRecentMessage and unreadMessages with early exit when lastMessage is nil, and fetch 1 row (not N) otherwise * UserList: replace 5 slow calls to user.messageList with new fast calls * NodeList: always put the connected node at the top of list (if it matches the node filters) * ChannelEntity: add faster mostRecentPrivateMessage and unreadMessages which fetch 1 row (not N) * ChannelList: replace 5 calls to channel.allPrivateMessage with new fast calls * Fix incorrect appState.unreadDirectMessages calculations * MyInfoEntity: also fix unreadMessages count here to be fast, and use it for appState.unreadChannelMessages * UserMessageList: use @FetchRequest to prevent the N^2 behavior that was happening in calls to allPrivateMessages * Refactor ChannelEntityExtension and MyInfoEntityExtension to be more similar to UserEntityExtension * Remove SwiftUI-infinite-loop-causing `.id(redrawTapbacksTrigger)` in ChannelMessageList and UserMessageList (duplicate row ids) * MyInfoEntityExtension: exclude emoji tapbacks (which never get marked as read anyway) from unread message count * Add SaveChannelLinkData so MessageText and MeshtasticApp can use .sheet(item: ...) and avoid infinite loop hang due to Binding rebuild * ChannelMessageList and UserMessageList: switch to stable messageId for ForEach SwiftUI row identity * ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification * ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear * ChannelMessageList and UserMessageList: block spurious markMessagesAsRead when this View is not active --------- Co-authored-by: Garth Vander Houwen Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> * message-list-performance: revert scrolling changes (#1472) * Revert e0f0b4a0f749d2e83946f2c1297e5c97c9fdf46e (ChannelMessageList and UserMessageList: scroll to bottom onFirstAppear) * Revert "ChannelMessageList and UserMessageList: debouncedScrollToBottom; keyboardWillShowNotification/keyboardDidShowNotification" This reverts commit ee1a7c44157eb6970ec2111fc1ac4d67a44a8238. --------- Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> Co-authored-by: Jake-B Co-authored-by: Mike Robbins * Explicitly set unmessagable, seems unnessary * Add back missing mesh map features * Fix: "Retrieving nodes" significantly slower after reconnect extracted from #1424 (#1477) * Fix: "Retrieving nodes" significantly slower after reconnect (#1424) The node database retrieval was calling context.save() for every single NodeInfo packet received (250 saves for 250 nodes). This caused severe performance degradation on reconnect when CoreData had accumulated state. Root Cause: - nodeInfoPacket() called context.save() immediately for each node - With 250 nodes, this meant 250 individual CoreData save operations - On first connection, CoreData is fresh and fast - On reconnect, CoreData has accumulated change tracking, undo management, and memory pressure, making each save progressively slower - This resulted in 10+ second retrieval times vs 1-2 seconds initially Solution: - Added deferSave parameter to nodeInfoPacket() function - During database retrieval (.retrievingDatabase state), defer all saves - Perform a single batch save when database retrieval completes (when NONCE_ONLY_DB configCompleteID is received) - This reduces 250 saves to 1 save Performance Impact: - Eliminates N individual saves during node database sync - Reduces database retrieval time back to 1-2 seconds on reconnect - Matches first-connection performance consistently Fixes #1424 * Revert *MessageListUnified files --------- Co-authored-by: Martin Bogomolni Co-authored-by: Jake-B * Hide route lines filter from mesh map * Mesh Map: fuzz imprecise locations so they're distinguishable and clickable at the highest zoom levels (#1478) Co-authored-by: Garth Vander Houwen * Fix bad merge * Update Meshtastic/Extensions/CoreData/UserEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Keep list of previous manual connections (#1484) * Keep list of previous manual connections * More descriptive manual connection rows * Merge fixes and new way to show IP on Connect view --------- Co-authored-by: Jake-B * Show who relayed messages (#1486) * Add identification for node that relayed text messages and add count for ammount of relayers of your message * Ack Relays * upsertPositionPacket: don't use future timestamps to set node's lastHeard (#1488) * R1 NEO * Neo * Update Meshtastic/Views/Settings/AppSettings.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove bad if * Git rid of extra environment variable * Update Meshtastic/Accessory/Transports/TCP/TCPTransport.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * MeshMap performance: quick wins (#1490) * MeshMap: change onMapCameraChange frequency to .onEnd so that zooming doesn't cause continuous SwiftUI reevaluation on every frame * MeshMapContent: factor out reducedPrecisionMapCircles into a separate function * MeshMapContent: when multiple reducedPrecisionCircles have the same (lat,lon,radius), just draw one (big perf boost in dense areas) * NodeMap performance improvements for high # positions history (#1480) * NodeMapContent: move Route Lines out of ForEach * NodeMapContent: move Convex Hull out of ForEach * NodeMapContent: Replace `position.nodePosition?` with `node` * NodeMapContent: drop unnecessary LazyVStack in showNodeHistory * NodeMapContent: hoist out nodeColorSwift * Move lineCoords, loraCoords calculations within showRouteLines, showConvexHull respectively * Hoist out repeated node.metadata?.positionFlags lookups / PositionFlags creation * NodeMapContent: remove unused @State * NodeMapSwiftUI: add NodeMapContentEquatableWrapper and NodeMapContentSignature to prevent frequent NodeMapContent recomputation and infinite render loops * NodeMapSwiftUI: disable animation during SwiftUI transactions * NodeMapContent: hoist nodeBorderColor and set allowsHitTesting(false) on history point views * NodeMapContent: prerenderHistoryPointCircle and prerenderHistoryPointArrow to avoid thousands of vector draw operations * NodeMapContent: Shared coordinate list for Route Lines and Convex Hull * NodeMapContent.prerenderHistoryPointArrow: add .frame(width: 16, height: 16) * Fix wantRangeTestPackets to correctly follow rangeTestConfig.enabled (#1489) * Fix interval drop down formatter * Clean up channel qr code functionality. * perferredPeripheralId fix * Set opt in * Retry once 5 second timer. dont throw the error * Queue for peripherals * Fix: hoplimit of dms would always fallback to hops away of the node even when configured hops was higher (#1495) * fix hops setting in dms * Fix hops for exchange position * Final fix * Don't favorite client base * Update device hardware * Prevent nil environment metrics * Bump datadog sdk * fix setting device telemetry enabled (#1515) * Update Muzi R1 Neo to actively supported * fix setting device telemetry enabled --------- Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors * Don't subscribe to mqtt topic if downlink is not on (#1501) * Dont sub if no downlink * moved reload mqtt connect config * Preview enabled in connected devices (#1509) * Update Muzi R1 Neo to actively supported * Preview enabled in connected devices * Fixing indentation * Fixing indentation --------- Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors * UpdateCoreData.updateAnyPacketFrom: mirror firmware's lastHeard/snr/rssi/hopsAway update logic from NodeDB::updateFrom (#1492) * `CLIENT_BASE` add-favorite/role-change confirmation dialog (#1493) * FavoriteNodeButton: refactor task out * AccessoryManager.connectedDeviceRole helper * FavoriteNodeButton: show confirmation dialog when a CLIENT_BASE is trying to add a favorite * addContactFromURL: add comment referencing upcoming change in https://github.com/meshtastic/firmware/pull/8495 * DeviceConfig: role picker: show a warning when selecting CLIENT_BASE, similar to warning shown for ROUTER * Adjust device configuration Client Base warning text * Compass view (#1521) * Added compass view * Added Compass View * Node colors in compass * Update Muzi R1 Neo to actively supported * Update PositionPopover.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update CompassView.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update CompassView.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Views/Helpers/CompassView.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Views/Helpers/CompassView.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove discovery queue * revert problematic retry functionalliy * format file * Update & improve zh-Hans translation (#1523) * Update Muzi R1 Neo to actively supported * update & improve zh-Hans translation rt --------- Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors * Update protobufs to 2.7.1 * Add long-turbo preset * Disable Range Test module when primary channel is public/unsecured (#1512) * Update Muzi R1 Neo to actively supported * Disable Range Test module when primary channel is public/unsecured Updated RangeTestConfig.swift to determine whether the primary channel (index 0) is operating without encryption or with a 1-byte minimal PSK. Disabled Range Test UI controls when on a public/default channel to prevent user interaction. Added safety enforcement in the save operation: Range Test enabled flag is automatically forced to false before sending updates to the device. Introduced a computed property isPrimaryChannelPublic following existing code patterns and security indicators (e.g., hexDescription PSK length). Matches the behavior implemented in the Android client for consistent policy across platforms. --------- Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors * Add new device images * Remove print statement, get rid of cut and paste error --------- Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> Co-authored-by: Jake-B Co-authored-by: Mike Robbins Co-authored-by: Martin Bogomolni Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: jake-b <1012393+jake-b@users.noreply.github.com> Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors Co-authored-by: Charles Pinesky <25388414+Vaidios@users.noreply.github.com> Co-authored-by: Radio <35003866+radiolee@users.noreply.github.com> Co-authored-by: Jason Houk * Initial TAK Server implementation for IOS based TAK clients This is my initial implementation for a TAK Server running inside Meshtastic-Apple. * Update marketing version to 2.7.7 * Update MessageText.swift * Update Meshtastic/Helpers/TAK/CoTMessage.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Helpers/TAK/TAKCertificateManager.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor TAKServerConfig file importer to conditionally allow p12 or pem types; update CoTMessage parsing method name for clarity; enhance mTLS logging in TAKServerManager. --------- Co-authored-by: Garth Vander Houwen Co-authored-by: Garth Vander Houwen Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> Co-authored-by: Jake-B Co-authored-by: Mike Robbins Co-authored-by: Martin Bogomolni Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: jake-b <1012393+jake-b@users.noreply.github.com> Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Co-authored-by: Jonathan Bennett Co-authored-by: Charles Pinesky <25388414+Vaidios@users.noreply.github.com> Co-authored-by: Radio <35003866+radiolee@users.noreply.github.com> Co-authored-by: Jason Houk Co-authored-by: MGJ <62177301+MGJ520@users.noreply.github.com> Co-authored-by: Alvaro Samudio Co-authored-by: Mathew Kamkar <578302+matkam@users.noreply.github.com> Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> * Fix * Add localization strings * CoreData Writes wrapped in an Actor (#1569) * MeshPackets.swift into an actor and make async * Move upserts in UpdateCoreData to the MeshPackets actor * Update Meshtastic/Views/Settings/AppSettings.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update Meshtastic/Persistence/UpdateCoreData.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Jake-B Co-authored-by: Ben Meadors Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor channel packet sending to use async/await pattern * Fix call * `// swiftlint:disable` overrides for TAK * Revert "Fix call" This reverts commit 097ddbd43fd1dcb73328fce42bcb20d0af3bd889. * Move CoreData operations onto background actor * Fix for discovery reconnect issues (#1574) Co-authored-by: Jake-B * Remove specific TAK client references Be TAK client neutral. * Update TAKDataPackageGenerator.swift Add client cert to datapackage generation for TAK clients who require it. (TAK Aware afaik) * Ble improvements (#1583) * Fix for discovery reconnect issues * Allow disconnect earlier in the connection process --------- Co-authored-by: Jake-B * Update linker flag for ios 17 crash * Update protobufs * Ru/translation (#1571) * Russian localization is 50% * Russian localization is 100% * fix Taiwan language Co-authored-by: Sergei K Co-authored-by: Dmitriy Petrov * Fix connectToPreferredDevice parameter type to resolve compilation error (#1590) * Initial plan * Fix connectToPreferredDevice signature to accept optional device parameter 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> * Fix TAK certificate security and storage consistency Address multiple certificate management issues identified in code review: - Fix server P12 storage/retrieval inconsistency: getActiveServerP12Data() now reads from Keychain instead of UserDefaults, matching where importServerIdentity() stores the data - Improve client P12 security: migrate storage from UserDefaults to Keychain to protect certificate data from unauthorized access and backups - Fix Keychain cleanup: clearCustomCertificateData() now properly deletes both server and client P12 data from Keychain during reset - Add force-unwrap safety: replace sec_identity_create() force-unwrap with guard statement to prevent crashes, throwing TAKServerError.tlsConfigurationFailed instead - Remove unused backup certificates: delete Resources/Certificates/backup/ folder to reduce app bundle size and avoid confusion about which certificates are active All certificate data (both server and client P12 files and passwords) now consistently use Keychain storage for improved security and proper cleanup during resets. * Update TAKDataPackageGenerator.swift Fix TAKAware's data package ingestion error. This was due to us generating a DP with certs in a cert subdirectory. However TAKAware's parser did not check subdirectories. 3 areas changed in TAKDataPackageGenerator.swift: 1. Removed certs/ subdirectory creation (old lines 50-52) — the pref file and certificates are now written directly to tempDir instead of certsDir 2. Files written to tempDir instead of certsDir — the pref file (line 72), truststore (line 80), and client cert (line 89) all go to the package root 3. Manifest zipEntry values flattened (lines 216-218) — changed from certs\filename to just filename Pref file cert/ paths left unchanged (lines 170, 172) — these are ATAK convention for the client's internal cert store path, not zip-relative paths. SwiftTAK strips them to just the filename anyway, and ATAK/iTAK interpret them the same way. The resulting zip structure will now be: Meshtastic_TAK_Server/ ├── manifest.xml ├── meshtastic-server.pref ├── truststore.p12 └── DeviceName.p12 This matches the flat structure used by all passing SwiftTAK test packages (tak-all-top.zip, tak-subfolder-all-top.zip, etc.). * Fix copilot identified issues #10 — manifest.xml: Removed certs\ prefix from zip entry paths so they match the actual flat file structure. #11 — taky-server.pref: Fixed cert/ path prefixes to match the flat structure, and replaced hardcoded atakatak passwords with YOURPASSWORD placeholders. #12 — TAKServerConfig.swift:41: Replaced force-unwraps (UTType(filenameExtension: "p12")!) with nil-coalescing fallbacks (?? .pkcs12 and ?? .plainText). #13 — TAKConnection.swift:26: Removed the unused bufferTrimSize constant. --------- Co-authored-by: Ben Meadors Co-authored-by: Gnome Adrift <646322+gnomeadrift@users.noreply.github.com> Co-authored-by: Zain Kergaye <62440012+ZainKergaye@users.noreply.github.com> Co-authored-by: NillRudd <102033730+NillRudd@users.noreply.github.com> Co-authored-by: Jake-B Co-authored-by: Mike Robbins Co-authored-by: Martin Bogomolni Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: jake-b <1012393+jake-b@users.noreply.github.com> Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Co-authored-by: Jonathan Bennett Co-authored-by: Charles Pinesky <25388414+Vaidios@users.noreply.github.com> Co-authored-by: Radio <35003866+radiolee@users.noreply.github.com> Co-authored-by: Jason Houk Co-authored-by: MGJ <62177301+MGJ520@users.noreply.github.com> Co-authored-by: Alvaro Samudio Co-authored-by: Mathew Kamkar <578302+matkam@users.noreply.github.com> Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com> Co-authored-by: Dmitriy Q <40627944+krotesk@users.noreply.github.com> Co-authored-by: Sergei K Co-authored-by: Dmitriy Petrov Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --- Localizable.xcstrings | 7476 ++++++++++++++++- Meshtastic.xcodeproj/project.pbxproj | 76 +- .../xcshareddata/swiftpm/Package.resolved | 10 +- .../AccessoryManager+Connect.swift | 6 +- .../AccessoryManager+Discovery.swift | 12 +- .../AccessoryManager+FromRadio.swift | 75 +- .../AccessoryManager+TAK.swift | 209 + .../AccessoryManager+ToRadio.swift | 118 +- .../Accessory Manager/AccessoryManager.swift | 74 +- .../Bluetooth Low Energy/BLEConnection.swift | 32 +- .../Bluetooth Low Energy/BLETransport.swift | 47 +- Meshtastic/Extensions/Logger.swift | 3 + .../Helpers/LocalNotificationManager.swift | 20 +- Meshtastic/Helpers/Logger.swift | 19 - Meshtastic/Helpers/MeshPackets.swift | 2211 ++--- Meshtastic/Helpers/TAK/CoTMessage.swift | 544 ++ Meshtastic/Helpers/TAK/CoTXMLParser.swift | 335 + Meshtastic/Helpers/TAK/EXICodec.swift | 148 + Meshtastic/Helpers/TAK/FountainCodec.swift | 630 ++ .../Helpers/TAK/GenericCoTHandler.swift | 399 + .../Helpers/TAK/TAKCertificateManager.swift | 788 ++ Meshtastic/Helpers/TAK/TAKConnection.swift | 497 ++ .../Helpers/TAK/TAKDataPackageGenerator.swift | 290 + .../Helpers/TAK/TAKMeshtasticBridge.swift | 516 ++ Meshtastic/Helpers/TAK/TAKServerManager.swift | 442 + Meshtastic/Meshtastic.entitlements | 2 + Meshtastic/MeshtasticApp.swift | 6 + Meshtastic/MeshtasticAppDelegate.swift | 4 + Meshtastic/Persistence/UpdateCoreData.swift | 3125 +++---- Meshtastic/Resources/Certificates/ca.pem | 23 + Meshtastic/Resources/Certificates/client.p12 | Bin 0 -> 3827 bytes Meshtastic/Resources/Certificates/server.p12 | Bin 0 -> 3859 bytes Meshtastic/Router/NavigationState.swift | 1 + Meshtastic/Views/Connect/Connect.swift | 50 +- Meshtastic/Views/Messages/ChannelList.swift | 8 +- Meshtastic/Views/Messages/MessageText.swift | 3 +- .../Views/Messages/TapbackInputView.swift | 13 + Meshtastic/Views/Messages/UserList.swift | 6 +- Meshtastic/Views/Nodes/DeviceMetricsLog.swift | 10 +- .../Views/Nodes/EnvironmentMetricsLog.swift | 6 +- Meshtastic/Views/Nodes/PaxCounterLog.swift | 10 +- Meshtastic/Views/Nodes/PositionLog.swift | 10 +- Meshtastic/Views/Nodes/PowerMetricsLog.swift | 10 +- Meshtastic/Views/Settings/AppSettings.swift | 33 +- .../Views/Settings/Config/DeviceConfig.swift | 6 +- Meshtastic/Views/Settings/Settings.swift | 15 + .../Views/Settings/TAKServerConfig.swift | 390 + .../Sources/meshtastic/admin.pb.swift | 363 +- .../Sources/meshtastic/config.pb.swift | 11 +- .../Sources/meshtastic/localonly.pb.swift | 38 +- .../Sources/meshtastic/mesh.pb.swift | 46 +- .../Sources/meshtastic/module_config.pb.swift | 272 +- .../Sources/meshtastic/portnums.pb.swift | 12 +- .../Sources/meshtastic/telemetry.pb.swift | 286 +- itak-example-data-package/iphone.p12 | Bin 0 -> 3009 bytes itak-example-data-package/manifest.xml | 12 + itak-example-data-package/server.p12 | Bin 0 -> 3009 bytes itak-example-data-package/taky-server.pref | 16 + 58 files changed, 16907 insertions(+), 2857 deletions(-) create mode 100644 Meshtastic/Accessory/Accessory Manager/AccessoryManager+TAK.swift delete mode 100644 Meshtastic/Helpers/Logger.swift create mode 100644 Meshtastic/Helpers/TAK/CoTMessage.swift create mode 100644 Meshtastic/Helpers/TAK/CoTXMLParser.swift create mode 100644 Meshtastic/Helpers/TAK/EXICodec.swift create mode 100644 Meshtastic/Helpers/TAK/FountainCodec.swift create mode 100644 Meshtastic/Helpers/TAK/GenericCoTHandler.swift create mode 100644 Meshtastic/Helpers/TAK/TAKCertificateManager.swift create mode 100644 Meshtastic/Helpers/TAK/TAKConnection.swift create mode 100644 Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift create mode 100644 Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift create mode 100644 Meshtastic/Helpers/TAK/TAKServerManager.swift create mode 100644 Meshtastic/Resources/Certificates/ca.pem create mode 100644 Meshtastic/Resources/Certificates/client.p12 create mode 100644 Meshtastic/Resources/Certificates/server.p12 create mode 100644 Meshtastic/Views/Settings/TAKServerConfig.swift create mode 100644 itak-example-data-package/iphone.p12 create mode 100644 itak-example-data-package/manifest.xml create mode 100644 itak-example-data-package/server.p12 create mode 100644 itak-example-data-package/taky-server.pref diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 3dc62e68..03458818 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -181,6 +181,12 @@ "value" : "ボード用のPIN_GPS_ENを(再)定義してください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Пере)определить PIN_GPS_EN для вашей платы." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -256,6 +262,12 @@ "value" : "%1$@ - %2$@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -297,6 +309,12 @@ "value" : "%1$@ - %2$@ - %3$@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ - %3$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -338,6 +356,12 @@ "value" : "%1$@ - %2$@ 送信 %3$@ 受信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ В обратном %3$@ направлении" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -378,6 +402,12 @@ "value" : "%@ - 応答なし" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - Нет ответа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -418,6 +448,12 @@ "value" : "%@ - 送信されませんでした" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - Не отправлены" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -458,6 +494,12 @@ "value" : "%1$@ (%2$@)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ (%2$@)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -480,6 +522,7 @@ "shouldTranslate" : false }, "%@ %@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -499,6 +542,12 @@ "value" : "%1$@ %2$@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -540,6 +589,12 @@ "value" : "%1$@ %2$lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -581,6 +636,12 @@ "value" : "%@ 離れた場所" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ доступны " + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -621,6 +682,12 @@ "value" : "%1$@ は最大 %2$@ バイトまで設定できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ может иметь длину до %@ байт." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -655,6 +722,12 @@ "value" : "%@ チャンネル?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ каналов?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -677,6 +750,12 @@ }, "%@ config data was requested via PKC admin but no response has been returned from the remote node." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ данные конфигурации были запрошены через PKC admin, но ответа от удаленной ноды получено не было." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -699,6 +778,12 @@ "value" : "%@ dB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -739,6 +824,12 @@ "value" : "%1$@, %2$@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -780,6 +871,12 @@ "value" : "%1$@: %2$lld / %3$lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$lld / %3$lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -815,6 +912,12 @@ "value" : "%@%%" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -849,6 +952,12 @@ "value" : "%@°F" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@°F" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -883,6 +992,12 @@ "value" : "%@mA" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@мА" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -911,6 +1026,12 @@ "value" : "%@V" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : " %@В" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -939,6 +1060,12 @@ "value" : "%d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1009,6 +1136,36 @@ "value" : "%dホップ" } }, + "ru" : { + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d хопа" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d хопов" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d хоп" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d хопов" + } + } + } + } + }, "sr" : { "variations" : { "plural" : { @@ -1060,6 +1217,7 @@ } }, "%d%%" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -1073,6 +1231,12 @@ "value" : "%d%%" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d%%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1094,7 +1258,14 @@ } }, "%f%%" : { + "extractionState" : "stale", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%f%%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1104,6 +1275,7 @@ } }, "%lf" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -1117,6 +1289,12 @@ "value" : "%lf" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lf" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1151,6 +1329,12 @@ "value" : "%lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1172,6 +1356,7 @@ } }, "%lld %@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1179,6 +1364,12 @@ "value" : "%1$lld %2$@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld %2$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1225,6 +1416,36 @@ } } }, + "ru" : { + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld особенности" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld особенностей" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld особенность" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld особенностей" + } + } + } + } + }, "sr" : { "variations" : { "plural" : { @@ -1271,6 +1492,12 @@ "value" : "%lldホップ以下" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld или меньше хопов доступны" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1299,6 +1526,12 @@ "value" : "計 %lld 件の読み取り値" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld общее количество показаний" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1327,6 +1560,12 @@ "value" : "計 %lld 件の検出イベント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld общее количество событий обнаружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1355,6 +1594,12 @@ "value" : "%lld%%" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1395,6 +1640,12 @@ "value" : "%llddb送信電力" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%llddb мощность передачи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1435,6 +1686,12 @@ "value" : "%llddBm送信電力" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : " %llddBm мощность передачи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1466,6 +1723,12 @@ "value" : "< 1%" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "< 1%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1488,7 +1751,15 @@ }, "⚠️ The configured value: (%@) is not one of the optimized options." : { "comment" : "A warning label below the picker, indicating that the selected update interval is not one of the optimized options.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Настроенное значение: (%@) не является одним из оптимизированных параметров." + } + } + } }, "🦕 End of life Version 🦖 ☄️" : { "localizations" : { @@ -1504,6 +1775,12 @@ "value" : "🦕 サポート終了バージョン 🦖 ☄️" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦕 Неподдерживаемая версия 🦖 ☄️" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1519,6 +1796,7 @@ } }, "0" : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { @@ -1554,6 +1832,12 @@ "value" : "1バイト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 байт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1588,6 +1872,12 @@ "value" : "1ホップ先" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 хоп доступен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1609,6 +1899,7 @@ } }, "2.4 Ghz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -1622,6 +1913,12 @@ "value" : "2.4GHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "2.4 ГГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1650,6 +1947,12 @@ "value" : "7" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "7" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1678,6 +1981,12 @@ "value" : "12時間表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "12-часовой формат" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1700,6 +2009,12 @@ "value" : "25" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "25" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1734,6 +2049,12 @@ "value" : "50" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "50" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1768,6 +2089,12 @@ "value" : "75" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "75" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1802,6 +2129,12 @@ "value" : "100" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "100" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1836,6 +2169,12 @@ "value" : "128 bit" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "128 бит" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1881,6 +2220,12 @@ "value" : "256 bit" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "256 бит" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1901,8 +2246,18 @@ } } }, + "8089" : { + "comment" : "The port number for the TAK Server.", + "isCommentAutoGenerated" : true + }, "A channel index of 0 indicates the primary channel where broadcast packets are sent from. Location data is broadcast from the first channel where it is enabled with firmware 2.7 forward." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Индекс канала, равный 0, указывает на основной канал, по которому отправляются широковещательные пакеты. Данные о местоположении передаются по первому каналу, где они включены с помощью встроенного программного обеспечения 2.7 и выше." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1910,6 +2265,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 green lock means the channel is securely encrypted with either a 128 or 256 bit AES key." : { "localizations" : { @@ -1919,6 +2277,12 @@ "value" : "緑色の鍵は、チャンネルが128ビットまたは256ビットのAESキーで安全に暗号化されていることを意味します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зеленый замок означает, что канал надежно зашифрован с помощью 128-битного или 256-битного ключа AES." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1965,6 +2329,12 @@ "value" : "In a Meshtastic LoRa Mesh there are up to 8 channels. The first one is the Primary channel where most activity happens and is required. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. It talks on its primary and your secondary channel. A channel with the name 'admin' controls nodes remotely. Other channels are for private groups, each with its own key." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "QR-код Meshtastic содержит конфигурацию LoRa и значения каналов, необходимые для взаимодействия радиостанций. Вы можете поделиться полной конфигурацией канала, используя опцию \"Заменить каналы\", если вы выберете \"Добавить каналы\", ваши общие каналы будут добавлены к каналам принимающей радиостанции." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -1993,6 +2363,12 @@ }, "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" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Красный открытый замок означает, что канал небезопасно зашифрован и используется для передачи точных данных о местоположении, в нем либо вообще не используется ключ, либо известен ключ размером в 1 байт." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2003,6 +2379,12 @@ }, "A red open lock with a warning means the channel is not securely encrypted and is used for precise location data which is being uplinked to the internet via MQTT, it uses either no key at all or a 1 byte known key." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Красный открытый замок с предупреждением означает, что канал небезопасно зашифрован и используется для передачи точных данных о местоположении, которые передаются в Интернет через MQTT, при этом ключ либо не используется вообще, либо имеет размер в 1 байт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2025,6 +2407,12 @@ "value" : "Trace Route が送信されましたが、応答が受信されませんでした。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Был отправлен запрос трассировки маршрута, ответа получено не было." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2052,7 +2440,15 @@ }, "A yellow open lock means the channel is not securely encrypted but it is not used for precise location data, it uses either no key at all or a 1 byte known key." : { "comment" : "A description of a yellow open lock in the Channels Help view.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Желтый открытый замок означает, что канал небезопасно зашифрован, но он не используется для передачи точных данных о местоположении, в нем либо вообще нет ключа, либо известен ключ размером в 1 байт." + } + } + } }, "About" : { "localizations" : { @@ -2074,6 +2470,12 @@ "value" : "概要" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О программе" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2108,6 +2510,12 @@ "value" : "Meshtasticについて" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О Meshtastic" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2142,6 +2550,12 @@ "value" : "精度 %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Точность %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2170,6 +2584,12 @@ "value" : "応答SNR: %@ dB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ack SNR: %@ dB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2198,6 +2618,12 @@ "value" : "応答時間: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время Ack: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2213,6 +2639,7 @@ } }, "Acknowledged" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2250,6 +2677,12 @@ "value" : "Potwierdzono" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтверждение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -2290,6 +2723,12 @@ "value" : "他のノードで確認済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтверждено другой нодой" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2324,6 +2763,12 @@ "value" : "アクション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2358,6 +2803,12 @@ "value" : "アクティブ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2392,6 +2843,12 @@ "value" : "アクティビティ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активность" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2444,6 +2901,12 @@ "value" : "ADC Override" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переопределение АЦП" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -2463,6 +2926,9 @@ } } } + }, + "Add CA" : { + }, "Add Channel" : { "localizations" : { @@ -2478,6 +2944,12 @@ "value" : "チャンネルを追加" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить канал" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2506,6 +2978,12 @@ "value" : "チャンネルを追加" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить каналы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2528,6 +3006,12 @@ "value" : "連絡先を追加" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить контакт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2556,6 +3040,12 @@ "value" : "Meshtasticノード%@を連絡先に追加" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить ноду Meshtastic %@ как контакт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2590,6 +3080,12 @@ "value" : "お気に入りに追加" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить в избранные" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2618,6 +3114,12 @@ "value" : "追加のヘルプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дополнительная помощь" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2652,6 +3154,12 @@ "value" : "住所" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адрес" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2680,6 +3188,12 @@ "value" : "管理者キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ключи админа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2702,6 +3216,12 @@ "value" : "管理" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Администрирование" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2730,6 +3250,12 @@ "value" : "管理機能が有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Администрирование включено" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2758,6 +3284,12 @@ "value" : "上級" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дополнительно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2792,6 +3324,12 @@ "value" : "高度なデバイスGPS" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширенное устройство GPS" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2861,6 +3399,12 @@ "value" : "高度な位置フラグ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширенные флаги позиционирования" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2882,6 +3426,7 @@ } }, "After" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2901,6 +3446,12 @@ "value" : "後" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "После" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -2971,6 +3522,36 @@ } } }, + "ru" : { + "variations" : { + "plural" : { + "few" : { + "stringUnit" : { + "state" : "translated", + "value" : "После %lld дней" + } + }, + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "После %lld дней" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "После %lld дня" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "После %lld дней" + } + } + } + } + }, "sr" : { "variations" : { "plural" : { @@ -3035,6 +3616,12 @@ "value" : "Po zapisaniu wartości konfiguracji węzeł zostanie zrestartowany." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "После сохранения значений нода перезагрузится." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3062,6 +3649,7 @@ } }, "Afternoon" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3081,6 +3669,12 @@ "value" : "午後" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "После полудня" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3133,6 +3727,12 @@ "value" : "Czas nadawania" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эфирное время" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3173,6 +3773,12 @@ "value" : "アラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3201,6 +3807,12 @@ "value" : "ベル受信時にGPIOブザーでアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждающий зуммер GPIO при получении звукового сигнала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3235,6 +3847,12 @@ "value" : "メッセージ受信時にGPIOブザーでアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждающий зуммер GPIO при получении сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3263,6 +3881,12 @@ "value" : "ベル受信時にGPIO振動モーターでアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждающий вибромотор GPIO о получении звукового сигнала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3297,6 +3921,12 @@ "value" : "メッセージ受信時にGPIO振動モーターでアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждающий вибромотор GPIO о получении сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3325,6 +3955,12 @@ "value" : "ベル受信時にアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждение при получении звукового сигнала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3359,6 +3995,12 @@ "value" : "メッセージ受信時にアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждение при получении сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3393,6 +4035,12 @@ "value" : "全て" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Все" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3427,6 +4075,12 @@ "value" : "位置要求を許可" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить запрос позиции" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3455,6 +4109,12 @@ "value" : "高度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выс" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3489,6 +4149,12 @@ "value" : "高度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высота" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3523,6 +4189,12 @@ "value" : "高度 %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высота %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3551,6 +4223,12 @@ "value" : "高度ジオイド分離" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Значение коррекции высоты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3579,6 +4257,12 @@ "value" : "高度は平均海面レベル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высота над уровнем моря" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3594,6 +4278,7 @@ } }, "Always On" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3631,6 +4316,12 @@ "value" : "Zawsze włączone" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всегда включено" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3677,6 +4368,12 @@ "value" : "常に北を指す" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всегда указывать на север" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3723,6 +4420,12 @@ "value" : "環境照明" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Окружающее освещение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3781,6 +4484,12 @@ "value" : "環境照明設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка окружающего освещения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3808,6 +4517,7 @@ } }, "Ambient Lighting module config received: %@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -3839,6 +4549,12 @@ "value" : "Ambient Lighting module config received: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля внешнего освещения: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3885,6 +4601,12 @@ "value" : "手頃な価格の低電力無線機で動作する、オープンソース、オフグリッド、分散型メッシュネットワーク。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это автономная децентрализованная mesh-сеть с открытым исходным кодом, работающая на недорогих и маломощных радиостанциях." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3919,6 +4641,12 @@ "value" : "見逃したメッセージは再配信されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Все пропущенные сообщения будут доставлены повторно." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3940,6 +4668,7 @@ } }, "App connected or stand alone messaging device." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3977,6 +4706,12 @@ "value" : "Klient (domyślnie) - Klient połączony z aplikacją." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключенное к приложению или автономное устройство для обмена сообщениями." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4023,6 +4758,12 @@ "value" : "App データ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4057,6 +4798,12 @@ "value" : "アプリファイル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить файлы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4079,6 +4826,12 @@ }, "App Icon" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Иконка приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4095,6 +4848,12 @@ "value" : "Mitteilungseinstellungen" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4123,6 +4882,12 @@ "value" : "アプリ設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4157,6 +4922,12 @@ "value" : "Appleアプリ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приложения Apple" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4197,6 +4968,12 @@ "value" : "正確な位置" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приблизительное местоположение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4225,6 +5002,12 @@ "value" : "このメッセージを削除してもよろしいですか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите удалить это сообщение?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4265,6 +5048,12 @@ "value" : "ノードを工場出荷時設定にリセットしてもよろしいですか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены что хотите сбросить настройки ноды?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4323,6 +5112,12 @@ "value" : "Jesteś pewny?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены?" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4350,6 +5145,7 @@ } }, "Australia / New Zealand" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -4363,6 +5159,12 @@ "value" : "オーストラリア / ニュージーランド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Австралия / Новая Зеландия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4379,6 +5181,12 @@ }, "Automatically Connect" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматическое подключение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4401,6 +5209,12 @@ "value" : "指定した間隔に基づいて、カルーセルのように画面の次のページに自動的に切り替わります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматический переход к следующей странице на экране, как в карусели, с заданным интервалом." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4441,6 +5255,12 @@ "value" : "利用可能なモデムプリセット、デフォルトは Long Fast です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступны предустановки модема, по умолчанию используется Long Fast." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4499,6 +5319,12 @@ "value" : "Dostępne radia" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступные радиостанции" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4526,6 +5352,7 @@ } }, "Back" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -4563,6 +5390,12 @@ "value" : "Wstecz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назад" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4597,6 +5430,12 @@ "value" : "バックアップ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Резервная копия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4613,6 +5452,12 @@ "value" : "プライベートキーをiCloudキーチェーンにバックアップします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать резервную копию приватного ключа в связку ключей iCloud." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4622,6 +5467,7 @@ } }, "Bad" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -4635,6 +5481,12 @@ "value" : "悪い" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Плохой" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4650,6 +5502,7 @@ } }, "Bad Request" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -4681,6 +5534,12 @@ "value" : "Złe żądanie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Плохой запрос" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4727,6 +5586,12 @@ "value" : "帯域幅" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пропускная способность" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4748,6 +5613,7 @@ } }, "Bar" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -4761,6 +5627,12 @@ "value" : "バー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Давл" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4795,6 +5667,12 @@ "value" : "バー系列" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Диапазон давления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4816,6 +5694,7 @@ } }, "Barometric Pressure" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -4829,6 +5708,12 @@ "value" : "気圧" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Атмосферное давление" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4881,6 +5766,12 @@ "value" : "Battery" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Батарея" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4946,6 +5837,12 @@ "value" : "Poziom naładowania baterii" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уровень заряда" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5011,6 +5908,12 @@ "value" : "Poziom naładowania baterii %" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уровень заряда %" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5076,6 +5979,12 @@ "value" : "Poziom naładowania baterii %d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уровень заряда %d" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5116,6 +6025,12 @@ "value" : "ボーレート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Бод" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5137,12 +6052,27 @@ } }, "Bearing: %@" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Несущая: %@" + } + } + } }, "Bearing: N/A" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Несущая: Н/А" + } + } + } }, "Biking" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -5162,6 +6092,12 @@ "value" : "サイクリング" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Езда на велосипеде" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5196,6 +6132,12 @@ "value" : "BLE" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLE" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5254,6 +6196,12 @@ "value" : "Pin BLE musi mieć długość 6 cyfr." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длина пин-кода BLE должна быть 6 цифр. " + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5318,6 +6266,12 @@ "value" : "Bluetooth" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5382,6 +6336,12 @@ "value" : "Konfiguracja Bluetooth" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка Bluetooth" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5409,6 +6369,7 @@ } }, "Bluetooth config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -5446,6 +6407,12 @@ "value" : "Otrzymano konfigurację Bluetooth: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Принята конфигурация Bluetooth: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5476,6 +6443,12 @@ "comment" : "A heading displayed on a view that guides users to configure Bluetooth connectivity for the app.", "isCommentAutoGenerated" : true, "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth-подключение " + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5486,6 +6459,12 @@ }, "Bold Heading" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Жирный заголовок" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5496,6 +6475,12 @@ }, "Bold the heading text on the screen." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выделять жирным шрифтом текст заголовка на экране." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5505,9 +6490,17 @@ } }, "Broadcast Device Metrics" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показатели широковещательного устройства" + } + } + } }, "Broadcast Interval" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -5521,6 +6514,12 @@ "value" : "ブロードキャスト間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал вещания" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5542,6 +6541,7 @@ } }, "Broadcasts GPS position packets as priority." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -5579,6 +6579,12 @@ "value" : "Tracker - Do użytku z urządzeniami przeznaczonymi jako śledzenie GPS. Pakiety pozycyjne wysyłane z tego urządzenia będą miały wyższy priorytet, z nadawaniem pozycji co dwie minuty. Inteligentna transmisja pozycji będzie domyślnie wyłączona." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Передает пакеты данных о местоположении GPS в качестве приоритетных." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5606,6 +6612,7 @@ } }, "Broadcasts location as message to default channel regularly for to assist with device recovery." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -5643,6 +6650,12 @@ "value" : "Broadcasts location as message to default channel regularly for to assist with device recovery." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Регулярно транслирует местоположение в виде сообщения на канал по умолчанию, чтобы помочь с поиском устройства." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5670,6 +6683,7 @@ } }, "Broadcasts telemetry packets as priority." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -5707,6 +6721,12 @@ "value" : "Broadcasts telemetry packets as priority." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Передает пакеты телеметрии в приоритетном порядке." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5747,6 +6767,12 @@ "value" : "ボタンGPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO кнопки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5781,6 +6807,12 @@ "value" : "完成品無線機を購入" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Покупайте комплектные радиоприемники" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5815,6 +6847,12 @@ "value" : "ブザーGPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO зуммер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5843,6 +6881,12 @@ "value" : "この機能を有効にすることで、お客様のデバイスのリアルタイム地理位置が暗号化されずにMQTTプロトコル経由で送信されることを承知し、明示的に同意することを認めます。この位置データは、ライブマップ報告、デバイス追跡、関連テレメトリー機能などの目的で使用される場合があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включив эту функцию, вы подтверждаете и даете явное согласие на передачу данных о географическом местоположении вашего устройства в режиме реального времени по протоколу MQTT без шифрования. Эти данные о местоположении могут использоваться для таких целей, как создание отчетов по карте в режиме реального времени, отслеживание устройства и связанные с этим функции телеметрии." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5858,6 +6902,7 @@ } }, "Bytes" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -5895,6 +6940,12 @@ "value" : "Bajty" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Байты" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5930,6 +6981,12 @@ "value" : "使用バイト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используемые байты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5958,6 +7015,12 @@ "value" : "コールサイン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позывной" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5998,6 +7061,12 @@ "value" : "コールサインは空にできません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позывной не должен быть пустым" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6056,6 +7125,12 @@ "value" : "Anuluj" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6083,6 +7158,7 @@ } }, "Canned Message module config received: %@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -6114,6 +7190,12 @@ "value" : "Otrzymano konfigurację modułu wiadomości gotowych: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получено сохраненное сообщение о конфигурации модуля: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6178,6 +7260,12 @@ "value" : "Gotowe wiadomości" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохраненные сообщения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6242,6 +7330,12 @@ "value" : "Konfiguracja gotowych wiadomości" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация сохраненных сообщений" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6269,6 +7363,7 @@ } }, "Canned Messages Messages Received For: %@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -6300,6 +7395,12 @@ "value" : "Otrzymano Wiadomości Gotowe Dla: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохраненные сообщения полученные для: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6340,6 +7441,12 @@ "value" : "カルーセル間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал карусели" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6374,6 +7481,12 @@ "value" : "カテゴリ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Категории" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6414,6 +7527,12 @@ "value" : "カテゴリ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Категория" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6442,6 +7561,12 @@ "value" : "Ch1 現在" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ток к1" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6470,6 +7595,12 @@ "value" : "Ch1 電圧" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Напряжение к1" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6498,6 +7629,12 @@ "value" : "Ch2 現在" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ток к2" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6526,6 +7663,12 @@ "value" : "Ch2 電圧" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Напряжение к2" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6554,6 +7697,12 @@ "value" : "Ch3 現在" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ток к3" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6582,6 +7731,12 @@ "value" : "Ch3 電圧" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Напряжение к3" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6634,6 +7789,12 @@ "value" : "Kanał" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6674,6 +7835,12 @@ "value" : "チャンネル0を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 0 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6708,6 +7875,12 @@ "value" : "チャンネル1" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 1" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6736,6 +7909,12 @@ "value" : "チャンネル1を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 1 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6770,6 +7949,12 @@ "value" : "チャンネル2" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 2" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6798,6 +7983,12 @@ "value" : "チャンネル2を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 2 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6832,6 +8023,12 @@ "value" : "チャンネル 3" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 3" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6860,6 +8057,12 @@ "value" : "チャンネル 3を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 3 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6894,6 +8097,12 @@ "value" : "チャンネル 4を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 4 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6928,6 +8137,12 @@ "value" : "チャンネル 5を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 5 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6962,6 +8177,12 @@ "value" : "チャンネル 6を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 6 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6996,6 +8217,12 @@ "value" : "チャンネル 7を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 7 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7030,6 +8257,12 @@ "value" : "チャンネル詳細" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сведения о канале" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7064,6 +8297,12 @@ "value" : "チャンネル名" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя канала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7098,6 +8337,12 @@ "value" : "チャンネル番号は0から7の間である必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Номер канала должен быть от 0 до 7." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7132,6 +8377,12 @@ "value" : "チャンネル役割" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Роль канала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7166,6 +8417,12 @@ "value" : "チャンネル URL" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL канала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7218,6 +8475,12 @@ "value" : "Wykorzystanie kanału" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использование канала" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -7258,6 +8521,12 @@ "value" : "チャンネル使用率 %@%%" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использование канала %@%%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7310,6 +8579,12 @@ "value" : "Kanały" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каналы" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -7337,6 +8612,7 @@ } }, "Channels being added from the QR code did not save. When adding channels the names must be unique." : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -7350,6 +8626,12 @@ "value" : "QRコードから追加されたチャンネルが保存されませんでした。チャンネルを追加する際は、名前が一意である必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каналы, добавленные с помощью QR-кода, не сохраняются. При добавлении каналов названия должны быть уникальными." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7378,6 +8660,12 @@ "value" : "チャンネルヘルプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каналы помощи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7387,6 +8675,7 @@ } }, "Chart" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -7400,6 +8689,12 @@ "value" : "チャート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Диаграмма" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7434,6 +8729,12 @@ "value" : "充電中" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "CHG" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7449,6 +8750,7 @@ } }, "China" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -7462,6 +8764,12 @@ "value" : "中国" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Китай" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7477,7 +8785,14 @@ } }, "Chirpy" : { + "extractionState" : "stale", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Щебетун" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7500,6 +8815,12 @@ "value" : "クリア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7558,6 +8879,12 @@ "value" : "Wyczyść dane aplikacji" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить данные приложения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -7598,6 +8925,12 @@ "value" : "ログクリア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить журнал" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7626,6 +8959,12 @@ "value" : "古いノードをクリア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить устаревшие ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7635,6 +8974,7 @@ } }, "Client" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -7648,6 +8988,12 @@ "value" : "クライアント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Клиент" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7670,9 +9016,24 @@ }, "Client Base should only favorite other nodes you control. Improper use will hurt your local mesh." : { "comment" : "A message displayed in a confirmation dialog when trying to favorite a node as a CLIENT_BASE.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используя Client Base, вы должны добавлять в избранное только подконтрольные вам ноды. Неправильное использование может ухудшить сетевую связность" + } + } + } + }, + "Client CA Certificate" : { + + }, + "Client Configuration" : { + }, "Client Hidden" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -7692,6 +9053,12 @@ "value" : "クライアント Hidden" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрытый клиент" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7726,6 +9093,12 @@ "value" : "クライアント履歴" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История клиента" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7760,6 +9133,12 @@ "value" : "クライアント履歴リクエストを送信しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлен запрос истории клиента" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7781,6 +9160,7 @@ } }, "Client Mute" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -7794,6 +9174,12 @@ "value" : "クライアント無音" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заглушить клиента" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7828,6 +9214,12 @@ "value" : "クライアントオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры клиента" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7862,6 +9254,12 @@ "value" : "時計回りロータリーイベント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вращение по часовой стрелке" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7920,6 +9318,12 @@ "value" : "Zamknij" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -7960,6 +9364,12 @@ "value" : "符号化率" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость кодирования" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8000,6 +9410,12 @@ "value" : "色" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8028,6 +9444,12 @@ "value" : "Bleibe mit deinen Freunden und deiner Community in Verbindung, auch abseits vom Mobilfunknetz." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Общайтесь вне сети со своими друзьями и сообществом без использования сотовой связи." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8050,6 +9472,12 @@ "value" : "通信中" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Общение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8078,6 +9506,12 @@ "value" : "コミュニティサポート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поддержка сообщества" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8093,7 +9527,14 @@ } }, "Compass" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Компас" + } + } + } }, "Config" : { "localizations" : { @@ -8109,6 +9550,12 @@ "value" : "設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8128,6 +9575,9 @@ } } } + }, + "Configuration" : { + }, "Configuration for: %@" : { "localizations" : { @@ -8149,6 +9599,12 @@ "value" : "%@ の設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация для: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8177,6 +9633,12 @@ "value" : "設定プリセット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Преднастройки конфигурации" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8217,6 +9679,12 @@ "value" : "設定する" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8235,6 +9703,12 @@ "comment" : "Button label to guide users to configure Bluetooth connectivity for the app.", "isCommentAutoGenerated" : true, "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка подключения по Bluetooth" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8247,6 +9721,12 @@ "comment" : "Button label to configure local network access permissions.", "isCommentAutoGenerated" : true, "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка доступа по локальной сети" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8263,6 +9743,12 @@ "value" : "Standortberechtigungen konfigurieren" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка разрешений на определение местоположения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8279,6 +9765,12 @@ "value" : "Mitteilungsberechtigungen konfigurieren" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка разрешений на уведомления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8301,6 +9793,12 @@ "value" : "確認" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтверждение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8317,6 +9815,12 @@ }, "Connect" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8345,6 +9849,12 @@ "value" : "ノードに接続" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключение к ноде" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8367,6 +9877,12 @@ "value" : "プロキシ経由でMQTTに接続" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключение к MQTT через прокси" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8395,6 +9911,12 @@ "value" : "新しい無線機に接続しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключиться к новому радиоприемнику?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8447,6 +9969,12 @@ "value" : "Podłączony" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключено" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -8493,6 +10021,12 @@ "value" : "接続済みノード %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключенная нода %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8521,6 +10055,12 @@ "value" : "接続済み無線機" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключенный радиоприемник" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8573,6 +10113,12 @@ "value" : "Łączenie . ." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключение . ." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -8613,6 +10159,12 @@ "value" : "新しい無線機に接続すると、電話上の全てのアプリデータがクリアされます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При подключении к новому радиоприемнику все данные приложения на телефоне будут удалены." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8647,6 +10199,12 @@ "value" : "接続試行 %lld / 10" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Количество попыток подключения, %lld из 10" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8669,6 +10227,12 @@ }, "Connection Name" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя подключения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8685,6 +10249,12 @@ "value" : "MQTT経由での暗号化されていないノードデータの共有に同意" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Согласие на передачу незашифрованных данных ноды через MQTT" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8700,6 +10270,7 @@ } }, "Contact Filters" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -8707,6 +10278,12 @@ "value" : "Kontaktfilter" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фильтры контактов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8723,6 +10300,12 @@ "value" : "連絡先URL" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL контакта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8738,6 +10321,7 @@ } }, "Contacts (%@)" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -8775,6 +10359,12 @@ "value" : "Kontakty (%@)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакты (%@)" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -8815,6 +10405,12 @@ "value" : "Control タイプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип управления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8849,6 +10445,12 @@ "value" : "デバイス上の点滅LEDを制御します。ほとんどのデバイスでは最大4つのLEDのうち1つを制御し、充電器とGPS LEDは制御できません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управляет мигающим светодиодом на устройстве. Для большинства устройств это будет управлять одним из максимум 4 светодиодов, светодиоды зарядки и GPS не управляются." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8889,6 +10491,12 @@ "value" : "凸包" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выпуклая оболочка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8923,6 +10531,12 @@ "value" : "座標" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Координаты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8957,6 +10571,12 @@ "value" : "座標 %@, %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Координаты %@, %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8997,6 +10617,12 @@ "value" : "座標:" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Координаты:" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9049,6 +10675,12 @@ "value" : "Kopiuj" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -9095,6 +10727,12 @@ "value" : "ノードが見つかりません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось найти ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9129,6 +10767,12 @@ "value" : "反時計回りの回転イベント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вращение энкодера против часовой стрелки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9163,6 +10807,12 @@ "value" : "ウェイポイントを作成" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать путевую точку" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9185,6 +10835,12 @@ "value" : "Erstelle deine eigenen Netzwerke" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создавайте свои собственные сети" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9213,6 +10869,12 @@ "value" : "作成日時: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создано: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9235,6 +10897,12 @@ "value" : "Kritische Hinweise" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Критические оповещения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9257,6 +10925,12 @@ "value" : "現在" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ток" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9291,6 +10965,12 @@ "value" : "現在のファームウェアバージョン: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установленная прошивка: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9331,6 +11011,12 @@ "value" : "現在のファームウェアバージョン: %@、最新のファームウェアバージョン: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установленная прошивка: %@, актуальная версия: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9371,6 +11057,12 @@ "value" : "現在: %lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущий: %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9399,6 +11091,12 @@ "value" : "現在、ESP32デバイスの更新の推奨方法は、デスクトップコンピューターのChromeベースのブラウザーでWebフラッシャーを使用することです。モバイルデバイスやBLE経由では動作しません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В настоящее время рекомендуемый способ обновления устройств ESP32 - использование веб-прошивальщика на настольном компьютере из браузера на основе Chrome. Не работает на мобильных устройствах или через BLE." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9439,6 +11137,12 @@ "value" : "日付" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дата" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9467,6 +11171,12 @@ "value" : "デバッグ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отладка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9507,6 +11217,12 @@ "value" : "デバッグログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал отладки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9535,6 +11251,12 @@ "value" : "デバッグログ%@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал отладки %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9587,6 +11309,12 @@ "value" : "Domyślny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По умолчанию" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -9614,6 +11342,7 @@ } }, "Default 128x64 screen layout" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -9627,6 +11356,12 @@ "value" : "デフォルト128x64スクリーンレイアウト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Формат экрана по умолчанию 128x64" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9685,6 +11420,12 @@ "value" : "Usuń" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -9710,6 +11451,9 @@ } } } + }, + "Delete All" : { + }, "Delete all config, keys and BLE bonds? " : { "localizations" : { @@ -9725,6 +11469,12 @@ "value" : "全ての設定、キー、BLEボンドを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все настройки, ключи и BLE-соединения?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9747,6 +11497,12 @@ "value" : "全ての設定を削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все настройки?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9787,6 +11543,12 @@ "value" : "Usunąć wszystkie metryki urządzenia?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все метрики устройства?" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -9827,6 +11589,12 @@ "value" : "全ての環境メトリクスを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все показатели среды?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9861,6 +11629,12 @@ "value" : "全てのPAXデータを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все данные о прохожих?" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -9895,6 +11669,12 @@ "value" : "全ての位置データを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все данные позиционирования?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9923,6 +11703,12 @@ "value" : "メッセージを削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9951,6 +11737,12 @@ "value" : "メッセージを削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9985,6 +11777,12 @@ "value" : "ノードを削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10019,6 +11817,12 @@ "value" : "ノードを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить ноду?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10047,6 +11851,12 @@ "value" : "電力メトリクスを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить метрики питания?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10081,6 +11891,12 @@ "value" : "説明" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Описание" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10109,6 +11925,12 @@ "value" : "説明は100バイト未満である必要があります" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Описание должно быть не более 100 байт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10131,6 +11953,12 @@ }, "Details..." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подробности..." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10153,6 +11981,12 @@ "value" : "検出" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обнаружение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10181,6 +12015,12 @@ "value" : "検出イベント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Событие обнаружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10234,6 +12074,12 @@ "value" : "Detection Sensor" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Датчик обнаружения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10286,6 +12132,12 @@ "value" : "検出センサー設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка датчика обнаружения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10326,6 +12178,12 @@ "value" : "検出センサーログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал датчика обнаружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10354,6 +12212,12 @@ "value" : "検出センサーメッセージはテキストメッセージとして受信されます。通知を有効にすると、受信した各検出メッセージの通知と対応する未読メッセージバッジが表示されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщения с датчиков обнаружения принимаются в виде текстовых сообщений. Если вы включите уведомления, вы будете получать уведомление о каждом полученном сообщении об обнаружении и соответствующий значок непрочитанного сообщения." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10375,6 +12239,7 @@ } }, "Detection Sensor module config received: %@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -10406,6 +12271,12 @@ "value" : "Detection Sensor module config received: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля датчика обнаружения: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10446,6 +12317,12 @@ "value" : "開発者" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разработчики" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10504,6 +12381,12 @@ "value" : "Urządzenie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройство" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10568,6 +12451,12 @@ "value" : "Konfiguracja urządzenia" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка устройства" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10595,6 +12484,7 @@ } }, "Device config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -10632,6 +12522,12 @@ "value" : "Otrzymano konfigurację urządzenia: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация устройства: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10678,6 +12574,12 @@ "value" : "デバイス設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация устройства" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10724,6 +12626,12 @@ "value" : "デバイス GPS" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройство GPS" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10758,6 +12666,12 @@ "value" : "デバイスはメッシュ管理者によって管理されており、ユーザーはデバイス設定にアクセスできません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройством управляет администратор сети, пользователь не может получить доступ ни к одной настройке устройства." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10779,6 +12693,7 @@ } }, "Device Metadata received from: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -10816,6 +12731,12 @@ "value" : "Otrzymano metadane urządzenia od: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метаданные устройства получены от: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10843,6 +12764,7 @@ } }, "Device Metrics" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -10856,6 +12778,12 @@ "value" : "デバイスメトリクス" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метрики устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10890,6 +12818,12 @@ "value" : "デバイスメトリクスログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал метрик устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10930,6 +12864,12 @@ "value" : "デバイスモデル: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель устройства: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10951,7 +12891,14 @@ } }, "Device Options" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры устройства" + } + } + } }, "Device Role" : { "localizations" : { @@ -10967,6 +12914,12 @@ "value" : "デバイス役割" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Роль устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11001,6 +12954,12 @@ "value" : "デバイス画面" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экран устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11022,6 +12981,7 @@ } }, "Device that does not forward packets from other devices." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -11059,6 +13019,12 @@ "value" : "Wyciszenie klienta - To samo, co klient, z wyjątkiem pakietów, które nie przeskakują przez ten węzeł, nie przyczynia się do routingu pakietów dla siatki." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройство, которое не пересылает пакеты с других устройств." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11086,6 +13052,7 @@ } }, "Device that only broadcasts as needed for stealth or power savings." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -11123,6 +13090,12 @@ "value" : " Used for nodes that \"only speak when spoken to\" Turns all of the routine broadcasts but allows for ad-hoc communication. Still rebroadcasts, but with local only rebroadcast mode (known meshes only). Can be used for private operation or to dramatically reduce airtime / power consumption." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройство, которое передает только при необходимости для скрытности или экономии энергии." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11163,6 +13136,12 @@ "value" : "精度希釈(DOP)、デフォルトでPDOPを使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Снижение точности (DOP) PDOP используется по умолчанию" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11197,6 +13176,12 @@ "value" : "ダイレクト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прямое" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11237,6 +13222,12 @@ "value" : "ダイレクトメッセージヘルプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Справка по личным сообщениям" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11265,6 +13256,12 @@ "value" : "ダイレクトメッセージキー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ключ личных переписок" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11311,6 +13308,12 @@ "value" : "Bezpośrednie Wiadomości" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Личные сообщения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11351,6 +13354,12 @@ "value" : "ダイレクトメッセージは暗号化のために新しい公開鍵インフラストラクチャを使用しています。ファームウェアバージョン2.5以上が必要です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Личные сообщения используют новую инфраструктуру открытых ключей для шифрования. Требуется версия прошивки 2.5 или выше." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11385,6 +13394,12 @@ "value" : "ダイレクトメッセージはチャンネルの共有キーを使用しています。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Личные сообщения используют общий ключ для канала." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11443,6 +13458,12 @@ "value" : "Wyłączony" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключен" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11507,6 +13528,12 @@ "value" : "Rozłącz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11541,6 +13568,12 @@ "value" : "ノードを切断" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключить ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11557,6 +13590,12 @@ "value" : "現在接続中のノードを切断します" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключить подключенную в данный момент ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11603,6 +13642,12 @@ "value" : "Zamknij" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отклонить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11667,6 +13712,12 @@ "value" : "Wyświetlacz (Ekran Urządzenia)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экран" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11725,6 +13776,12 @@ "value" : "Konfiguracja Wyświetlacza" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки экрана" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11752,6 +13809,7 @@ } }, "Display config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -11789,6 +13847,12 @@ "value" : "Otrzymano konfigurację wyświetlacza: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Полученная конфигурация отображения: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11829,6 +13893,12 @@ "value" : "華氏表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отображение по Фаренгейту" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11863,6 +13933,12 @@ "value" : "表示モード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим экрана" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11891,6 +13967,12 @@ "value" : "Darstellung der Entfernung zwischen deinem Handy und anderen Meshtastic-Knoten mit Positionsangabe." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отобразить расстояние между вашим телефоном и другими нодами Meshtastic с указанием местоположения." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11913,6 +13995,12 @@ "value" : "表示単位" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Единицы измерения на экране" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11953,6 +14041,12 @@ "value" : "距離" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расстояние" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11981,6 +14075,12 @@ "value" : "Distanzfilter" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фильтры расстояния" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11997,6 +14097,12 @@ "value" : "Distanzmessungen" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Измерения расстояний" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12006,7 +14112,14 @@ } }, "Distance: %@" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расстояние: %@" + } + } + } }, "Documentation" : { "localizations" : { @@ -12028,6 +14141,12 @@ "value" : "ドキュメント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Документация" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12056,6 +14175,12 @@ "value" : "完了" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Готово" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12084,6 +14209,12 @@ "value" : "ダブルタップをボタンとして使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Двойной тап как кнопка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12105,6 +14236,7 @@ } }, "Down" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12142,6 +14274,12 @@ "value" : "W Dół" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вниз" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -12182,6 +14320,12 @@ "value" : "ダウンリンク有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нисходящая связь включена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12201,6 +14345,9 @@ } } } + }, + "Download TAK Server Data Package" : { + }, "Drag & Drop Firmware Update" : { "localizations" : { @@ -12216,6 +14363,12 @@ "value" : "ドラッグ&ドロップファームウェア更新" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление прошивки в режиме внешнего диска" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12250,6 +14403,12 @@ "value" : "ドラッグ&ドロップファームウェア更新ドキュメント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Документация по обновлению прошивки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12284,6 +14443,12 @@ "value" : "ドラッグ&ドロップはNRFデバイスのファームウェア更新に推奨される方法です。お使いのiPhoneまたはiPadがUSB-Cの場合、通常のUSB-C充電ケーブルで動作します。Lightningデバイスの場合は、Apple Lightning to USBカメラアダプターが必要です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перетаскивание - это рекомендуемый способ обновления прошивки для устройств NRF. Если ваш iPhone или iPad имеет USB-C, это будет работать с обычным кабелем зарядки USB-C. Для устройств с Lightning необходим адаптер Apple Lightning to USB для камеры." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12305,6 +14470,7 @@ } }, "Driving" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12324,6 +14490,12 @@ "value" : "運転" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вождение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12358,6 +14530,12 @@ "value" : "マップにピンを配置" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить метку на карте" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12380,6 +14558,12 @@ "value" : "Richte einfach private Mesh-Netzwerke für eine sichere und zuverlässige Kommunikation in abgelegenen Gebieten ein." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Легко настройте частные ячеистые сети для безопасной и надежной связи в отдаленных районах." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12414,6 +14598,12 @@ "value" : "エコー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эхо" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -12454,6 +14644,12 @@ "value" : "ウェイポイント編集" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактирование путевой точки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12469,6 +14665,7 @@ } }, "Eighteen Hours" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12506,6 +14703,12 @@ "value" : "Osiemnaście Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восемнадцать часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -12552,6 +14755,12 @@ "value" : "標高ゲイン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Набор высоты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12580,6 +14789,12 @@ "value" : "絵文字" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эмодзи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12614,6 +14829,12 @@ "value" : "空" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пустой" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12635,7 +14856,14 @@ } }, "Enable broadcasting device metrics to the mesh network. When disabled, metrics are only sent to connected clients." : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить передачу метрик устройства в ячеистую сеть. При отключении метрики отправляются только подключенным клиентам." + } + } + } }, "Enable broadcasting packets via UDP over the local network." : { "localizations" : { @@ -12651,6 +14879,12 @@ "value" : "ローカルネットワーク上でUDP経由のパケットブロードキャストを有効にします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить трансляцию пакетов через UDP по локальной сети." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12673,6 +14907,12 @@ "value" : "Standortfreigabe aktivieren" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Делиться местоположением" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12695,6 +14935,12 @@ "value" : "通知を有効にする" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить уведомления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12714,6 +14960,9 @@ } } } + }, + "Enable TAK Server" : { + }, "Enable this device as a Store and Forward server. Requires an ESP32 device with PSRAM." : { "localizations" : { @@ -12729,6 +14978,12 @@ "value" : "このデバイスをStore and Forwardサーバーとして有効にします。PSRAMを搭載したESP32デバイスが必要です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить это устройство как сервер хранения и пересылки. Требуется устройство ESP32 с PSRAM." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12781,6 +15036,12 @@ "value" : "Włączony" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включен" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -12808,6 +15069,7 @@ } }, "Enables automatic TAK PLI broadcasts and reduces routine broadcasts." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -12845,6 +15107,12 @@ "value" : "Enables automatic TAK PLI broadcasts and reduces routine broadcasts." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включает автоматическую передачу TAK PLI и сокращает регулярные передачи." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -12885,6 +15153,12 @@ "value" : "ネイティブI2Sオーディオ出力を持つデバイスで、ブザーのようにスピーカー経由でRTTTLを使用できるようにします。例えば、T-Watch S3やT-Deckにはこの機能があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позволяет устройствам с нативным аудиовыходом I2S использовать RTTTL через динамик как зуммер. Например, T-Watch S3 и T-Deck имеют эту возможность." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12913,6 +15187,12 @@ "value" : "Aktiviert den blauen Standort-Punkt für dein Handy in der Mesh-Karte." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включает синюю точку местоположения вашего телефона на карте сети." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12935,6 +15215,12 @@ "value" : "検出センサーモジュールを有効にします。センサーを持つノードと、検出センサーテキストメッセージを受信したり、検出センサーログやチャートを表示したいノードの両方で有効にする必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включает модуль датчика обнаружения. Его необходимо включить как на ноде с датчиком, так и на всех нодах, на которых вы хотите получать текстовые сообщения датчика обнаружения или просматривать журнал и график датчика обнаружения." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12969,6 +15255,12 @@ "value" : "Store and Forwardモジュールを有効にします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включает модуль хранения и пересылки." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12997,6 +15289,12 @@ "value" : "Ethernetを有効にすると、アプリへのBluetooth接続が無効になります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включение Ethernet отключит соединение Bluetooth с приложением." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13007,6 +15305,12 @@ }, "Enabling WiFi will disable the bluetooth connection to the app." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При включении WiFi отключится bluetooth подключение к телефону" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13029,6 +15333,12 @@ "value" : "エンコーダープレスイベント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Событие нажатия энкодера" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13081,6 +15391,12 @@ "value" : "Zaszyfrowany" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зашифровано" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -13156,6 +15472,12 @@ "value" : "暗号化有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Шифрование включено" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13196,6 +15518,12 @@ "value" : "DFUモードに入る" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Войти в режим DFU" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13220,6 +15548,12 @@ "comment" : "A label for a text field where the user can enter a hostname or IP address and optionally a port number.", "isCommentAutoGenerated" : true, "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите имя хоста[:порт]" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13227,6 +15561,12 @@ } } } + }, + "Enter P12 Password" : { + + }, + "Enter the password for the PKCS#12 file" : { + }, "environment" : { "localizations" : { @@ -13248,6 +15588,12 @@ "value" : "環境" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "окружение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13288,6 +15634,12 @@ "value" : "環境" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Окружение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13309,6 +15661,7 @@ } }, "Environment Metrics" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -13322,6 +15675,12 @@ "value" : "環境メトリクス" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метрики окружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13343,7 +15702,14 @@ } }, "Environment Metrics Enabled" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метрики окружения включены" + } + } + } }, "Environment Metrics Log" : { "localizations" : { @@ -13359,6 +15725,12 @@ "value" : "環境メトリクスログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал метрик окружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13380,7 +15752,14 @@ } }, "Environment Sensor Options" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры датчика окружения" + } + } + } }, "Erase all app data?" : { "localizations" : { @@ -13402,6 +15781,12 @@ "value" : "全てのアプリデータを消去しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все данные приложения?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13442,6 +15827,12 @@ "value" : "全てのデバイスおよびアプリデータを消去しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все данные устройства и приложения?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13476,6 +15867,12 @@ "value" : "エラー: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13510,6 +15907,12 @@ "value" : "ESP32 OTAアップデートは開発中です。下のボタンをクリックして、デバイスにOTA管理モードへの再起動メッセージを送信してください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление ESP32 OTA находится в разработке, нажмите кнопку ниже, чтобы отправить устройству сообщение о перезагрузке в режим OTA администратора." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13544,6 +15947,12 @@ "value" : "ESP32デバイスファームウェア更新" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление прошивки для устройства ESP32" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13578,6 +15987,12 @@ "value" : "イーサネットオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры Ethernet" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13593,6 +16008,7 @@ } }, "European Union 433MHz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -13606,6 +16022,12 @@ "value" : "欧州連合 433MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eвросоюз 433Mhz" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13621,6 +16043,7 @@ } }, "European Union 868MHz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -13634,6 +16057,12 @@ "value" : "欧州連合 868MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Евросоюз 868Mhz" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13649,6 +16078,7 @@ } }, "Evening" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -13668,6 +16098,12 @@ "value" : "夕方" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вечер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13696,6 +16132,12 @@ "value" : "位置交換" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Делиться местоположением" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13711,9 +16153,17 @@ } }, "Exchange User Info" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обменяться информацией о пользователе" + } + } + } }, "Exclamation" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -13751,6 +16201,12 @@ "value" : "Wykrzyknik" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восклицательный знак" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -13785,6 +16241,12 @@ "value" : "有効期限" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Истечение срока" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13813,6 +16275,12 @@ "value" : "期限切れ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Истекает" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13847,6 +16315,12 @@ "value" : "有効期限" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Истекает" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13875,6 +16349,12 @@ "value" : "有効期限: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Истекает: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13909,6 +16389,12 @@ "value" : "エクスポート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экспорт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13961,6 +16447,12 @@ "value" : "Zewnętrzne Powiadomienie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внешние уведомления" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14025,6 +16517,12 @@ "value" : "Konfiguracja Zewnętrznego Powiadomienia" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка внешних уведомлений" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14052,6 +16550,7 @@ } }, "External Notification module config received: %@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -14083,6 +16582,12 @@ "value" : "Otrzymano konfigurację modułu zewnętrznych powiadomień: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля внешних уведомлений: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14129,6 +16634,12 @@ "value" : "Factory リセット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сброс настроек" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14157,6 +16668,12 @@ "value" : "工場出荷時リセットによりデバイスとアプリのデータが削除されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сброс к заводским настройкам удалит данные устройства и приложения." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14166,6 +16683,7 @@ } }, "Failed to encode message content" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -14179,6 +16697,12 @@ "value" : "メッセージ内容のエンコードに失敗しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось закодировать содержимое сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14194,7 +16718,14 @@ } }, "Failed to exchange user info." : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось обменяться информацией о пользователе." + } + } + } }, "Failed to get a valid position to exchange" : { "localizations" : { @@ -14210,6 +16741,12 @@ "value" : "交換用の有効な位置の取得に失敗しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось получить действительную позицию для обмена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14238,6 +16775,12 @@ "value" : "交換用の有効な位置の取得に失敗しました。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось получить действительную позицию для обмена." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14253,6 +16796,7 @@ } }, "Fair" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -14272,6 +16816,12 @@ "value" : "普通" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удовлетворительно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14306,6 +16856,12 @@ "value" : "お気に入り" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Избранное" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14344,7 +16900,14 @@ } }, "Favorited and ignored nodes are always retained. Other nodes are cleared from the app database on the schedule set by the user. (Nodes with PKC keys are always retained for at least 7 days.) This feature only purges nodes from the app that are not stored in the device node database." : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Избранные и игнорируемые ноды всегда сохраняются. Другие ноды удаляются из базы данных приложения по расписанию, установленному пользователем. (Ноды с ключами PKC всегда сохраняются не менее 7 дней.) Эта функция удаляет из приложения только те ноды, которые не хранятся в базе данных нод устройства." + } + } + } }, "Favorites" : { "localizations" : { @@ -14366,6 +16929,12 @@ "value" : "お気に入り" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Избранное" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14400,6 +16969,12 @@ "value" : "お気に入りと最近のメッセージがあるノードは、連絡先リストの上部に表示されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Избранные ноды и ноды с недавно полученными сообщениями появляются в верхней части списка контактов." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14440,6 +17015,12 @@ "value" : "特定のノードの最新位置を取得" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получить последнюю позицию определенной ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14468,6 +17049,12 @@ "value" : "15分" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 минут" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14483,6 +17070,7 @@ } }, "Fifteen Seconds" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -14520,6 +17108,12 @@ "value" : "Piętnaście Sekund" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 секунд" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14560,6 +17154,12 @@ "value" : "ファイルストレージ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хранилище файлов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14577,6 +17177,12 @@ "Files Available" : { "comment" : "Data source label when files exist but none are active", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступные файлы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14593,6 +17199,12 @@ "value" : "Filtere die Knotenliste und die Mesh-Karte nach der Nähe zu deinem Handy." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фильтровать список нод и карту сети по близости к вашему телефону." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14621,6 +17233,12 @@ "value" : "連絡先を検索" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Найти контакт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14655,6 +17273,12 @@ "value" : "ノードを検索" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Найти ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14707,6 +17331,12 @@ "value" : "Finish" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14742,6 +17372,12 @@ "value" : "Ziel" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершить" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14770,6 +17406,12 @@ "value" : "ファームウェア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прошивка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14804,6 +17446,12 @@ "value" : "ファームウェア更新ドキュメント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Документация по обновлению прошивки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14844,6 +17492,12 @@ "value" : "ファームウェア更新" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление прошивки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14902,6 +17556,12 @@ "value" : "Wersja Oprogramowania" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия прошивки" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14942,6 +17602,12 @@ "value" : "初回受信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Первое обнаружение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14957,6 +17623,7 @@ } }, "Five Hours" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -14994,6 +17661,12 @@ "value" : "Pięć Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пять часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15040,6 +17713,12 @@ "value" : "5分" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пять минут" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15055,6 +17734,7 @@ } }, "Five Seconds" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -15092,6 +17772,12 @@ "value" : "Pięć Sekund" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пять секунд" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15156,6 +17842,12 @@ "value" : "Stały PIN" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фиксированный PIN" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15196,6 +17888,12 @@ "value" : "固定位置" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фиксированная позиция" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15224,6 +17922,12 @@ "value" : "画面反転" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перевернуть экран" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15252,6 +17956,12 @@ "value" : "画面を垂直に反転" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перевернуть экран вертикально" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15267,6 +17977,7 @@ } }, "Follow" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -15304,6 +18015,12 @@ "value" : "Śledź" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Следить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15331,6 +18048,7 @@ } }, "Follow with heading" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -15368,6 +18086,12 @@ "value" : "Śledź z kierunkiem" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Следить с направлением" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15408,6 +18132,12 @@ "value" : "マップレポート以外のすべてのMqtt機能については、Mqtt経由でブリッジしたい各チャンネルのアップリンクとダウンリンクも設定する必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для всей функциональности MQTT, кроме отчета карты, вы также должны настроить восходящую и нисходящую связь для каждого канала, который хотите мостить через MQTT." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15448,6 +18178,12 @@ "value" : "すべての人に" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для всех" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15482,6 +18218,12 @@ "value" : "自分に" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для меня" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15497,6 +18239,7 @@ } }, "Forty Eight Hours" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -15534,6 +18277,12 @@ "value" : "Czterdzieści Osiem Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сорок восемь часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15561,6 +18310,7 @@ } }, "Forty Five Seconds" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -15598,6 +18348,12 @@ "value" : "Czterdzieści Pięć Sekund" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сорок пять секунд" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15625,6 +18381,7 @@ } }, "Four Hours" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -15662,6 +18419,12 @@ "value" : "Cztery Godziny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Четыре часа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15689,6 +18452,7 @@ } }, "Four Seconds" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -15726,6 +18490,12 @@ "value" : "Cztery Sekundy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Четыре секунды" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15772,6 +18542,12 @@ "value" : "周波数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Частота" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15800,6 +18576,12 @@ "value" : "周波数オーバーライド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переопределение частоты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15828,6 +18610,12 @@ "value" : "周波数スロット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Частотный слот" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15856,6 +18644,12 @@ "value" : "フレンドリー名" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Понятное имя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15890,6 +18684,12 @@ "value" : "メッシュに送信されるメッセージのフォーマットに使用されるフレンドリ名。例:「Motion」という名前は「Motion detected」というメッセージになります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Понятное имя, используемое для форматирования сообщения, отправляемого в сеть. Пример: Имя \"Motion\" приведет к сообщению \"Motion detected\"." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15912,6 +18712,12 @@ }, "From Radio (RX): %lld" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "От радио (RX): %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15934,6 +18740,12 @@ "value" : "完全サポート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Полная поддержка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15947,6 +18759,9 @@ } } } + }, + "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" : { @@ -15956,6 +18771,12 @@ "value" : "現在使用中のプライベートキーを置き換える新しいプライベートキーを生成します。パブリックキーはプライベートキーから自動的に再生成されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать новый закрытый ключ для замены текущего. Открытый ключ будет автоматически создан из вашего закрытого ключа." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16002,6 +18823,12 @@ "value" : "Generuj Kod QR" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать QR-код" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -16042,6 +18869,12 @@ "value" : "カスタム防水ソーラー・検出センサールーターノード、アルミニウムデスクトップノード、頑丈なハンドセットを入手できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получите специальные водонепроницаемые солнечные ноды с датчиками обнаружения, алюминиевые настольные ноды и прочные устройства." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16076,6 +18909,12 @@ "value" : "ノード位置取得" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получить позицию ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16110,6 +18949,12 @@ "value" : "App StoreからNRF DFUを取得" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузите NRF DFU из App Store" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16138,6 +18983,12 @@ "value" : "Los geht's" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начать" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16160,6 +19011,12 @@ "value" : "最新の安定版ファームウェアを取得" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получить последнюю стабильную прошивку" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16188,6 +19045,12 @@ "value" : "GitHubリポジトリ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Репозиторий GitHub" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16197,6 +19060,7 @@ } }, "Good" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -16210,6 +19074,12 @@ "value" : "良好" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хорошо" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16238,6 +19108,12 @@ "value" : "GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16272,6 +19148,12 @@ "value" : "GPIO出力時間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длительность выхода GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16300,6 +19182,12 @@ "value" : "ロータリーエンコーダーAポート用GPIOピン。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO для порта A поворотного энкодера." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16328,6 +19216,12 @@ "value" : "ロータリーエンコーダーBポート用GPIOピン。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO для порта B поворотного энкодера." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16356,6 +19250,12 @@ "value" : "ロータリーエンコーダープレスポート用GPIOピン。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO для порта нажатия поворотного энкодера." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16384,6 +19284,12 @@ "value" : "GPIO ピン to monitor" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO для мониторинга" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16412,6 +19318,12 @@ "value" : "GPS有効化GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS EN GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16446,6 +19358,12 @@ "value" : "GPS受信GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO приема GPS" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16480,6 +19398,12 @@ "value" : "GPS送信GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO передачи GPS" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16520,6 +19444,12 @@ "value" : "グループメッセージ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Групповое сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16554,6 +19484,12 @@ "value" : "突風 %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Порывы ветра %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16569,6 +19505,7 @@ } }, "HaHa" : { + "extractionState" : "stale", "localizations" : { "he" : { "stringUnit" : { @@ -16582,6 +19519,12 @@ "value" : "ハハ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ХаХа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -16610,6 +19553,12 @@ }, "Hard Reset" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Жесткий сброс" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16632,6 +19581,12 @@ "value" : "ハードウェア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оборудование" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16653,6 +19608,7 @@ } }, "Hazardous" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -16666,6 +19622,12 @@ "value" : "危険" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Опасно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16694,6 +19656,12 @@ "value" : "方位" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Направление" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16728,6 +19696,12 @@ "value" : "方位: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Направление: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16743,6 +19717,7 @@ } }, "Heard" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -16780,6 +19755,12 @@ "value" : "Usłyszano" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Услышан" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -16807,6 +19788,7 @@ } }, "Heart" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -16844,6 +19826,12 @@ "value" : "Serce" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сердце" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -16884,6 +19872,12 @@ "value" : "アラートを非表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть оповещения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16912,6 +19906,12 @@ "value" : "アラートを非表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть оповещения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16946,6 +19946,12 @@ "value" : "高" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ВЫСОКИЙ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16967,6 +19973,7 @@ } }, "Hiking" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -16986,6 +19993,12 @@ "value" : "ハイキング" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пеший туризм" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17020,6 +20033,12 @@ "value" : "履歴返信最大数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимальное возвращение истории" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17048,6 +20067,12 @@ "value" : "履歴返信時間枠" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Временное окно возврата истории" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17082,6 +20107,12 @@ "value" : "ホップ距離" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хопов до ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17116,6 +20147,12 @@ "value" : "ホップ距離 %d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хопов до ноды: %d" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17131,6 +20168,7 @@ } }, "Hops Away:" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -17150,6 +20188,12 @@ "value" : "ホップ距離:" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хопов до ноды:" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17184,6 +20228,12 @@ "value" : "ホップ距離: %d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хопов до ноды: %d" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17218,6 +20268,12 @@ "value" : "時間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Час" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17252,6 +20308,12 @@ "value" : "時間あたりデューティサイクル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Почасовой рабочий цикл" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17280,6 +20342,12 @@ "value" : "ユーザーボタンが押されたり、メッセージが受信された後、画面が点灯し続ける時間。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как долго экран остается включенным после нажатия кнопки пользователя или получения сообщений." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17314,6 +20382,12 @@ "value" : "デバイスメトリクスがメッシュ経由で送信される頻度。デフォルトは30分です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как часто метрики устройства отправляются по сети. По умолчанию 30 минут." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17348,6 +20422,12 @@ "value" : "環境メトリクスがメッシュ経由で送信される頻度。デフォルトは30分です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как часто метрики окружения отправляются по сети. По умолчанию 30 минут." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17382,6 +20462,12 @@ "value" : "電力メトリクスがメッシュ経由で送信される頻度。デフォルトは30分です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как часто метрики питания отправляются по сети. По умолчанию 30 минут." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17416,6 +20502,12 @@ "value" : "GPS位置を取得する頻度。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как часто следует пытаться получить позицию GPS." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17450,6 +20542,12 @@ "value" : "検出の有無に関係なく、検出センサーの状態をメッシュに送信する頻度。デフォルトは「なし」です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как часто отправлять состояние датчика обнаружения в сеть независимо от обнаружения. По умолчанию никогда." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17502,6 +20600,12 @@ "value" : "How often we can send a message to the mesh when people are detected." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как часто мы можем отправлять сообщение в сеть при обнаружении людей." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -17548,6 +20652,12 @@ "value" : "ファームウェアの更新方法" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как обновить прошивку" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17569,6 +20679,7 @@ } }, "Hum" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -17582,6 +20693,12 @@ "value" : "湿度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Влажность" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17616,6 +20733,12 @@ "value" : "湿度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Влажность" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17631,6 +20754,7 @@ } }, "Hybrid" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17662,6 +20786,12 @@ "value" : "Hybrydowy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Гибридный" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -17689,6 +20819,7 @@ } }, "Hybrid Flyover" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17720,6 +20851,12 @@ "value" : "Hybrydowy Przelot" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Гибридный облет" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -17754,6 +20891,12 @@ "value" : "上記を読み理解しました。MQTT経由でのノードデータの暗号化されない送信に自発的に同意します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Я прочитал и понимаю вышеизложенное. Я добровольно даю согласие на незашифрованную передачу данных моей ноды через MQTT." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17782,6 +20925,12 @@ "value" : "IAQ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17816,6 +20965,12 @@ "value" : "IAQ " } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17850,6 +21005,12 @@ "value" : "IAQ %lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17890,6 +21051,12 @@ "value" : "アイコン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Значок" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17912,6 +21079,12 @@ }, "Icons" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Значки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17934,6 +21107,12 @@ "value" : "DOPが設定されている場合、PDOPの代わりにHDOP / VDOP値を使用します" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если DOP установлен, используйте значения HDOP / VDOP вместо PDOP" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17968,6 +21147,12 @@ "value" : "有効にすると、「出力」ピンがアクティブハイになり、無効にするとアクティブローになります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если включено, контакт 'output' будет активирован на высоком уровне, отключено означает активный низкий уровень." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18002,6 +21187,12 @@ "value" : "デバイスのリセットボタンにアクセスが困難な場合は、ここでDFUモードに入ってください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если трудно получить доступ к кнопке сброса вашего устройства, войдите в режим DFU здесь." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18036,6 +21227,12 @@ "value" : "設定すると、送信したパケットがデバイスにエコーバックされます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если установлено, все отправляемые вами пакеты будут отправлены эхом обратно на ваше устройство." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18070,6 +21267,12 @@ "value" : "デフォルトの地域トピックが混雑している場合は、よりローカルなトピックを選択できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если тема региона по умолчанию слишком загружена, вы можете выбрать более локальную тему." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18104,6 +21307,12 @@ "value" : "MQTTを無視" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Игнорировать MQTT" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18138,6 +21347,12 @@ "value" : "ノードを無視" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Игнорировать ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18172,6 +21387,12 @@ "value" : "無視" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Игнорируется" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18193,6 +21414,7 @@ } }, "Ignores observed messages from foreign meshes like Local Only, but takes it step further by also ignoring messages from nodes not already in the node's known list." : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { @@ -18200,6 +21422,12 @@ "value" : "Local Onlyのように外部メッシュからの観測メッセージを無視しますが、さらに進んで、ノードの既知リストにないノードからのメッセージも無視します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Игнорирует наблюдаемые сообщения из чужих сетей, как Local Only, но идет дальше, также игнорируя сообщения от нод, которых еще нет в списке известных." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18215,6 +21443,7 @@ } }, "Ignores observed messages from foreign meshes that are open or those which it cannot decrypt. Only rebroadcasts message on the nodes local primary / secondary channels." : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { @@ -18222,6 +21451,12 @@ "value" : "オープンまたは復号化できない外部メッシュからの観測メッセージを無視します。ノードのローカル主要/副次チャンネルでのみメッセージを再ブロードキャストします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Игнорирует наблюдаемые сообщения из чужих сетей, которые открыты или которые не может расшифровать. Повторно передает сообщения только на локальных первичных/вторичных каналах ноды." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18235,6 +21470,18 @@ } } } + }, + "Import" : { + + }, + "Import .pem" : { + + }, + "Import Custom .p12" : { + + }, + "Import Error" : { + }, "Import Route" : { "localizations" : { @@ -18256,6 +21503,12 @@ "value" : "ルートインポート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Импортировать маршрут" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18278,6 +21531,12 @@ }, "In addition to Config, Keys and BLE bonds will be wiped" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Помимо конфигурации, будут стерты ключи и связи BLE." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18324,6 +21583,12 @@ "value" : "Dołącz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -18358,6 +21623,12 @@ "value" : "Eingehende Nachrichten" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Входящие сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18367,6 +21638,7 @@ } }, "Incomplete" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -18392,6 +21664,12 @@ "value" : "未完了" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не завершено" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18407,6 +21685,7 @@ } }, "India" : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { @@ -18414,6 +21693,12 @@ "value" : "インド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Индия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18429,6 +21714,7 @@ } }, "Indoor Air Quality" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -18442,6 +21728,12 @@ "value" : "室内空気品質" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Качество воздуха в помещении" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18476,6 +21768,12 @@ "value" : "室内空気質(IAQ)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Качество воздуха в помещении (IAQ)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18497,6 +21795,7 @@ } }, "Infrastructure node on a tower or mountain top only. Not to be used for roofs or mobile nodes. Needs exceptional coverage. Visible in Nodes list." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -18534,6 +21833,12 @@ "value" : "Router - Pakiety siatki będą preferować trasowanie przez ten węzeł. Zakłada, że urządzenie będzie działać samodzielnie, umieszczone w miejscu z przewagą zasięgu. UWAGA: Radia BLE/Wi-Fi i ekran OLED zostaną uśpione." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инфраструктурная нода только на башне или вершине горы. Не используется для крыш или мобильных нод. Требуется исключительное покрытие. Видна в списке нод." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -18574,6 +21879,12 @@ "value" : "入力" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Входы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18602,6 +21913,12 @@ "value" : "Ungültiger Dateiinhalt. Bitte überprüfe das Dateiformat." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Недопустимое содержимое файла. Проверьте формат файла." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18611,6 +21928,7 @@ } }, "Inverted top bar for 2 Color display" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -18624,6 +21942,12 @@ "value" : "2色ディスプレイ用反転トップバー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инвертированная верхняя панель для 2-цветного дисплея" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18645,6 +21969,7 @@ } }, "Japan" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -18658,6 +21983,12 @@ "value" : "日本" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Япония" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18686,6 +22017,12 @@ "value" : "JSON有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18720,6 +22057,12 @@ "value" : "JSONモードは、Home Assistantとのローカル統合のための限定的で暗号化されていないMQTT出力です" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим JSON - это ограниченный, незашифрованный вывод MQTT для локальной интеграции с Home Assistant." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18748,6 +22091,12 @@ "value" : "最新に移動" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перейти к настоящему" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18782,6 +22131,12 @@ "value" : "キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ключ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18810,6 +22165,12 @@ "value" : "キーバックアップ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Резервная копия ключа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18832,6 +22193,12 @@ "value" : "キーマッピング" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назначение клавиш" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18866,6 +22233,12 @@ "value" : "キーサイズ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Размер ключа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18881,6 +22254,7 @@ } }, "Korea" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -18894,6 +22268,12 @@ "value" : "韓国" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Корея" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18928,6 +22308,12 @@ "value" : "最終受信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последнее обнаружение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18950,10 +22336,25 @@ }, "Last seen device:" : { "comment" : "A label displayed next to the last seen device text in the `DeviceConnectRow`.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последнее устройство:" + } + } + } }, "Last seen device: %@" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последнее устройство: %@" + } + } + } }, "Latitude" : { "localizations" : { @@ -18975,6 +22376,12 @@ "value" : "緯度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Широта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19003,6 +22410,12 @@ "value" : "緯度(度単位、例: 37.7749)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Широта в градусах (например, 55.7558)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19019,6 +22432,12 @@ "value" : "緯度は-90度から90度の間である必要があります" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Широта должна быть между -90 и 90 градусами." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19041,6 +22460,12 @@ "value" : "LEDハートビート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пульс LED" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19081,6 +22506,12 @@ "value" : "LED状態" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Состояние LED" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19102,6 +22533,7 @@ } }, "Left" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -19139,6 +22571,12 @@ "value" : "W Lewo" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Влево" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -19203,6 +22641,12 @@ "value" : "Level" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уровень" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -19243,6 +22687,12 @@ "value" : "ライセンスオペレーター" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лицензированный оператор" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19277,6 +22727,12 @@ "value" : "特にテレメトリと位置情報のすべての定期ブロードキャスト間隔を制限します。ホップを増やす必要がある場合は、中央のノードではなく端のノードで行ってください。デューティサイクルが制限されている場合、ゲートウェイノードがすべての作業を行うため、MQTTは推奨されません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ограничьте все интервалы периодической передачи, особенно телеметрию и позицию. Если вам нужно увеличить количество хопов, делайте это на нодах \"на краях\", а не в середине. MQTT не рекомендуется, когда вы ограничены рабочим циклом, потому что тогда нода-шлюз выполняет всю работу." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19311,6 +22767,12 @@ "value" : "線系列" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Линейная серия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19339,6 +22801,12 @@ "value" : "Loading ログs. . ." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка журналов. . ." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19363,6 +22831,12 @@ "comment" : "A label displayed above the options for local network access.", "isCommentAutoGenerated" : true, "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступ к локальной сети" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19391,6 +22865,12 @@ "value" : "場所:" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Местоположение:" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19431,6 +22911,12 @@ "value" : "ロック済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заблокирован" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19465,6 +22951,12 @@ "value" : "ログレベル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уровни журнала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19523,6 +23015,12 @@ "value" : "Rejestracja" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ведение журнала" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -19563,6 +23061,12 @@ "value" : "ログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журналы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19584,6 +23088,7 @@ } }, "Logs:" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -19597,6 +23102,12 @@ "value" : "ログ:" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журналы:" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19637,6 +23148,12 @@ "value" : "長い名前" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длинное имя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19677,6 +23194,12 @@ "value" : "長押しで連絡先をお気に入りに追加、ミュート、または会話を削除できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длительное нажатие, чтобы добавить в избранное или отключить звук контакта или удалить беседу." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19698,6 +23221,7 @@ } }, "Long Range - Fast" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -19711,6 +23235,12 @@ "value" : "長距離 - 高速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long Range - Fast" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19726,6 +23256,7 @@ } }, "Long Range - Moderate" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -19739,6 +23270,12 @@ "value" : "長距離 - 中程度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long Range - Moderate" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19754,6 +23291,7 @@ } }, "Long Range - Slow" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -19767,6 +23305,12 @@ "value" : "長距離 - 低速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long Range - Slow" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19801,6 +23345,12 @@ "value" : "経度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Долгота" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19829,6 +23379,12 @@ "value" : "経度(度単位、例: -122.4194)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Долгота в градусах (например, 37.6173)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19845,6 +23401,12 @@ "value" : "経度は-180度から180度の間である必要があります" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Долгота должна быть между -180 и 180 градусами." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19891,6 +23453,12 @@ "value" : "LoRa" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoRa" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -19955,6 +23523,12 @@ "value" : "Konfiguracja LoRa" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка LoRa" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -19983,6 +23557,12 @@ }, "LoRa Config Changes:" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменения настройки LoRa:" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19992,6 +23572,7 @@ } }, "LoRa config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -20029,6 +23610,12 @@ "value" : "Otrzymano konfigurację LoRa: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация LoRa: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20056,6 +23643,7 @@ } }, "Lost and Found" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -20075,6 +23663,12 @@ "value" : "落とし物" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Потеряно и найдено" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20109,6 +23703,12 @@ "value" : "低" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "НИЗКИЙ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20131,6 +23731,12 @@ "value" : "Niedriger Akkustand" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Низкий заряд батареи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20140,6 +23746,7 @@ } }, "M5 Stack Card KB / RAK Keypad" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -20165,6 +23772,12 @@ "value" : "M5 Stack Card KB / RAK キーパッド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "M5 Stack Card KB / RAK клавиатура" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20186,6 +23799,7 @@ } }, "Malaysia 433MHz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -20199,6 +23813,12 @@ "value" : "マレーシア 433MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Малайзия 433MHz" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20214,6 +23834,7 @@ } }, "Malaysia 919MHz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -20227,6 +23848,12 @@ "value" : "マレーシア 919MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Малайзия 919MHz" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20279,6 +23906,12 @@ "value" : "Manage Channels" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управление каналами" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20320,6 +23953,12 @@ "value" : "カスタムマップオーバーレイを管理" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управление пользовательскими слоями карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20349,6 +23988,12 @@ "value" : "Kartendaten verwalten" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управление данными карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20371,6 +24016,12 @@ "value" : "管理されたデバイス" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управляемое устройство" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20393,6 +24044,12 @@ }, "Manual" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вручную" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20402,6 +24059,7 @@ } }, "Manual Configuration" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -20439,6 +24097,12 @@ "value" : "Konfiguracja ręczna" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ручная настройка" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20467,6 +24131,12 @@ }, "Manual connection string" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Строка подключения вручную" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20476,10 +24146,23 @@ } }, "Manual Connections" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ручные подключения" + } + } + } }, "Map Data" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20508,6 +24191,12 @@ "value" : "マップオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20566,6 +24255,12 @@ "value" : "Nakładki map" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Слои карты" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20593,6 +24288,7 @@ } }, "Map Publish Interval" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -20606,6 +24302,12 @@ "value" : "マップ公開間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал публикации карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20640,6 +24342,12 @@ "value" : "マップレポート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отчет карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20661,6 +24369,7 @@ } }, "Max Retransmission Reached" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -20698,6 +24407,12 @@ "value" : "Osiągnięto limit retransmisji" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Достигнуто максимальное количество повторных передач" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20725,6 +24440,7 @@ } }, "Medium Range - Fast" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -20738,6 +24454,12 @@ "value" : "中距離 - 高速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium Range - Fast" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20753,6 +24475,7 @@ } }, "Medium Range - Slow" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -20766,6 +24489,12 @@ "value" : "中距離 - 低速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium Range - Slow" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20794,6 +24523,12 @@ "value" : "メッシュアクティビティ更新" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление активности сети" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20846,6 +24581,12 @@ "value" : "Aktywność na Żywo" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущая активность сети" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20910,6 +24651,12 @@ "value" : "Mapa Sieci" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Карта сети" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20944,6 +24691,12 @@ "value" : "Standort auf der Mesh-Karte" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Местоположение на карте сети" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20954,6 +24707,12 @@ }, "Meshtastic" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20963,7 +24722,14 @@ } }, "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" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic не собирает личную информацию. Мы анонимно собираем данные об использовании и сбоях для улучшения приложения. Вы можете отказаться в настройках приложения." + } + } + } }, "Meshtastic Node %@ has shared channels with you" : { "localizations" : { @@ -20985,6 +24751,12 @@ "value" : "Meshtasticノード %@ があなたとチャンネルを共有しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нода Meshtastic %@ поделилась с вами каналами" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21007,6 +24779,12 @@ "value" : "Meshtastic verwendet den Standort deines Handys, um eine Reihe von Funktionen zu ermöglichen. Du kannst deine Standortberechtigungen jederzeit in den Einstellungen aktualisieren." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic использует местоположение вашего телефона для включения ряда функций. Вы можете изменить разрешения на определение местоположения в любое время в настройках." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21029,6 +24807,12 @@ "value" : "Meshtastic® Copyright Meshtastic LLC" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic® Copyright Meshtastic LLC" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21063,6 +24847,12 @@ "value" : "メッセージ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21097,6 +24887,12 @@ "value" : "メッセージ内容が200バイトを超えています。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Содержимое сообщения превышает 200 байт." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21149,6 +24945,12 @@ "value" : "Szczegóły wiadomości" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Детали сообщения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -21176,6 +24978,7 @@ } }, "Message received from the text message app." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -21213,6 +25016,12 @@ "value" : "Wiadomość odebrana z aplikacji do wysyłania wiadomości tekstowych." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение получено из приложения текстовых сообщений." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -21248,6 +25057,12 @@ "value" : "メッセージサイズ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Размер сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21270,6 +25085,12 @@ "value" : "メッセージ Status Options" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры статуса сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21316,6 +25137,12 @@ "value" : "Wiadomości" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -21362,6 +25189,12 @@ "value" : "メッセージs separate with |" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщения разделяются |" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21384,6 +25217,12 @@ "value" : "メッセージング" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обмен сообщениями" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21393,6 +25232,7 @@ } }, "Metric" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -21406,6 +25246,12 @@ "value" : "メトリック" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метрика" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21421,6 +25267,7 @@ } }, "Midday" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -21440,6 +25287,12 @@ "value" : "正午" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Полдень" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21474,6 +25327,12 @@ "value" : "最小距離" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минимальное расстояние" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21489,6 +25348,7 @@ } }, "Minimum Interval" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -21508,6 +25368,12 @@ "value" : "最小間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минимальный интервал" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21523,6 +25389,7 @@ } }, "Minimum time between detection broadcasts" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -21536,6 +25403,12 @@ "value" : "検出ブロードキャスト間の最小時間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минимальное время между передачами сообщений об обнаружении" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21564,6 +25437,12 @@ "value" : "検出ブロードキャスト間の最小時間。デフォルトは45秒です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минимальное время между передачами сообщений об обнаружении. Значение по умолчанию - 45 секунд." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21616,6 +25495,12 @@ "value" : "Tryb" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -21656,6 +25541,12 @@ "value" : "モデル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21677,6 +25568,7 @@ } }, "Moderate" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -21690,6 +25582,12 @@ "value" : "中程度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Умеренно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21748,6 +25646,12 @@ "value" : "Konfiguracja modułu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация модуля" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -21775,6 +25679,7 @@ } }, "Morning" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -21794,6 +25699,12 @@ "value" : "朝" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Утро" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21846,6 +25757,12 @@ "value" : "Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/radio/channels/)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Большинство данных в вашей сети передается по первичному каналу. Вы можете настроить вторичные каналы для создания дополнительных групп обмена сообщениями, защищенных собственным ключом. [Советы по настройке каналов](https://meshtastic.org/docs/configuration/tips/)" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -21886,6 +25803,12 @@ "value" : "MQTT" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21944,6 +25867,12 @@ "value" : "Klient Proxy MQTT" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT клиент-прокси" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22008,6 +25937,12 @@ "value" : "Konfiguracja MQTT" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка MQTT" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22035,6 +25970,7 @@ } }, "MQTT module config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -22072,6 +26008,12 @@ "value" : "Otrzymano konfigurację modułu MQTT: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля MQTT: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22097,6 +26039,9 @@ } } } + }, + "mTLS" : { + }, "Multiplier" : { "localizations" : { @@ -22130,6 +26075,12 @@ "value" : "Multiplier" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Множитель" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22164,6 +26115,12 @@ "value" : "単一の絵文字である必要があります" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Должен быть один эмодзи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22179,6 +26136,7 @@ } }, "MyInfo received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -22216,6 +26174,12 @@ "value" : "Otrzymano Moje Informacje: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена информация обо мне: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22270,9 +26234,6 @@ } } } - }, - "Nag Timeout" : { - }, "Name" : { "localizations" : { @@ -22294,6 +26255,12 @@ "value" : "名前" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22334,6 +26301,12 @@ "value" : "名前は30バイト未満である必要があります" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длина имени должна быть не более 30 байт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22368,6 +26341,12 @@ "value" : "ノードに移動" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перейти к ноде" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22396,6 +26375,12 @@ "value" : "近くのトピック" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ближайшие темы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22448,6 +26433,12 @@ "value" : "Sieć" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сеть" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22512,6 +26503,12 @@ "value" : "Konfiguracja sieci" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка сети" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22539,6 +26536,7 @@ } }, "Network config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -22576,6 +26574,12 @@ "value" : "Odebrano konfigurację sieci: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация сети: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22616,6 +26620,12 @@ "value" : "ネットワーク状態 オレンジ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус сети: Оранжевый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22650,6 +26660,12 @@ "value" : "ネットワーク状態 レッド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус сети: Красный" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22671,6 +26687,7 @@ } }, "New Node" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -22684,6 +26701,12 @@ "value" : "新しいノード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новая нода" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22699,6 +26722,7 @@ } }, "New Node has been discovered" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -22712,6 +26736,12 @@ "value" : "新しいノードが発見されました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обнаружена новая нода" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22734,6 +26764,12 @@ "value" : "Neue Knoten" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новые ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22743,6 +26779,7 @@ } }, "New Zealand 865MHz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -22756,6 +26793,12 @@ "value" : "ニュージーランド 865MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новая Зеландия 865MHz" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22790,6 +26833,12 @@ "value" : "新しいファームウェアが利用可能です" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступна новая версия прошивки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22811,6 +26860,7 @@ } }, "Nighttime" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -22830,6 +26880,12 @@ "value" : "夜間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ночное время" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22845,6 +26901,7 @@ } }, "NMEA Positions" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -22882,6 +26939,12 @@ "value" : "Pozycje NMEA" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиции NMEA" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22909,6 +26972,7 @@ } }, "No Channel" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -22946,6 +27010,12 @@ "value" : "Brak kanału" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет канала" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22992,6 +27062,12 @@ "value" : "接続されたノードがありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет подключенной ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23027,6 +27103,12 @@ "value" : "データなし" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет данных" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23085,6 +27167,12 @@ "value" : "Brak podłączonych urządzeń" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет подключенного устройства" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23125,6 +27213,12 @@ "value" : "デバイスメトリクスがありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет метрик устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23153,6 +27247,12 @@ "value" : "環境メトリクスがありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет метрик окружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23181,6 +27281,12 @@ "value" : "Keine Dateien hochgeladen" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет загруженных файлов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23190,6 +27296,7 @@ } }, "No Interface" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -23227,6 +27334,12 @@ "value" : "Brak interfejsu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет интерфейса" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23256,6 +27369,12 @@ "No map data files uploaded" : { "comment" : "Message when no files are uploaded", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет загруженных файлов данных карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23278,6 +27397,12 @@ "value" : "PAXカウンターログがありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет журналов счетчика PAX" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23299,6 +27424,7 @@ } }, "No PIN (Just Works)" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -23336,6 +27462,12 @@ "value" : "Brak PINu (po prostu działa)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет PIN (Просто работает)" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23382,6 +27514,12 @@ "value" : "位置情報がありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет позиций" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23410,6 +27548,12 @@ "value" : "電力メトリクスがありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет метрик питания" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23425,6 +27569,7 @@ } }, "No Response" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -23462,6 +27607,12 @@ "value" : "Brak odpowiedzi" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет ответа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23489,6 +27640,7 @@ } }, "No Route" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -23526,6 +27678,12 @@ "value" : "Brak trasy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет маршрута" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23572,6 +27730,12 @@ "value" : "ノード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нода" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23606,6 +27770,12 @@ "value" : "ノードコアデータバックアップ %1$@/%2$@ - %3$@ - %4$@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Резервное копирование данных ядра ноды %1$@/%2$@ - %3$@ - %4$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23627,6 +27797,7 @@ } }, "Node does not have positions" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -23646,6 +27817,12 @@ "value" : "ノードに位置情報がありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нода не имеет позиций" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23680,6 +27857,12 @@ "value" : "Node 履歴" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23695,6 +27878,7 @@ } }, "Node Info Broadcast Interval" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -23708,6 +27892,12 @@ "value" : "ノード情報ブロードキャスト間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал передачи информации о ноде" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23742,6 +27932,12 @@ "value" : "Node マップ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Карта нод" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23776,6 +27972,12 @@ "value" : "ノード番号" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Номер ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23822,6 +28024,12 @@ "value" : "ノード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ноды" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23849,6 +28057,7 @@ } }, "Nodes (%@)" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -23886,6 +28095,12 @@ "value" : "Węzły (%@)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ноды (%@)" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23913,6 +28128,7 @@ } }, "None" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -23950,6 +28166,12 @@ "value" : "Brak" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23990,6 +28212,12 @@ "value" : "有効なルートファイルではありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не допустимый файл маршрута" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24005,6 +28233,7 @@ } }, "Not Authorized" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -24042,6 +28271,12 @@ "value" : "Nieautoryzowany" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не авторизовано" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24069,6 +28304,7 @@ } }, "Not Present" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -24094,6 +28330,12 @@ "value" : "存在しません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отсутствует" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24140,6 +28382,12 @@ "value" : "メモ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24162,6 +28410,12 @@ "value" : "Mitteilungen für Kanal- und Direktnachrichten." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления о канале и личных сообщениях." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24178,6 +28432,12 @@ "value" : "Mitteilungen bei niedrigem Akkustand des verbundenen Funkgeräts." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления о низком заряде батареи подключенного устройства." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24194,6 +28454,12 @@ "value" : "Mitteilungen für neu entdeckte Knoten." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления о вновь обнаруженных нодах." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24222,6 +28488,12 @@ "value" : "ホップ数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Количество хопов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24256,6 +28528,12 @@ "value" : "レコード数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Количество записей" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24290,6 +28568,12 @@ "value" : "衛星数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Количество спутников" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24306,6 +28590,12 @@ }, "Ok" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ок" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24334,6 +28624,12 @@ "value" : "OK" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24362,6 +28658,12 @@ "value" : "MQTT OK" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК для MQTT" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24402,6 +28704,12 @@ "value" : "OLEDタイプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип OLED" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24423,6 +28731,7 @@ } }, "On Boot Only" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -24460,6 +28769,12 @@ "value" : "Tylko przy uruchomieniu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только при загрузке" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24500,6 +28815,12 @@ "value" : "ライセンス取得者のオンボーディングにはファームウェア2.0.20以上が必要です。必ずお住まいの地域の規制を参照し、疑問がある場合は地域のアマチュア周波数コーディネーターにお問い合わせください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Регистрация для лицензированных операторов требует прошивки версии 2.0.20 или выше. Убедитесь, что соблюдаете местные нормативные акты и обращайтесь к местным координаторам любительских частот с вопросами." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24558,6 +28879,12 @@ "value" : "Jedna Godzina" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Один час" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24622,6 +28949,12 @@ "value" : "Jedna Minuta" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Одна минута" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24649,6 +28982,7 @@ } }, "One Second" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -24686,6 +29020,12 @@ "value" : "Jedna Sekunda" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Одна секунда" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24732,6 +29072,12 @@ "value" : "オンライン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Онлайн" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24747,6 +29093,7 @@ } }, "Only permitted for SENSOR, TRACKER and TAK_TRACKER roles, this will inhibit all rebroadcasts, not unlike CLIENT_MUTE role." : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { @@ -24754,6 +29101,12 @@ "value" : "SENSOR、TRACKER、TAK_TRACKERロールでのみ許可されており、CLIENT_MUTEロールと同様にすべての再ブロードキャストを抑制します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешено только для ролей SENSOR, TRACKER и TAK_TRACKER, это отключит все повторные передачи, как и роль CLIENT_MUTE." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24769,6 +29122,7 @@ } }, "Only rebroadcasts packets from the core portnums: NodeInfo, Text, Position, Telemetry, and Routing." : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { @@ -24776,6 +29130,12 @@ "value" : "コアポート番号からのパケットのみ再ブロードキャスト: ノード情報、テキスト、位置、テレメトリ、ルーティング。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторно передает только пакеты с основных портов: NodeInfo, Text, Position, Telemetry и Routing." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24791,7 +29151,14 @@ } }, "Open Compass" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть компас" + } + } + } }, "Open Settings" : { "localizations" : { @@ -24813,6 +29180,12 @@ "value" : "設定を開く" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть настройки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24834,6 +29207,7 @@ } }, "Optimized for 2 color displays" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -24847,6 +29221,12 @@ "value" : "2色ディスプレイ用に最適化" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оптимизирован для 2-цветных дисплеев" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24862,6 +29242,7 @@ } }, "Optimized for ATAK system communication, reduces routine broadcasts." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -24899,6 +29280,12 @@ "value" : "Optimized for ATAK system communication, reduces routine broadcasts." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оптимизирован для связи с системой ATAK, сокращает регулярные передачи." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24939,6 +29326,12 @@ "value" : "位置メッセージを組み立てる際に含めるオプションフィールド。含めるフィールドが多いほどメッセージが大きくなり、通信時間が長くなってパケット損失のリスクが高くなります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Необязательные поля для включения при составлении сообщений о позиции. Чем больше полей включено, тем больше будет сообщение - что приведет к более длительному времени передачи и более высокому риску потери пакета" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24973,6 +29366,12 @@ "value" : "オプション GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дополнительный GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25025,6 +29424,12 @@ "value" : "Opcje" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25065,6 +29470,12 @@ "value" : "OSログエントリ詳細" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Детали записи журнала ОС" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25093,6 +29504,12 @@ "value" : "このNRFデバイスではOTA更新はサポートされていません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновления OTA не поддерживаются на этом устройстве NRF." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25127,6 +29544,12 @@ "value" : "お使いのプラットフォームではOTA更新はサポートされていません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновления OTA не поддерживаются на вашей платформе." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25161,6 +29584,12 @@ "value" : "その他のデータソース" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Другие источники данных" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25195,6 +29624,12 @@ "value" : "シリアル経由でライブデバッグログを出力し、Bluetooth経由で位置情報を削除したデバイスログを表示・エクスポート。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вывод отладочного журнала в реальном времени через последовательный порт, просмотр и экспорт журналов устройства со скрытыми позициями через Bluetooth." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25223,6 +29658,12 @@ "value" : "出力ピンブザーGPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выходной контакт зуммера GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25251,6 +29692,12 @@ "value" : "出力ピンGPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выходной контакт GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25279,6 +29726,12 @@ "value" : "出力ピン振動GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выходной контакт вибрации GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25294,6 +29747,7 @@ } }, "Overlanding" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -25307,6 +29761,12 @@ "value" : "オーバーランド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перекрывающий" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25341,6 +29801,12 @@ "value" : "自動OLEDスクリーン検出をオーバーライド。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переопределить автоматическое обнаружение OLED-экрана." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25357,6 +29823,12 @@ }, "Override default screen layout." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переопределить макет экрана по умолчанию" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25367,6 +29839,12 @@ }, "Packet Count" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Счетчик пакетов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25413,6 +29891,12 @@ "value" : "Tryb parowania" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим сопряжения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25477,6 +29961,12 @@ "value" : "Hasło" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пароль" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25541,6 +30031,12 @@ "value" : "Pause" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пауза" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25593,6 +30089,12 @@ "value" : "PAX Counter" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Счетчик прохожих" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25639,6 +30141,12 @@ "value" : "PAXカウンター設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка счетчика прохожих" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25666,6 +30174,7 @@ } }, "PAX Counter config received: %@" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -25679,6 +30188,12 @@ "value" : "PAXカウンター設定を受信しました: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Принята конфигурация счетчика прохожих: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25713,6 +30228,12 @@ "value" : "PAXカウンターログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал счетчика прохожих" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25734,6 +30255,7 @@ } }, "PAX Counter message received from: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -25765,6 +30287,12 @@ "value" : "PAX Counter packet received for: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение счетчика прохожих, полученное от: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25799,6 +30327,12 @@ "value" : "PAXカウンターログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "paxcounter.log" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25833,6 +30367,12 @@ "value" : "接続しているノードの工場出荷時リセットを実行" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выполнить сброс к заводским настройкам на ноде, к которой вы подключены" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25848,6 +30388,7 @@ } }, "Philippines 433MHz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -25861,6 +30402,12 @@ "value" : "フィリピン 433MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Филиппины 433МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25876,6 +30423,7 @@ } }, "Philippines 868MHz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -25889,6 +30437,12 @@ "value" : "フィリピン 868MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Филиппины 868МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25904,6 +30458,7 @@ } }, "Philippines 915MHz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -25917,6 +30472,12 @@ "value" : "フィリピン 915MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Филиппины 915МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25969,6 +30530,12 @@ "value" : "GPS telefonu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS телефона" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26003,6 +30570,12 @@ "value" : "Standorteinstellungen" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Местоположение телефона" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26025,6 +30598,12 @@ "value" : "ピン %lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26053,6 +30632,12 @@ "value" : "ピンA" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin A" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26081,6 +30666,12 @@ "value" : "ピンB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin B" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26115,6 +30706,12 @@ "value" : "PKIベースのノード管理、ファームウェアバージョン2.5+が必要" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Администрирование ноды на основе PKI, требуется версия прошивки 2.5+" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26143,6 +30740,12 @@ "value" : "マップレポートは暗号化されていないため、あなたのデータが第三者によって永続的に保存・表示される可能性があることをご承知ください。Meshtasticは、このようなデータの保存、表示、開示について一切の責任を負いません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имейте в виду, что поскольку отчет карты не зашифрован, ваши данные могут быть постоянно сохранены и отображены третьими сторонами. Meshtastic не несет ответственности за такое хранение, отображение или раскрытие этих данных." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26177,6 +30780,12 @@ "value" : "設定を構成するには無線機に接続してください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пожалуйста, подключитесь к радио для настройки параметров." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26198,6 +30807,7 @@ } }, "Please set a region" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -26217,6 +30827,12 @@ "value" : "地域を設定してください" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пожалуйста, укажите регион" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26245,6 +30861,12 @@ "value" : "興味地点" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Точки интереса" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26260,6 +30882,7 @@ } }, "Poop" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -26297,6 +30920,12 @@ "value" : "Kupa" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Какашка" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26322,6 +30951,9 @@ } } } + }, + "Port" : { + }, "Position" : { "localizations" : { @@ -26349,6 +30981,12 @@ "value" : "Pozycja" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиция" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26407,6 +31045,12 @@ "value" : "Konfiguracja pozycji" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка позиции" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26434,6 +31078,7 @@ } }, "Position config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -26471,6 +31116,12 @@ "value" : "Odebrano konfigurację pozycji: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация позиции: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26505,6 +31156,12 @@ "value" : "位置交換に失敗しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обмен позициями не удался" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26533,6 +31190,12 @@ "value" : "位置交換要求" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запрошен обмен позициями" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26561,6 +31224,12 @@ "value" : "位置フラグ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Флаги позиции" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26589,6 +31258,12 @@ "value" : "位置ログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал позиций" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26617,6 +31292,12 @@ "value" : "位置ログ %lld ポイント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал позиций %lld точек" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26645,6 +31326,12 @@ "value" : "位置パケット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пакет позиции" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26679,6 +31366,12 @@ "value" : "位置送信済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиция отправлена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26707,6 +31400,12 @@ "value" : "位置情報有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиции включены" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26741,6 +31440,12 @@ "value" : "位置情報はデバイスのGPSによって提供されます。無効または存在しないを選択した場合は、固定位置を設定できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиции будут предоставлены GPS вашего устройства. Если вы выберете отключено или отсутствует, вы можете установить фиксированную позицию." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26793,6 +31498,12 @@ "value" : "Power" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Питание" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26851,6 +31562,12 @@ "value" : "Power Config" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки питания" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26878,6 +31595,7 @@ } }, "Power config received: %@" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -26891,6 +31609,12 @@ "value" : "電源設定を受信しました: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация питания: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26912,6 +31636,7 @@ } }, "Power Metrics" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -26925,6 +31650,12 @@ "value" : "電源メトリクス" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метрики питания" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26953,6 +31684,12 @@ "value" : "電力メトリクスログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал метрик питания" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26981,6 +31718,12 @@ "value" : "電源オフ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27056,6 +31799,12 @@ "value" : "Power Saving" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Энергосбережение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -27096,6 +31845,12 @@ "value" : "電源画面" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Питание экрана" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27111,7 +31866,14 @@ } }, "Power Sensor Options" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры питания сенсоров" + } + } + } }, "Powered" : { "localizations" : { @@ -27133,6 +31895,12 @@ "value" : "電源供給中" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Питаемый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27167,6 +31935,12 @@ "value" : "精密位置" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Точное местоположение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27201,6 +31975,12 @@ "value" : "プリセット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пресеты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27235,6 +32015,12 @@ "value" : "プレスピン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт нажатия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27263,6 +32049,12 @@ "value" : "気圧" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Давление" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27315,6 +32107,12 @@ "value" : "Podstawowy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Первичный" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -27361,6 +32159,12 @@ "value" : "プライマリ管理キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Первичный ключ администратора" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27395,6 +32199,12 @@ "value" : "プライマリGPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Первичный GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27429,6 +32239,12 @@ "value" : "秘密キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Первичный ключ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27450,6 +32266,7 @@ } }, "Process" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -27487,6 +32304,12 @@ "value" : "Process" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Процесс" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -27533,6 +32356,12 @@ "value" : "ファイル処理中..." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработка файла..." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27573,6 +32402,12 @@ "value" : "プロジェクト情報" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Информация о проекте" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27594,6 +32429,7 @@ } }, "Protobufs" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -27631,6 +32467,12 @@ "value" : "Protobufy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Протобуферы" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -27665,6 +32507,12 @@ "value" : "Teile anonyme Nutzungsstatistiken und Absturzberichte." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предоставлять анонимную статистику использования и отчеты о сбоях." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27675,6 +32523,12 @@ }, "Provide Confirmation" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предоставить подтверждение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27703,6 +32557,12 @@ "value" : "公開キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Публичный ключ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27737,6 +32597,12 @@ "value" : "公開鍵暗号化" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Шифрование с открытым ключом" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27771,6 +32637,12 @@ "value" : "公開キー不一致" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Несоответствие открытого ключа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27805,6 +32677,12 @@ "value" : "パスワード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "PWD" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27820,6 +32698,7 @@ } }, "Question" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -27857,6 +32736,12 @@ "value" : "Znak zapytania" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вопрос" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -27897,6 +32782,12 @@ "value" : "放射線" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Излучение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27949,6 +32840,12 @@ "value" : "Konfiguracja radia" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка радио" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -27976,6 +32873,7 @@ } }, "RAK Rotary Encoder" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -28013,6 +32911,12 @@ "value" : "Kodera obrotowego RAK" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "RAK поворотный энкодер" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28077,6 +32981,12 @@ "value" : "Test zasięgu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тест дальности" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28141,6 +33051,12 @@ "value" : "Konfiguracja testu zasięgu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка теста дальности" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28168,6 +33084,7 @@ } }, "Range Test module config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -28205,6 +33122,12 @@ "value" : "Odebrano konfigurację modułu testu zasięgu: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получение конфигурация модуля теста дальности: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28269,6 +33192,12 @@ "value" : "Uruchom ponownie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезагрузка" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28333,6 +33262,12 @@ "value" : "Uruchomić ponownie węzeł?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезагрузить ноду?" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28360,6 +33295,7 @@ } }, "Rebroadcast any observed message, if it was on our private channel or from another mesh with the same lora params." : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { @@ -28367,6 +33303,12 @@ "value" : "プライベートチャンネル上、または同じLoRaパラメータを持つ他のメッシュからの観測されたメッセージを再ブロードキャストします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторно передать любое наблюдаемое сообщение, если оно было на нашем приватном канале или из другой сети с такими же параметрами LoRa." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28395,6 +33337,12 @@ "value" : "再ブロードキャストモード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим повторной передачи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28429,6 +33377,12 @@ "value" : "受信データ(RXD)GPIOピン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO приема данных (rxd)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28444,6 +33398,7 @@ } }, "Received a negative acknowledgment" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -28481,6 +33436,12 @@ "value" : "Otrzymano negatywne potwierdzenie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получено отрицательное подтверждение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28573,7 +33534,14 @@ } }, "Received Ack: %@" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получено подтверждение: %@" + } + } + } }, "Recipient Ack" : { "extractionState" : "stale", @@ -28641,7 +33609,14 @@ } }, "Recipient Ack: %@" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтверждение получателя: %@" + } + } + } }, "Recording route" : { "localizations" : { @@ -28663,6 +33638,12 @@ "value" : "ルート記録中" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запись маршрута" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28691,6 +33672,12 @@ "value" : "デバイスメタデータを更新" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновить метаданные устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28713,6 +33700,12 @@ "value" : "プライベートキーを再生成" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать заново закрытый ключ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28741,6 +33734,12 @@ "value" : "地域" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Регион" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28762,6 +33761,7 @@ } }, "Regional Duty Cycle Limit Reached" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -28799,6 +33799,12 @@ "value" : "Osiągnięto regionalny limit cyklu pracy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Достигнут региональный лимит рабочего цикла" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28832,6 +33838,12 @@ "state" : "new", "value" : "Relayed by %1$d %2$@" } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Передано %1$d %2$@" + } } } }, @@ -28849,6 +33861,12 @@ "value" : "リリースノート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Примечания к выпуску" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28862,6 +33880,9 @@ } } } + }, + "Reload Bundled Certificates" : { + }, "Remote administration for: %@" : { "localizations" : { @@ -28877,6 +33898,12 @@ "value" : "%@ のリモート管理" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаленное администрирование для: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28905,6 +33932,12 @@ "value" : "リモートレガシー管理: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаленное администрирование устаревших систем: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28933,6 +33966,12 @@ "value" : "リモートPKI管理: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаленное администрирование PKI: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28967,6 +34006,12 @@ "value" : "削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29001,6 +34046,12 @@ "value" : "お気に入りから削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить из избранного" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29029,6 +34080,12 @@ "value" : "無視リストから削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить из игнорируемых" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29050,6 +34107,7 @@ } }, "Repeater" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -29069,6 +34127,12 @@ "value" : "リピーター" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ретранслятор" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29103,6 +34167,12 @@ "value" : "チャンネル置換" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заменить каналы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29155,6 +34225,12 @@ "value" : "Odpowiedz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ответить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29195,6 +34271,12 @@ "value" : "レガシー管理者要求: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запросить администрирование устаревшей системы: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29223,6 +34305,12 @@ "value" : "PKI管理者要求: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запросить администрирование PKI: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29238,6 +34326,7 @@ } }, "Requested Canned Messages Module Messages for node: %@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -29269,6 +34358,12 @@ "value" : "Zażądano Wiadomości z Modułu Wiadomości Gotowych dla węzła: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запрошены сообщения модуля готовых сообщений для ноды: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29309,6 +34404,12 @@ "value" : "デバイスに加速度計が必要です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется наличие акселерометра на вашем устройстве." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29343,6 +34444,12 @@ "value" : "アプリ設定をリセット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сброс настроек приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29383,6 +34490,12 @@ "value" : "NodeDBをリセット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сброс NodeDB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29396,6 +34509,9 @@ } } } + }, + "Reset to Default" : { + }, "Restart" : { "localizations" : { @@ -29417,6 +34533,12 @@ "value" : "再起動" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезапуск" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29430,6 +34552,9 @@ } } } + }, + "Restart Server" : { + }, "Restart to the node you are connected to" : { "localizations" : { @@ -29451,6 +34576,12 @@ "value" : "接続しているノードを再起動" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезагрузить ноду, к которой вы подключены" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29473,6 +34604,12 @@ "value" : "復元" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстановить" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29519,6 +34656,12 @@ "value" : "Resume" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29547,6 +34690,12 @@ }, "Retreiving nodes . ." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка нод . ." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29556,7 +34705,14 @@ } }, "Retreiving nodes %lld" : { + "extractionState" : "stale", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка нод: %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29567,6 +34723,12 @@ }, "Retrieving nodes" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка нод" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29577,6 +34739,12 @@ }, "Retrieving nodes %lld" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка нод: %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29587,6 +34755,12 @@ }, "Retrying (attempt %lld)" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторная попытка (попытка %lld)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29615,6 +34789,12 @@ "value" : "アプリをレビュー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оценить приложение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29636,6 +34816,7 @@ } }, "Right" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -29673,6 +34854,12 @@ "value" : "W Prawo" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вправо" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29737,6 +34924,12 @@ "value" : "Dzwonek" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рингтон" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29795,6 +34988,12 @@ "value" : "Ringtone Config" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка рингтона" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29835,6 +35034,12 @@ "value" : "着信音転送言語" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Язык передачи рингтона" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29887,6 +35092,12 @@ "value" : "Ringtone Transfer Language(RTTTL) Ringtone String used by supported buzzers in external notifications." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Язык передачи рингтона (RTTTL) - строка рингтона, используемая поддерживаемыми зуммерами во внешних уведомлениях." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29933,6 +35144,12 @@ "value" : "役割" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Роль" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29948,6 +35165,7 @@ } }, "Role: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -29967,6 +35185,12 @@ "value" : "役割: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Роль: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30001,6 +35225,12 @@ "value" : "役割" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Роли" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30029,6 +35259,12 @@ "value" : "ルートトピック" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Основная тема" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30057,6 +35293,12 @@ "value" : "ロータリー1" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поворотный 1" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30091,6 +35333,12 @@ "value" : "ルート(復路): %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Маршрут обратно: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30119,6 +35367,12 @@ "value" : "ルートライン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Линии маршрута" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30141,6 +35395,12 @@ "value" : "Routenliste" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Список маршрутов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30169,6 +35429,12 @@ "value" : "ルートレコーダー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Записывающее устройство маршрутов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30203,6 +35469,12 @@ "value" : "ルート記録を一時停止" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запись маршрута приостановлена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30237,6 +35509,12 @@ "value" : "ルート: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Маршрут: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30252,6 +35530,7 @@ } }, "Router" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -30271,6 +35550,12 @@ "value" : "ルーター" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Маршрутизатор" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30292,6 +35577,7 @@ } }, "Router Late" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -30311,6 +35597,12 @@ "value" : "ルーター遅延" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Маршрутизатор с задержкой" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30345,6 +35637,12 @@ "value" : "ルート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Маршруты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30360,6 +35658,7 @@ } }, "Routing received for RequestID: %@ Ack Status: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -30397,6 +35696,12 @@ "value" : "Odebrano trasowanie dla RequestID: %@ Ack Status: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена маршрутизация для RequestID: %@ Статус подтверждения: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -30437,6 +35742,12 @@ "value" : "RSSI %@ dBm" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %@ dBm" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30465,6 +35776,12 @@ "value" : "RSSI %ddB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %ddB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30480,6 +35797,7 @@ } }, "RSSI %llddB" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -30493,6 +35811,12 @@ "value" : "RSSI %llddB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %llddB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30508,6 +35832,7 @@ } }, "RTTTL Ringtone config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -30545,6 +35870,12 @@ "value" : "Odebrano konfigurację dzwonka RTTTL: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация мелодии RTTTL: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -30572,6 +35903,7 @@ } }, "Russia" : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { @@ -30579,6 +35911,12 @@ "value" : "ロシア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Россия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30607,6 +35945,12 @@ "value" : "RX ブーストゲイン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усиление приема" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30622,6 +35966,7 @@ } }, "Same as behavior as ALL but skips packet decoding and simply rebroadcasts them. Only available in Repeater role. Setting this on any other roles will result in ALL behavior." : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { @@ -30629,6 +35974,12 @@ "value" : "ALLと同じ動作ですが、パケットのデコードをスキップして単純に再ブロードキャストします。リピーター役割でのみ利用可能です。他の役割で設定するとALLの動作になります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Такое же поведение, как ALL, но пропускает декодирование пакета и просто повторно передает его. Доступно только в роли Repeater. Установка этого для любых других ролей приведет к поведению ALL." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30644,6 +35995,7 @@ } }, "Satellite" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -30669,6 +36021,12 @@ "value" : "Satelita" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спутник" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -30696,6 +36054,7 @@ } }, "Satellite Flyover" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -30727,6 +36086,12 @@ "value" : "Przelot satelity" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пролёт спутника" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -30773,6 +36138,12 @@ "value" : "衛星" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спутников" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30807,6 +36178,12 @@ "value" : "衛星推定数 %lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оценка спутников %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30841,6 +36218,12 @@ "value" : "視野内衛星数: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спутников в зоне видимости: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30893,6 +36276,12 @@ "value" : "Zapisz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -30933,6 +36322,12 @@ "value" : "チャンネル設定を保存" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранение настроек канала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30948,6 +36343,7 @@ } }, "Save Config for %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -30985,6 +36381,12 @@ "value" : "Zapisz konfigurację dla %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранение конфигурации для %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -31031,6 +36433,12 @@ "value" : "ユーザー設定を %@ に保存しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить конфигурацию пользователя в %@?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31065,6 +36473,12 @@ "value" : "レンジテストメッセージの詳細をCSVで保存します。現在、Webサーバーを持つESP32デバイスでのみ利用可能です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохраняет CSV с деталями сообщений тестирования дальности, в настоящее время доступно только на устройствах ESP32 с веб-сервером." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31093,6 +36507,12 @@ "value" : "このQRコードをスキャンして、%@ を別のデバイスに追加してください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отсканируйте этот QR-код, чтобы добавить %@ на другое устройство." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31121,6 +36541,12 @@ "value" : "画面オン時間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экран включен на" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31155,6 +36581,12 @@ "value" : "検索" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31183,6 +36615,12 @@ "value" : "秒" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Второй" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31235,6 +36673,12 @@ "value" : "Wtórny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Второй" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -31281,6 +36725,12 @@ "value" : "副管理者キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Второй ключ админа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31300,6 +36750,9 @@ } } } + }, + "Secure mTLS connection on port 8089. Both server and client certificates are required." : { + }, "Security" : { "localizations" : { @@ -31321,6 +36774,12 @@ "value" : "セキュリティ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Безопасность" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31361,6 +36820,12 @@ "value" : "セキュリティ設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки безопасности" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31401,6 +36866,12 @@ "value" : "セキュリティ設定にはファームウェアバージョン2.5以上が必要です" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки безопасности требуют версии прошивки 2.5+" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31422,6 +36893,7 @@ } }, "Select" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -31459,6 +36931,12 @@ "value" : "Wybierz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -31505,6 +36983,12 @@ "value" : "チャンネルを選択" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать канал" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31533,6 +37017,12 @@ "value" : "会話を選択" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите беседу" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31561,6 +37051,12 @@ "value" : "会話タイプを選択" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите тип беседы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31576,7 +37072,14 @@ } }, "Select a Node" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите ноду" + } + } + } }, "Select a node from the drop down to manage connected or remote devices." : { "localizations" : { @@ -31586,6 +37089,12 @@ "value" : "ドロップダウンからノードを選択して、接続済みまたはリモートデバイスを管理してください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите ноду из выпадающего списка для управления подключенными или удаленными устройствами." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31614,6 +37123,12 @@ "value" : "トレースルートを選択" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите маршрут трассировки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31629,7 +37144,14 @@ } }, "Select an emoji" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите эмодзи" + } + } + } }, "Select Channel" : { "localizations" : { @@ -31645,6 +37167,12 @@ "value" : "チャンネル選択" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите канал" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31667,6 +37195,12 @@ "value" : "Datei auswählen" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите файл карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31683,6 +37217,12 @@ "value" : "Als kritisch eingestufte Mitteilungen ignorieren den Stummschalter und die 'Nicht stören'-Einstellungen des Benachrichtigungszentrums." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите пакеты, отправленные как критические, они будут игнорировать переключатель отключения звука и настройки «Не беспокоить» в центре уведомлений ОС." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31711,6 +37251,12 @@ "value" : "送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31745,6 +37291,12 @@ "value" : "${messageContent} をチャンネル ${channelNumber} に送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить ${messageContent} в ${channelNumber}" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31773,6 +37325,12 @@ "value" : "${messageContent} をノード ${nodeNumber} に送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить ${messageContent} на ${nodeNumber}" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31789,6 +37347,12 @@ }, "Send a Direct Message" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить личное сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31817,6 +37381,12 @@ "value" : "グループメッセージを送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить групповое сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31845,6 +37415,12 @@ "value" : "サーバーの存在を通知するためのハートビートを送信します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить heartbeat для оповещения о присутствии сервера." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31873,6 +37449,12 @@ "value" : "特定のMeshtasticチャンネルにメッセージを送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить сообщение на определенный канал Meshtastic" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31889,6 +37471,12 @@ }, "Send a message to a certain meshtastic node" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить сообщение на определенную ноду Meshtastic" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31911,6 +37499,12 @@ "value" : "ユーザーボタンが3回クリックされたときにプライマリチャンネルで位置を送信します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить позицию на первичном канале при тройном нажатии пользовательской кнопки." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31951,6 +37545,12 @@ "value" : "接続しているノードにシャットダウン信号を送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправьте сообщение о завершении работы на ноду, к которой вы подключены" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31985,6 +37585,12 @@ "value" : "ウェイポイントを送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить путевую точку" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32013,6 +37619,12 @@ "value" : "アラートメッセージ付きASCIIベルを送信。ベルでの外部通知のトリガーに便利です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправьте ASCII колокольчик с предупреждающим сообщением. Полезно для срабатывания внешнего уведомления на колокольчик." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32053,6 +37665,12 @@ "value" : "ベル送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить колокол" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32111,6 +37729,12 @@ "value" : "Send Heartbeat" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить сердцебиение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32145,6 +37769,12 @@ "value" : "Mitteilungen senden" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить уведомления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32167,6 +37797,12 @@ "value" : "OTA再起動送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить перезагрузку OTA" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32188,6 +37824,7 @@ } }, "Sender Interval" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -32201,6 +37838,12 @@ "value" : "送信者間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал отправителя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32222,6 +37865,7 @@ } }, "Sensor" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -32241,6 +37885,12 @@ "value" : "センサー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Датчик" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32275,6 +37925,12 @@ "value" : "センサーオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры датчика" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32319,6 +37975,7 @@ } }, "Sent a Channel for: %@ Channel Index %d" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -32350,6 +38007,12 @@ "value" : "Wysłano kanał dla: %@ Indeks kanału %d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлен канал для: %@ Индекс канала %d" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32377,6 +38040,7 @@ } }, "Sent a LoRa.Config for: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -32414,6 +38078,12 @@ "value" : "Wysłano konfigurację LoRa dla: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлена конфигурация LoRa для: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32479,6 +38149,12 @@ "value" : "Wysłano pakiet pozycji z GPS urządzenia Apple do węzła: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлен пакет позиции с GPS устройства Apple на ноду: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32506,6 +38182,7 @@ } }, "Sent a Trace Route Request to node: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -32543,6 +38220,12 @@ "value" : "Wysłano żądanie śledzenia trasy do węzła: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлен запрос трассировки маршрута на ноду: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32570,6 +38253,7 @@ } }, "Sent a Waypoint Packet from: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -32607,6 +38291,12 @@ "value" : "Wysłano pakiet punktu orientacyjnego z: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлен пакет путевой точки от: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32634,6 +38324,7 @@ } }, "Sent message %@ from %@ to %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -32671,6 +38362,12 @@ "value" : "Wysłano wiadomość %@ od %@ do %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлено сообщение %@ от %@ к %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32717,6 +38414,12 @@ "value" : "シーケンス番号" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Порядковый номер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32751,6 +38454,12 @@ "value" : "シーケンス: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последовательность: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32797,6 +38506,12 @@ "value" : "Seryjny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последовательный порт" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32861,6 +38576,12 @@ "value" : "Konfiguracja seryjna" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка последовательного порта" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32907,6 +38628,12 @@ "value" : "シリアルコンソール" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Консоль последовательного порта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32941,6 +38668,12 @@ "value" : "Stream API経由のシリアルコンソール。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Консоль последовательного порта через Stream API." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32956,6 +38689,7 @@ } }, "Serial module config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -32993,6 +38727,12 @@ "value" : "Odebrano konfigurację modułu szeregowego: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля последовательного порта: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33033,6 +38773,12 @@ "value" : "系列" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Серии" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33067,6 +38813,12 @@ "value" : "サーバー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сервер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33101,6 +38853,12 @@ "value" : "サーバーアドレス" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адрес сервера" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33114,6 +38872,9 @@ } } } + }, + "Server Certificate" : { + }, "Server Option" : { "localizations" : { @@ -33129,6 +38890,12 @@ "value" : "サーバーオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры сервера" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33142,6 +38909,9 @@ } } } + }, + "Server Status" : { + }, "Set" : { "localizations" : { @@ -33157,6 +38927,12 @@ "value" : "設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устан" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33209,6 +38985,12 @@ "value" : "Ustaw region LoRa" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить регион LoRa" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33249,6 +39031,12 @@ "value" : "RXDとTXDのGPIOピンを設定します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установите контакты GPIO для RXD и TXD." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33271,6 +39059,12 @@ "value" : "現在の位置に設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить текущее местоположение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33293,6 +39087,12 @@ "value" : "最大ホップ数を設定します。デフォルトは3です。ホップ数を増やすと輻輳も増加するため、慎重に使用してください。0ホップのブロードキャストメッセージはACKを受信しません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устанавливает максимальное количество хопов, по умолчанию 3. Увеличение хопов также увеличивает перегрузку и должно использоваться осторожно. Широковещательные сообщения с 0 хопов не получат подтверждений." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33315,6 +39115,12 @@ "value" : "画面の時計表示を12時間形式に設定します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устанавливает 12-часовой формат экранных часов." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33331,6 +39137,12 @@ "value" : "Einstellungen" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "настройки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33377,6 +39189,12 @@ "value" : "Ustawienia" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33404,6 +39222,7 @@ } }, "Seventy Two Hours" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -33441,6 +39260,12 @@ "value" : "Siedemdziesiąt Dwie Godziny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Семьдесят два часа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33475,6 +39300,12 @@ "value" : "連絡先QRを共有" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться QR контакта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33497,6 +39328,12 @@ "value" : "Standort teilen" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться местоположением" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33543,6 +39380,12 @@ "value" : "Udostępnij kod QR kanałów" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться QR-кодом" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33589,6 +39432,12 @@ "value" : "QRコードとリンクを共有" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться QR-кодом и ссылкой" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33611,6 +39460,12 @@ "value" : "Teile deinen Standort in Echtzeit und koordiniere deine Gruppe mithilfe integrierter GPS-Funktionen." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Делитесь своим местоположением в реальном времени и поддерживайте координацию группы с интегрированными функциями GPS." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33639,6 +39494,12 @@ "value" : "共有キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Общий ключ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33691,6 +39552,12 @@ "value" : "Sharing Meshtastic Channels" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Совместное использование каналов Meshtastic" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33737,6 +39604,12 @@ "value" : "短い名前" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Короткое имя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33758,6 +39631,7 @@ } }, "Short Range - Fast" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -33771,6 +39645,12 @@ "value" : "短距離 - 高速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Short Range - Fast" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33786,6 +39666,7 @@ } }, "Short Range - Slow" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -33799,6 +39680,12 @@ "value" : "短距離 - 低速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Short Range - Slow" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33814,6 +39701,7 @@ } }, "Short Range - Turbo" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -33827,6 +39715,12 @@ "value" : "短距離 - ターボ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Short Range - Turbo" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33843,6 +39737,12 @@ }, "Show a confirmation dialog before performing the factory reset" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать диалог подтверждения перед выполнением сброса к заводским настройкам" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33871,6 +39771,12 @@ "value" : "アラート表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать оповещения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33905,6 +39811,12 @@ "value" : "アラート表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать оповещения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33939,6 +39851,12 @@ "value" : "ノード表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33973,6 +39891,12 @@ "value" : "デバイス画面に表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать на экране устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34007,6 +39931,12 @@ "value" : "メッシュマップに表示。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать на карте сети." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34058,6 +39988,12 @@ }, "Shows information for the connected Lora radio. You can swipe left to disconnect the radio and long press to start the live activity." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывает информацию о подключенном радио LoRa. Вы можете провести влево, чтобы отключить радио, и нажать долго, чтобы запустить текущую активность." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34086,6 +40022,12 @@ "value" : "シャットダウン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34120,6 +40062,12 @@ "value" : "ノードをシャットダウンしますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключить ноду?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34154,6 +40102,12 @@ "value" : "ノードをシャットダウンしますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключить ноду?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34200,6 +40154,12 @@ "value" : "Shutdown on Power Loss" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключение при потере питания" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -34240,6 +40200,12 @@ "value" : "信号 %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сигнал %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34255,6 +40221,7 @@ } }, "Simple" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -34292,6 +40259,12 @@ "value" : "Prosty" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Простой" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -34319,6 +40292,7 @@ } }, "Singapore 923MHz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -34332,6 +40306,12 @@ "value" : "シンガポール 923MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сингапур 923 МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34347,6 +40327,7 @@ } }, "Six Hours" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -34384,6 +40365,12 @@ "value" : "Sześć Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Шесть часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -34411,6 +40398,7 @@ } }, "Skiing" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -34430,6 +40418,12 @@ "value" : "スキー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Катание на лыжах" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34464,6 +40458,12 @@ "value" : "スマート位置" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Умное позиционирование" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34492,6 +40492,12 @@ "value" : "SNR" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34520,6 +40526,12 @@ "value" : "SNR %@ dB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@ dB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34548,6 +40560,12 @@ "value" : "SNR %@dB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@dB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34576,6 +40594,12 @@ "value" : "土壌水分" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Влажность почвы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34604,6 +40628,12 @@ "value" : "土壌温度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Температура почвы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34667,6 +40697,12 @@ "value" : "速度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34701,6 +40737,12 @@ "value" : "速度 %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34735,6 +40777,12 @@ "value" : "速度: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34763,6 +40811,12 @@ "value" : "アプリ開発をスポンサー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спонсор разработки приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34785,6 +40839,12 @@ "value" : "拡散係数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Коэф. распространения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34837,6 +40897,12 @@ "value" : "SSID" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSID" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -34864,6 +40930,7 @@ } }, "Standard" : { + "extractionState" : "stale", "localizations" : { "he" : { "stringUnit" : { @@ -34889,6 +40956,12 @@ "value" : "Standardowy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стандартный" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34910,6 +40983,7 @@ } }, "Standard Muted" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -34941,6 +41015,12 @@ "value" : "Standardowy wyłączony" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стандартный без звука" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35005,6 +41085,12 @@ "value" : "Start" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начало" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35032,6 +41118,7 @@ } }, "State Broadcast Interval" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -35045,6 +41132,12 @@ "value" : "状態ブロードキャスト間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал передачи состояния" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35058,6 +41151,9 @@ } } } + }, + "Status" : { + }, "Stay Connected Anywhere" : { "localizations" : { @@ -35067,6 +41163,12 @@ "value" : "Überall in Verbindung bleiben" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оставайтесь на связи везде" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35089,6 +41191,12 @@ "value" : "蓄積転送" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить и переслать" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35123,6 +41231,12 @@ "value" : "蓄積転送設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка сохранения и пересылки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35144,6 +41258,7 @@ } }, "Store & Forward module config received: %@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -35175,6 +41290,12 @@ "value" : "Store & Forward module config received: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля сохранения и пересылки: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35215,6 +41336,12 @@ "value" : "蓄積転送サーバーには、PSRAM搭載のESP32デバイスまたはLinux Nativeが必要です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Серверы сохранения и пересылки требуют устройство ESP32 с PSRAM или Linux Native." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35249,6 +41376,12 @@ "value" : "購読済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подписан" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35264,6 +41397,7 @@ } }, "Subsystem" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -35277,6 +41411,12 @@ "value" : "サブシステム" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подсистема" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35305,6 +41445,12 @@ "value" : "Successfully uploaded '%1$@' with %2$lld overlays" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Успешно загружено '%1$@' с заменой %2$lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35333,6 +41479,12 @@ "value" : "サポート済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поддерживаемый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35361,6 +41513,12 @@ "value" : "サポートされているI2C接続センサーは自動的に検出されます。センサーはBMP280、BME280、BME680、MCP9808、INA219、INA260、LPS22、およびSHTC3です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поддерживаемые датчики, подключенные через I2C, будут автоматически обнаружены. Датчики: BMP280, BME280, BME680, MCP9808, INA219, INA260, LPS22 и SHTC3." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35382,6 +41540,7 @@ } }, "Table" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -35395,6 +41554,12 @@ "value" : "テーブル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Таблица" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35410,6 +41575,7 @@ } }, "Taiwan" : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { @@ -35417,6 +41583,12 @@ "value" : "台湾" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тайвань" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35432,6 +41604,7 @@ } }, "TAK" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -35451,6 +41624,12 @@ "value" : "TAK" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAK" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35470,8 +41649,12 @@ } } } + }, + "TAK Server" : { + }, "TAK Tracker" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -35491,6 +41674,12 @@ "value" : "TAKトラッカー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAK трекер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35525,6 +41714,12 @@ "value" : "MeshtasticチャンネルURLを取得し、チャンネル設定を保存します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Принимает URL канала Meshtastic и сохраняет настройки канала." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35547,6 +41742,12 @@ "value" : "Meshtastic連絡先URLを取得し、ノードデータベースに保存します" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Принимает URL контакта Meshtastic и сохраняет его в базу данных нод" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35562,7 +41763,14 @@ } }, "Tap to enter emoji" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тапните для ввода эмодзи" + } + } + } }, "Tapback" : { "localizations" : { @@ -35602,6 +41810,12 @@ "value" : "Odpowiedź na stuknięcie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обратная связь" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35628,9 +41842,6 @@ } } }, - "TCP" : { - "shouldTranslate" : false - }, "Telemetry" : { "localizations" : { "de" : { @@ -35669,6 +41880,12 @@ "value" : "Telemetria (czujniki)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Телеметрия" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35733,6 +41950,12 @@ "value" : "Konfiguracja telemetrii" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки телеметрии" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35760,6 +41983,7 @@ } }, "Telemetry module config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -35797,6 +42021,12 @@ "value" : "Odebrano konfigurację modułu telemetrii: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля телеметрии: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35824,6 +42054,7 @@ } }, "Temp" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -35843,6 +42074,12 @@ "value" : "温度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Темп" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35877,6 +42114,12 @@ "value" : "温度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Температура" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35929,6 +42172,12 @@ "value" : "Dziesięć Minut" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Десять минут" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35956,6 +42205,7 @@ } }, "Ten Seconds" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -35993,6 +42243,12 @@ "value" : "Dziesięć Sekund" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Десять секунд" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -36039,6 +42295,12 @@ "value" : "第三管理者キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Третичный ключ администратора" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36060,6 +42322,7 @@ } }, "Text Message" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -36097,6 +42360,12 @@ "value" : "Wiadomość tekstowa" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текстовое сообщение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -36124,6 +42393,7 @@ } }, "TFT Full Color Displays" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -36137,6 +42407,12 @@ "value" : "TFTフルカラーディスプレイ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Полноцветные TFT-дисплеи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36152,6 +42428,7 @@ } }, "Thailand" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -36165,6 +42442,12 @@ "value" : "タイ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тайланд" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36193,6 +42476,12 @@ "value" : "パケットが完了したと見なすまでの待機時間。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время ожидания, прежде чем мы сочтем ваш пакет завершенным." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36221,6 +42510,12 @@ "value" : "画面上の円の外側にあるコンパスの方位は常に北を指します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Направление компаса на экране вне круга всегда будет указывать на север." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36255,6 +42550,12 @@ "value" : "現在の露点は %@ です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Точка росы: %@." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36283,6 +42584,12 @@ "value" : "最小距離条件が満たされた場合の位置更新送信の最短間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимальная частота, с которой будут отправлены обновления позиции, если минимальное расстояние соблюдено" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36317,6 +42624,12 @@ "value" : "デバイスのBLE名を設定するため、MACアドレスの末尾4桁が短縮名に追加されます。短縮名は最大4バイトまでです。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последние 4 символа MAC-адреса устройства будут добавлены к короткому имени для установки имени BLE устройства. Короткое имя может быть длиной до 4 байт." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36351,6 +42664,12 @@ "value" : "ノードが位置をブロードキャストしない最大間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимальный интервал, который может пройти без передачи нодой позиции" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36379,6 +42698,12 @@ "value" : "Meshtastic Appleアプリはファームウェアバージョン %@ 以上をサポートしています。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приложения Meshtastic для Apple поддерживают версию прошивки %@ и выше." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36413,6 +42738,12 @@ "value" : "スマート位置ブロードキャストで考慮される最小距離変化(メートル)。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минимальное изменение расстояния в метрах, которое будет учитываться для интеллектуальной передачи позиции." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36434,6 +42765,7 @@ } }, "The packet is too large" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -36471,6 +42803,12 @@ "value" : "Pakiet jest zbyt duży" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пакет слишком большой" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -36517,6 +42855,12 @@ "value" : "このノードに管理メッセージを送信する権限を持つプライマリ公開キー。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Основной открытый ключ, авторизованный для отправки административных сообщений этой ноде." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36557,6 +42901,12 @@ "value" : "無線機を使用する地域。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Регион, в котором вы будете использовать ваше устройство" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36591,6 +42941,12 @@ "value" : "MQTTに使用するルートトピック。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Корневая тема для использования MQTT." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36612,7 +42968,14 @@ } }, "The Router roles are only for high vantage locations like mountaintops and towers with few nearby nodes, not for use in urban areas. Improper use will hurt your local mesh." : { + "extractionState" : "stale", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Роли Router предназначены только для высоких точек обзора, таких как вершины гор и башни с небольшим количеством близлежащих нод, не для использования в городских районах. Неправильное использование повредит вашей локальной сети." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36641,6 +43004,12 @@ "value" : "このノードに管理メッセージを送信する権限を持つセカンダリ公開キー。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вторичный открытый ключ, авторизованный для отправки административных сообщений этой ноде." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36681,6 +43050,12 @@ "value" : "LEDの状態(オン/オフ)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Состояние LED (вкл/выкл)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36715,6 +43090,12 @@ "value" : "このノードに管理メッセージを送信する権限を持つ三次公開キー。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Третичный открытый ключ, авторизованный для отправки административных сообщений этой ноде." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36749,6 +43130,12 @@ "value" : "チャンネル設定のURL" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL настроек канала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36771,6 +43158,12 @@ "value" : "追加するノードのURL" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL ноды для добавления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36787,6 +43180,12 @@ }, "There has been no response to a request for device metadata via PKC admin for this node." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не было ответа на запрос метаданных устройства через PKC admin для этой ноды." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36797,6 +43196,12 @@ }, "There is an issue with this contact's public key." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Возникла проблема с открытым ключом этого контакта." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36807,7 +43212,15 @@ }, "These settings will %@" : { "comment" : "A paragraph below the title that explains what the user is about to do.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эти настройки будут %@" + } + } + } }, "These settings will %@ channels. The current LoRa Config will be replaced, if there are substantial changes to the LoRa config the device will reboot" : { "extractionState" : "stale", @@ -36876,6 +43289,12 @@ "value" : "Trzydzieści Minut" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тридцать минут" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -36903,6 +43322,7 @@ } }, "Thirty Seconds" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -36940,6 +43360,12 @@ "value" : "Trzydzieści Sekund" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тридцать секунд" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -36967,6 +43393,7 @@ } }, "Thirty Six Hours" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -37004,6 +43431,12 @@ "value" : "Trzydzieści Sześć Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тридцать шесть часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37044,6 +43477,12 @@ "value" : "この会話は削除されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эта беседа будет удалена." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37072,6 +43511,12 @@ "value" : "これには時間がかかる場合があります。応答は送信先ノードのトレースルートログに表示されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это может занять некоторое время, ответ появится в журнале трассировки маршрута для ноды, которой он был отправлен." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37100,6 +43545,12 @@ "value" : "このデバイスは選択した間隔でレンジテストメッセージを送信します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это устройство будет отправлять сообщения тестирования дальности с выбранным интервалом." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37140,6 +43591,12 @@ "value" : "このメッセージは配信されなかった可能性があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это сообщение, вероятно, не было доставлено." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37168,6 +43625,12 @@ "value" : "このノードは設定可能なモジュールをサポートしていません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эта нода не поддерживает никаких настраиваемых модулей." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37196,6 +43659,12 @@ "value" : "これにより固定位置が無効になり、現在設定されている位置が削除されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это отключит фиксированную позицию и удалит текущую установленную позицию." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37224,6 +43693,12 @@ "value" : "これにより、お使いの携帯電話から現在位置を送信し、固定位置を有効にします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это отправит текущую позицию с вашего телефона и включит фиксированную позицию." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37245,6 +43720,7 @@ } }, "Three Hours" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -37282,6 +43758,12 @@ "value" : "Trzy Godziny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Три часа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37309,6 +43791,7 @@ } }, "Three Seconds" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -37346,6 +43829,12 @@ "value" : "Trzy Sekundy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Три секунды" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37373,6 +43862,7 @@ } }, "Thumbs Down" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -37410,6 +43900,12 @@ "value" : "Kciuk w dół" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Палец вниз" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37437,6 +43933,7 @@ } }, "Thumbs Up" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -37474,6 +43971,12 @@ "value" : "Kciuk w górę" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Палец вверх" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37520,6 +44023,12 @@ "value" : "時刻" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37554,6 +44063,12 @@ "value" : "タイムスタンプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отметка времени" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37588,6 +44103,12 @@ "value" : "タイムゾーン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Часовой пояс" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37622,6 +44143,12 @@ "value" : "デバイス画面とログの日付用タイムゾーン。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Часовой пояс для отображения дат на экране устройства и в журнале." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37674,6 +44201,12 @@ "value" : "Limit czasu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Таймаут" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37738,6 +44271,12 @@ "value" : "Znacznik czasu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отметка времени" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37766,6 +44305,12 @@ }, "Timing and Overrides" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбор времени и переопределения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37773,6 +44318,9 @@ } } } + }, + "TLS Certificates" : { + }, "TLS Enabled" : { "localizations" : { @@ -37788,6 +44336,12 @@ "value" : "TLS有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "TLS включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37816,6 +44370,12 @@ "value" : "CCPAやGDPRなどのプライバシー法に準拠するため、正確な位置データの共有は避けています。代わりに、あなたのプライバシーを保護するために匿名化または近似(不正確)の位置情報を使用します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для соблюдения законов о конфиденциальности, таких как CCPA и GDPR, мы избегаем передачи точных данных о местоположении. Вместо этого мы используем анонимизированную или приблизительную (неточную) информацию о местоположении для защиты вашей конфиденциальности." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37832,6 +44392,12 @@ }, "To Radio (TX): %lld" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "К радио (TX): %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37841,6 +44407,7 @@ } }, "Topic: %@" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -37854,6 +44421,12 @@ "value" : "トピック: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37888,6 +44461,12 @@ "value" : "合計" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37916,6 +44495,12 @@ "value" : "総PAX" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего PAX" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37950,6 +44535,12 @@ "value" : "トレースルート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37972,6 +44563,12 @@ "value" : "トレースルート(%@秒)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута (через %@ сек)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38000,6 +44597,12 @@ "value" : "トレースルートログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал трассировки маршрута" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38015,6 +44618,7 @@ } }, "Trace Route request returned: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -38052,6 +44656,12 @@ "value" : "Żądanie śledzenia trasy zwrócone: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запрос трассировки маршрута вернул: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38092,6 +44702,12 @@ "value" : "トレースルート送信済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута отправлена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38120,6 +44736,12 @@ "value" : "%@ にトレースルートを送信しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута отправлена на %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38148,6 +44770,12 @@ "value" : "%@ へのトレースルートは送信されませんでした。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута до %@ не была отправлена." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38176,6 +44804,12 @@ "value" : "トレースルートの送信レートが制限されました。トレースルートは30秒ごとに最大1回送信できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута была ограничена по частоте. Вы можете отправлять трассировку маршрута максимум один раз в тридцать секунд." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38198,6 +44832,12 @@ "value" : "Standorte verfolgen und teilen" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отслеживание и обмен местоположением" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38207,6 +44847,7 @@ } }, "Tracker" : { + "extractionState" : "stale", "localizations" : { "ja" : { "stringUnit" : { @@ -38214,6 +44855,12 @@ "value" : "トラッカー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трекер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38254,6 +44901,12 @@ "value" : "トラフィック" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трафик" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38282,6 +44935,12 @@ "value" : "送信データ(TXD)GPIOピン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO передачи данных (txd)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38310,6 +44969,12 @@ "value" : "送信有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Передача включена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38344,6 +45009,12 @@ "value" : "サポートされている加速度計でのダブルタップをユーザーボタン押下として扱います。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Считать двойное нажатие на поддерживаемых акселерометрах как нажатие пользовательской кнопки." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38378,6 +45049,12 @@ "value" : "トリガータイプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип триггера" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38406,6 +45083,12 @@ "value" : "トリプルクリック アドホックPing" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тройное нажатие для Ad Hoc Ping" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38440,6 +45123,12 @@ "value" : "再試行" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Попробовать снова" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38455,6 +45144,7 @@ } }, "Twelve Hours" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -38492,6 +45182,12 @@ "value" : "Dwanaście Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Двенадцать часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38519,6 +45215,7 @@ } }, "Twenty Four Hours" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -38556,6 +45253,12 @@ "value" : "Dwadzieścia Cztery Godziny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Двадцать четыре часа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38620,6 +45323,12 @@ "value" : "Dwie Godziny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Два часа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38647,6 +45356,7 @@ } }, "Two Minutes" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -38684,6 +45394,12 @@ "value" : "Dwie Minuty" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Две минуты" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38711,6 +45427,7 @@ } }, "Two Seconds" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -38748,6 +45465,12 @@ "value" : "Dwie Sekundy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Две секунды" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38788,6 +45511,12 @@ "value" : "UDPブロードキャスト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "UDP-трансляция" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38803,6 +45532,7 @@ } }, "Ukraine 433MHz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -38816,6 +45546,12 @@ "value" : "ウクライナ 433MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Украина 433 МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38831,6 +45567,7 @@ } }, "Ukraine 868MHz" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -38844,6 +45581,12 @@ "value" : "ウクライナ 868MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Украина 868МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38872,6 +45615,12 @@ "value" : "お気に入りを解除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизбранный" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38887,6 +45636,7 @@ } }, "Unhealthy" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -38900,6 +45650,12 @@ "value" : "不健康" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нездоровый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38915,6 +45671,7 @@ } }, "Unhealthy for Sensitive Groups" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -38928,6 +45685,12 @@ "value" : "敏感なグループには不健康" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Может быть небезопасно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38943,6 +45706,7 @@ } }, "United States" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -38956,6 +45720,12 @@ "value" : "アメリカ合衆国" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "США" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38984,6 +45754,12 @@ "value" : "デバイス画面に表示される単位" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Единицы измерения, отображаемые на экране устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38999,6 +45775,7 @@ } }, "unknown" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -39012,6 +45789,12 @@ "value" : "不明" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "неизвестный" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39058,6 +45841,12 @@ "value" : "Nieznany" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестный" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39122,6 +45911,12 @@ "value" : "Nieznany wiek" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестный возраст" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39162,6 +45957,12 @@ "value" : "メッセージ不可" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Невозможно отправить сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39178,6 +45979,12 @@ "value" : "監視なし" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неконтролируемый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39224,6 +46031,12 @@ "value" : "Nieustawiony" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не задан" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39264,6 +46077,12 @@ "value" : "サポート対象外" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неподдерживаемый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39279,6 +46098,7 @@ } }, "Up" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -39316,6 +46136,12 @@ "value" : "W Górę" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вверх" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39356,6 +46182,12 @@ "value" : "上下 1" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вверх вниз 1" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39390,6 +46222,12 @@ "value" : "更新間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал обновления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39442,6 +46280,12 @@ "value" : "Zaktualizuj firmware" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновите прошивку" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39482,6 +46326,12 @@ "value" : "ノード統計データを更新しました。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновлены данные статистики ноды." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39516,6 +46366,12 @@ "value" : "更新日時: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновлено: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39544,6 +46400,12 @@ "value" : "アップリンク有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включена восходящая связь" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39584,6 +46446,12 @@ "value" : "アップロードエラー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка загрузки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39624,6 +46492,12 @@ "value" : "カスタムマップオーバーレイを表示するためにGeoJSONファイルをアップロードしてください。ファイルはローカルに保存され、最大10MBまでです。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузите файлы GeoJSON для отображения пользовательских слоев карты. Файлы хранятся локально и могут быть размером до 10 МБ." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39659,6 +46533,12 @@ "value" : "マップデータをアップロード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузить данные карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39700,6 +46580,12 @@ "value" : "オーバーレイを有効にするにはマップデータをアップロード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузите данные карты для включения слоев" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39728,6 +46614,12 @@ "value" : "Kartendaten hochladen" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузить слои карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39756,6 +46648,12 @@ "value" : "アップロード成功" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Успешная загрузка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39784,6 +46682,12 @@ "value" : "Hochgeladene Kartendaten" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загруженные слои карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39806,6 +46710,12 @@ "value" : "稼働時間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время работы" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39834,6 +46744,12 @@ "value" : "Telemetriedaten erfassen" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные об использовании и сбоях" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39856,6 +46772,12 @@ "value" : "オン/オフ出力ではなく、PWM出力(RAKブザーなど)をチューンに使用してください。これにより、出力、出力時間、アクティブ設定は無視され、代わりにデバイス設定のブザーGPIOオプションが使用されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используйте выход PWM (например, RAK Buzzer) для мелодий вместо вывода вкл/выкл. Это будет игнорировать вывод, длительность вывода и активные настройки и использовать опцию GPIO зуммера в конфигурации устройства." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39890,6 +46812,12 @@ "value" : "I2Sをブザーとして使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать I2S как зуммер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39918,6 +46846,12 @@ "value" : "自分の位置を使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать мое местоположение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39946,6 +46880,12 @@ "value" : "プリセットを使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать пресет" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39980,6 +46920,12 @@ "value" : "PWMブザーを使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать PWM зуммер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40008,6 +46954,12 @@ "value" : "Verwende das GPS deines Handys anstelle des GPS deines Funkgeräts." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используйте GPS вашего телефона для отправки местоположения на вашу ноду вместо использования аппаратного GPS на вашей ноде." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40030,6 +46982,12 @@ "value" : "リモート デバイスとの共有キーを作成するために使用されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используется для создания общего ключа с удаленным устройством." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40058,6 +47016,12 @@ "value" : "監視されていないまたはインフラストラクチャノードを識別するために使用され、応答しないノードにはメッセージング機能が利用できないようにします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используется для идентификации неотслеживаемых или инфраструктурных нод, чтобы обмен сообщениями был недоступен для нод, которые никогда не ответят." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40104,6 +47068,12 @@ "value" : "Użytkownik" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользователь" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -40150,6 +47120,12 @@ "value" : "ユーザー設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки пользователя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40190,6 +47166,12 @@ "value" : "ユーザー詳細" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сведения о пользователе" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40224,6 +47206,12 @@ "value" : "ユーザーID" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID пользователя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40245,13 +47233,34 @@ } }, "User Info Exchange Failed" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Произошел сбой обмена информацией о пользователе" + } + } + } }, "User Info Sent" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инфа о пользователе отпр-на" + } + } + } }, "User Privacy" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша конфиденциальность" + } + } + } }, "User Uploaded" : { "comment" : "Data source label for user uploaded files", @@ -40274,6 +47283,12 @@ "value" : "ユーザーがアップロード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загруженный пользователем" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40332,6 +47347,12 @@ "value" : "Nazwa użytkownika" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя пользователя" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -40372,6 +47393,12 @@ "value" : "プルアップ抵抗を使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использует подтягивающий резистор" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40400,6 +47427,12 @@ "value" : "スマートフォンのネットワーク接続を利用してMQTTに接続します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использует сетевое подключение вашего телефона для подключения к MQTT." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40440,6 +47473,12 @@ "value" : "車両方位" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Направление транспорта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40474,6 +47513,12 @@ "value" : "車両速度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость транспорта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40490,6 +47535,12 @@ }, "Verify who you are messaging with by comparing public keys in person or over the phone. The most recent public key for this node does not match the previously recorded key. You can delete the node and let it exchange keys again if the key change was due to a factory reset or other intentional action but this also may indicate a more serious security problem." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверьте, с кем вы общаетесь, сравнив открытые ключи при личной встрече или по телефону. Самый последний открытый ключ для этой ноды не совпадает с ранее записанным ключом. Вы можете удалить ноду и позволить ей снова обменяться ключами, если смена ключа произошла из-за сброса настроек или другого преднамеренного действия, но это также может указывать на более серьезную проблему безопасности." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40518,6 +47569,12 @@ "value" : "バージョン%1$@には、ネットワークの大幅な最適化と、デバイスおよびクライアントアプリへの広範な変更が含まれています。サポートされるノードはバージョン%2$@以降のみです。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия %1$@ содержит существенные оптимизации сети и значительные изменения в устройствах и клиентских приложениях. Поддерживаются только ноды версии %2$@ и выше." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40552,6 +47609,12 @@ "value" : "バージョン: %@ (%@)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия: %1$@ (%2$@)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40567,6 +47630,7 @@ } }, "Version: %1$@ (%2$@)" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -40586,6 +47650,12 @@ "value" : "バージョン: %1$@ (%2$@)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия: %1$@ (%2$@)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40607,6 +47677,7 @@ } }, "Very Unhealthy" : { + "extractionState" : "stale", "localizations" : { "it" : { "stringUnit" : { @@ -40620,6 +47691,12 @@ "value" : "非常に不健康" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очень вредно для здоровья" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40654,6 +47731,12 @@ "value" : "LoRa経由" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Через Lora" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40688,6 +47771,12 @@ "value" : "MQTT経由" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Через MQTT" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40746,6 +47835,12 @@ "value" : "Napięcie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Напряжение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -40786,6 +47881,12 @@ "value" : "Volts %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вольт: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40838,6 +47939,12 @@ "value" : "Czekam. . ." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ожидайте" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -40878,6 +47985,12 @@ "value" : "承認待ち. . ." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ожидайте подтверждения. . ." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40906,6 +48019,12 @@ "value" : "タップまたはモーションで画面を起動" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экран пробуждения при нажатии или движении" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40921,6 +48040,7 @@ } }, "Walking" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -40940,6 +48060,12 @@ "value" : "歩行" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прогулка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40961,6 +48087,7 @@ } }, "Wave" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -40980,6 +48107,12 @@ "value" : "波" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Волна" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -41008,6 +48141,12 @@ "value" : "ウェイポイントの送信に失敗" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось отправить путевую точку" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41036,6 +48175,12 @@ "value" : "ウェイポイントオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры путевых точек" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41051,6 +48196,7 @@ } }, "Waypoint Packet received from node: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -41088,6 +48234,12 @@ "value" : "Odebrano pakiet punktu orientacyjnego od węzła: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пакет путевой точки, полученный от ноды: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -41115,7 +48267,14 @@ } }, "Waypoints" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Путевые точки" + } + } + } }, "Weather Conditions" : { "localizations" : { @@ -41137,6 +48296,12 @@ "value" : "気象条件" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Погодные условия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41171,6 +48336,12 @@ "value" : "ウェブフラッシャー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Веб-флешер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41199,6 +48370,12 @@ "value" : "ウェブサイト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сайт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41233,6 +48410,12 @@ "value" : "重量" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вес" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41255,6 +48438,12 @@ "value" : "Willkommen bei" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добро пожаловать" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41283,6 +48472,12 @@ "value" : "鍵マークの意味は?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Что означает этот замок?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41323,6 +48518,12 @@ "value" : "Meshtasticとは?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Что такое Meshtastic?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41357,6 +48558,12 @@ "value" : "ライセンス操作者モードの機能:\n* ノード名をコールサインに設定\n* 10分ごとにノード情報をブロードキャスト\n* 周波数、デューティサイクル、送信電力をオーバーライド\n* 暗号化を無効化" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Что делает режим лицензированного оператора:\n* Устанавливает название ноды в соответствии с вашим позывным \n* Передает информацию о ноде каждые 10 минут \n* Изменяет частоту, режим работы и мощность линии связи \n* Отключает шифрование" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41397,6 +48604,12 @@ "value" : "PAXカウンターモジュールを有効にすると、WiFiとBluetoothを使用して通過する人数をカウントします。PAXカウンターを動作させるには、WiFiとBluetoothの両方を無効にする必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При включении модуль прохожих подсчитывает количество людей, проходящих мимо, используя Wi-Fi и Bluetooth. Для работы счетчика количества человек как Wi-Fi, так и Bluetooth должны быть отключены." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -41437,6 +48650,12 @@ "value" : "GPIOモードで使用する際、この期間出力をオンに保ちます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При использовании в режиме GPIO держите вывод включенным в течение этого времени." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41471,6 +48690,12 @@ "value" : "GPIOピンでINPUT_PULLUPモードを使用するかどうか。ボードがピンでプルアップ抵抗を使用している場合のみ適用されます" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Независимо от того, используется ли режим INPUT_PULLUP для вывода GPIO. Применимо только в том случае, если на выводе платы используются подтягивающие резисторы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41499,6 +48724,12 @@ "value" : "WiFi" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "WiFi" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -41539,6 +48770,12 @@ "value" : "WiFiオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры WiFi" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41579,6 +48816,12 @@ "value" : "Will sleep everything as much as possible, for the tracker and sensor role this will also include the lora radio. Don't use this setting if you want to use your device with the phone apps or are using a device without a user button." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Все устройства будут работать в режиме ожидания, насколько это возможно, в качестве трекера и датчика также будет использоваться радиоприемник lora. Не используйте эту настройку, если вы хотите использовать свое устройство с приложениями для телефона или устройство без пользовательской кнопки." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -41619,6 +48862,12 @@ "value" : "風" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ветер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41634,6 +48883,7 @@ } }, "Wind Direction" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -41653,6 +48903,12 @@ "value" : "風向" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Направление ветра" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41668,6 +48924,7 @@ } }, "Wind Speed" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -41687,6 +48944,12 @@ "value" : "風速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость ветра" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41702,6 +48965,7 @@ } }, "Within %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -41721,6 +48985,12 @@ "value" : "%@ 以内" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внутри %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -41755,6 +49025,12 @@ "value" : "x" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "x" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41789,6 +49065,12 @@ "value" : "X: %1$@, Y: %2$d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$d" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41830,6 +49112,12 @@ "value" : "X: %1$@, Y: %2$f" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$f" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41871,6 +49159,12 @@ "value" : "X: %1$@, Y: %2$lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41906,6 +49200,12 @@ "value" : "y" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "y" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41928,7 +49228,15 @@ }, "Yes, I control this node" : { "comment" : "A button label that appears in a confirmation sheet when favoriting a node as a CLIENT_BASE.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Да, я управляю этой нодой" + } + } + } }, "Yesterday" : { "localizations" : { @@ -41950,6 +49258,12 @@ "value" : "昨日" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вчера" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41978,6 +49292,12 @@ "value" : "Nordic DFUアプリを使用してBluetoothでMeshtasticデバイスを更新することもできます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы также можете обновить свое устройство Meshtastic по Bluetooth с помощью приложения Nordic DFU." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42036,6 +49356,12 @@ "value" : "You can send and receive channel (group chats) and direct messages. From any message you can long press to see available actions like copy, reply, tapback and delete as well as delivery details." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы можете отправлять и получать сообщения по каналам (групповые чаты) и напрямую. Вы можете долгим тапом по любому сообщению просматривать доступные действия, такие как копирование, ответ, повторное нажатие и удаление, а также информацию о доставке." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -42076,6 +49402,12 @@ "value" : "現在の位置が固定位置として設定され、位置間隔でメッシュネットワーク上にブロードキャストされます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваше текущее местоположение будет установлено как фиксированная позиция и транслироваться по сетке на интервале определения местоположения." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42116,6 +49448,12 @@ "value" : "ファームウェアは最新です" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша прошивка обновлена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42150,6 +49488,12 @@ "value" : "MQTTサーバーはTLSをサポートする必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш MQTT-сервер должен поддерживать протокол TLS." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42172,6 +49516,12 @@ "value" : "ノードは設定されたMQTTサーバーに定期的に暗号化されていないマップレポートパケットを送信します。これにはID、短縮名と長い名前、おおよその位置、ハードウェアモデル、役割、ファームウェアバージョン、LoRa地域、モデムプリセット、プライマリチャンネル名が含まれます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша нода будет периодически отправлять незашифрованный пакет местоположения на настроенный сервер MQTT, включающий идентификатор, короткое и длинное имя, приблизительное местоположение, модель оборудования, роль, версию встроенного ПО, регион LoRa, предустановку модема и название основного канала." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42200,6 +49550,12 @@ "value" : "ノードの動作周波数は、地域、モデムプリセット、およびこのフィールドに基づいて計算されます。0の場合、スロットはプライマリチャンネル名に基づいて自動的に計算されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рабочая частота вашей ноды рассчитывается на основе региона, настроек модема и этого поля. При значении 0 интервал автоматически рассчитывается на основе названия основного канала." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42228,6 +49584,12 @@ "value" : "位置情報が位置の返信要求と共に送信されました。位置が返信されると通知を受け取ります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша позиция была отправлена с запросом на ответ с указанием их позиции. Вы получите уведомление, когда позиция будет возвращена." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42249,7 +49611,14 @@ } }, "Your public key is generated from your private key and sent to other nodes on the mesh so they can compute a shared secret key with you." : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш открытый ключ генерируется на основе вашего закрытого ключа и отправляется другим нодам сети, чтобы они могли вычислить общий с вами секретный ключ." + } + } + } }, "Your region has a %lld%% duty cycle. MQTT is not advised when you are duty cycle restricted, the extra traffic will quickly overwhelm your LoRa mesh." : { "localizations" : { @@ -42265,6 +49634,12 @@ "value" : "お住まいの地域はデューティサイクルが%lld%%です。デューティサイクル制限がある場合、MQTTの使用は推奨されません。追加のトラフィックによってLoRaメッシュがすぐに圧迫されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В вашем регионе действует режим работы %lld%%. Не рекомендуется использовать MQTT, если у вас ограничен режим работы, так как дополнительный трафик быстро перегрузит вашу сеть LoRa." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42299,6 +49674,12 @@ "value" : "お住まいの地域は時間あたり%lld%%のデューティサイクル制限があります。無線機が時間制限に達すると、パケットの送信を停止します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В вашем регионе установлен часовой режим работы %lld%%, ваша радиостанция прекратит отправку пакетов, когда достигнет часового лимита." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42327,6 +49708,12 @@ "value" : "ルートファイルには緯度と経度の列とヘッダーの両方が必要です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш файл маршрута должен содержать столбцы широты и долготы, а также заголовки." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42342,8 +49729,15 @@ } }, "Your user info has been sent with a request for a response with their user info." : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша пользовательская информация была отправлена с запросом на получение ответа с их пользовательской информацией." + } + } + } } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 817570e2..1544d0eb 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -82,18 +82,29 @@ 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 */; }; + 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 */; }; 3D3417C82E29D38A006A988B /* GeoJSONOverlayConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */; }; 3D3417D22E2DC260006A988B /* MapDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D12E2DC260006A988B /* MapDataManager.swift */; }; 3D3417D42E2DC293006A988B /* MapDataFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417D32E2DC293006A988B /* MapDataFiles.swift */; }; + 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F203877F307073096C89179 /* FountainCodec.swift */; }; 6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; }; 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */; }; 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */; }; 6DEDA55C2A9592F900321D2E /* MessageEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */; }; + 7CCBCA0251DAB58FD9D63D06 /* GenericCoTHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F1B62B5CB54395476C3A924 /* GenericCoTHandler.swift */; }; + 8398407DBA32EE7CFC16A385 /* TAKDataPackageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */; }; + 8A8F2D8A3769D24BAB88B4A1 /* CoTMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */; }; 8D3F8A3F2D44BB02009EAAA4 /* PowerMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */; }; 8D3F8A412D44C2A6009EAAA4 /* PowerMetricsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */; }; + 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 */; }; + A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.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 */; }; B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B399E8A32B6F486400E4488E /* RetryButton.swift */; }; B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; }; BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC10380E2DD4333C00B00BFA /* AddContactIntent.swift */; }; @@ -298,6 +309,8 @@ DDF924CA26FBB953009FE055 /* ConnectedDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF924C926FBB953009FE055 /* ConnectedDevice.swift */; }; DDFEB3BB29900C1200EE7472 /* CurrentConditionsCompact.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFEB3BA29900C1200EE7472 /* CurrentConditionsCompact.swift */; }; DDFFA7472B3A7F3C004730DB /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFFA7462B3A7F3C004730DB /* Bundle.swift */; }; + E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */; }; + FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -332,6 +345,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 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 = ""; }; 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = ""; }; 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = ""; }; 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = ""; }; @@ -395,17 +411,27 @@ 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 = ""; }; + 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 = ""; }; 3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = ""; }; 3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = ""; }; 3D3417D32E2DC293006A988B /* MapDataFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataFiles.swift; sourceTree = ""; }; + 3F203877F307073096C89179 /* FountainCodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FountainCodec.swift; sourceTree = ""; }; + 4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTMessage.swift; sourceTree = ""; }; + 518D504DED9874EBF9D76578 /* Certificates */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = Certificates; sourceTree = ""; }; 6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = ""; }; 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = ""; }; 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = ""; }; 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageEntityExtension.swift; sourceTree = ""; }; + 748E4806582595DE80D455CD /* CoTXMLParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTXMLParser.swift; sourceTree = ""; }; + 7F1B62B5CB54395476C3A924 /* GenericCoTHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GenericCoTHandler.swift; sourceTree = ""; }; + 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+TAK.swift"; sourceTree = ""; }; + 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKMeshtasticBridge.swift; sourceTree = ""; }; 8D3F8A3D2D44B137009EAAA4 /* MeshtasticDataModelV 49.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 49.xcdatamodel"; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -792,6 +818,7 @@ 23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */, 23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */, 23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */, + 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */, ); path = "Accessory Manager"; sourceTree = ""; @@ -897,6 +924,23 @@ path = AppIntents; sourceTree = ""; }; + C37572859BC745C4284A9B42 /* TAK */ = { + isa = PBXGroup; + children = ( + 4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */, + 748E4806582595DE80D455CD /* CoTXMLParser.swift */, + 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */, + 01028778B8BFD81F7A039593 /* TAKConnection.swift */, + 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */, + 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */, + 9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */, + 3F203877F307073096C89179 /* FountainCodec.swift */, + 3D0A8ABAEF1E587683970927 /* EXICodec.swift */, + 7F1B62B5CB54395476C3A924 /* GenericCoTHandler.swift */, + ); + path = TAK; + sourceTree = ""; + }; D9C9839E2B79D0C600BDBE6A /* TextMessageField */ = { isa = PBXGroup; children = ( @@ -983,6 +1027,7 @@ DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */, DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */, ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */, + 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */, ); path = Settings; sourceTree = ""; @@ -1236,6 +1281,7 @@ DDB75A192A05EB67006ED576 /* alpha.png */, DDC2E15B26CE248F0042C5E4 /* Assets.xcassets */, DD0E21002B8A6BC500F2D100 /* DeviceHardware.json */, + 518D504DED9874EBF9D76578 /* Certificates */, ); path = Resources; sourceTree = ""; @@ -1300,6 +1346,7 @@ DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */, 6D825E612C34786C008DBEE4 /* CommonRegex.swift */, 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */, + C37572859BC745C4284A9B42 /* TAK */, ); path = Helpers; sourceTree = ""; @@ -1570,6 +1617,7 @@ DDC2E15C26CE248F0042C5E4 /* Assets.xcassets in Resources */, DD0E21012B8A6F1300F2D100 /* DeviceHardware.json in Resources */, DDDBC87B2BC62E4E001E8DF7 /* Settings.bundle in Resources */, + 8E587743574CE17703E892C6 /* Certificates in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1877,6 +1925,18 @@ BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */, D93068D72B8146690066FBC8 /* MessageText.swift in Sources */, DDC2E15826CE248E0042C5E4 /* MeshtasticApp.swift in Sources */, + 8A8F2D8A3769D24BAB88B4A1 /* CoTMessage.swift in Sources */, + 8EED425B7820DA4FEB40C375 /* CoTXMLParser.swift in Sources */, + B16C760DB291CFAB5335EADB /* TAKCertificateManager.swift in Sources */, + 2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */, + 300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */, + E3ED80145D0E873011982556 /* TAKServerManager.swift in Sources */, + FE508F9AF5AD5DA20AA64DBF /* AccessoryManager+TAK.swift in Sources */, + A5339E2F74E83F8FC41EEE33 /* TAKServerConfig.swift in Sources */, + 8398407DBA32EE7CFC16A385 /* TAKDataPackageGenerator.swift in Sources */, + 655AF7816E76D5F310DF87A6 /* FountainCodec.swift in Sources */, + 9604373EEB96801AA89DF48C /* EXICodec.swift in Sources */, + 7CCBCA0251DAB58FD9D63D06 /* GenericCoTHandler.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2110,7 +2170,11 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.7; + MARKETING_VERSION = 2.7.8; + OTHER_LDFLAGS = ( + "-weak_framework", + SwiftUI, + ); PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -2145,7 +2209,11 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.7; + MARKETING_VERSION = 2.7.8; + OTHER_LDFLAGS = ( + "-weak_framework", + SwiftUI, + ); PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -2177,7 +2245,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.7; + MARKETING_VERSION = 2.7.8; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2210,7 +2278,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.7; + MARKETING_VERSION = 2.7.8; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3b339cec..cb5d36cf 100644 --- a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2569905853aec088d5bac6b540eac77f78963f88b406e8dd95a88c40623cc8b4", + "originHash" : "7d747a138ea225de00b815c2d9ed46c704c081d98cc8d1018c8d11cb91f39bc4", "pins" : [ { "identity" : "cocoamqtt", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DataDog/dd-sdk-ios.git", "state" : { - "revision" : "d0a42d8067665cb6ee86af51251ccc071f62bd54", - "version" : "2.29.0" + "revision" : "2cddcb47c021365c5a6ebc377cb379aa979c450e", + "version" : "3.4.0" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "102a647b573f60f73afdce5613a51d71349fe507", - "version" : "1.30.0" + "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", + "version" : "1.33.3" } } ], diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift index 456001d8..26443685 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift @@ -30,6 +30,9 @@ extension AccessoryManager { packetsSent = 0 packetsReceived = 0 expectedNodeDBSize = nil + + self.allowDisconnect = true + self.userRequestedConnectionCancellation = false // Prepare to connect self.connectionStepper = SequentialSteps(maxRetries: maxRetries, retryDelay: retryDelay) { @@ -40,7 +43,6 @@ extension AccessoryManager { if retryAttempt > 0 { try await self.closeConnection() // clean-up before retries. self.updateState(.retrying(attempt: retryAttempt + 1)) - self.allowDisconnect = true } else { self.updateState(.connecting) } @@ -61,7 +63,7 @@ extension AccessoryManager { self.updateState(.communicating) self.connectionEventTask = Task { for await event in eventStream { - self.didReceive(event) + await self.didReceive(event) } Logger.transport.info("[Accessory] Event stream closed") } diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift index 1a0e9ebd..3c2b7293 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Discovery.swift @@ -52,14 +52,18 @@ extension AccessoryManager { existing.rssi = newDevice.rssi self.devices[index] = existing } else { - // This is a new device, add it to our list - self.devices.append(newDevice) + // This is a new device, add it to our list if we are in the foreground + if !(self.isInBackground) { + self.devices.append(newDevice) + } else { + Logger.transport.debug("🔎 [Discovery] Found a new device but not in the foreground, not adding to our list: peripheral \(newDevice.name)") + } } - if self.shouldAutomaticallyConnectToPreferredPeripheral, + if self.shouldAutomaticallyConnectToPreferredPeripheralAfterError, !userRequestedConnectionCancellation, UserDefaults.autoconnectOnDiscovery, UserDefaults.preferredPeripheralId == newDevice.id.uuidString { Logger.transport.debug("🔎 [Discovery] Found preferred peripheral \(newDevice.name)") - self.connectToPreferredDevice() + self.connectToPreferredDevice(device: newDevice) } // Update the list of discovered devices on the main thread for presentation diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift index 5bcead9b..46d4f767 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift @@ -65,7 +65,7 @@ extension AccessoryManager { Logger.services.error("⚠️ Client Notification: \(clientNotification.message, privacy: .public)") } - func handleMyInfo(_ myNodeInfo: MyNodeInfo) { + func handleMyInfo(_ myNodeInfo: MyNodeInfo) async { // TODO: this works for connections like BLE that have a uniqueId, but what about ones like serial? guard let connectedDeviceId = activeConnection?.device.id.uuidString else { Logger.services.error("⚠️ Failed to decode MyInfo, no connected device ID") @@ -75,7 +75,8 @@ extension AccessoryManager { updateDevice(key: \.num, value: Int64(myNodeInfo.myNodeNum)) - if let myInfo = myInfoPacket(myInfo: myNodeInfo, peripheralId: connectedDeviceId, context: context) { + if let myInfoId = await MeshPackets.shared.myInfoPacket(myInfo: myNodeInfo, peripheralId: connectedDeviceId), + let myInfo = try? context.existingObject(with: myInfoId) as? MyInfoEntity { if let bleName = myInfo.bleName { updateDevice(key: \.name, value: bleName) updateDevice(key: \.longName, value: bleName) @@ -93,9 +94,11 @@ extension AccessoryManager { } tryClearExistingChannels() + // Initialize TAK bridge for TAK integration + initializeTAKBridge() } - func handleNodeInfo(_ nodeInfo: NodeInfo) { + func handleNodeInfo(_ nodeInfo: NodeInfo) async { if let continuation = self.firstDatabaseNodeInfoContinuation { continuation.resume() self.firstDatabaseNodeInfoContinuation = nil @@ -107,10 +110,13 @@ extension AccessoryManager { } // Check if we're in database retrieval mode to defer saves for performance - let isRetrievingDatabase = if case .retrievingDatabase = self.state { true } else { false } + // Commented out: No need to defer save when nodeInfoPacket is now happening off the main thread + // let isRetrievingDatabase = if case .retrievingDatabase = self.state { true } else { false } // TODO: nodeInfoPacket's channel: parameter is not used - if let nodeInfo = nodeInfoPacket(nodeInfo: nodeInfo, channel: 0, context: context, deferSave: isRetrievingDatabase) { + // deferSave hard coded: No need to defer save when nodeInfoPacket is now happening off the main thread + if let nodeInfoId = await MeshPackets.shared.nodeInfoPacket(nodeInfo: nodeInfo, channel: 0, deferSave: false), + let nodeInfo = try? context.existingObject(with: nodeInfoId) as? NodeInfoEntity { if let activeDevice = activeConnection?.device, activeDevice.num == nodeInfo.num { if let user = nodeInfo.user { updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName ?? "?") @@ -136,24 +142,24 @@ extension AccessoryManager { } - func handleChannel(_ channel: Channel) { + func handleChannel(_ channel: Channel) async { guard let deviceNum = activeConnection?.device.num else { Logger.data.error("Attempt to process channel information when no connected device.") return } - channelPacket(channel: channel, fromNum: Int64(truncatingIfNeeded: deviceNum), context: context) + await MeshPackets.shared.channelPacket(channel: channel, fromNum: Int64(truncatingIfNeeded: deviceNum)) } - func handleConfig(_ config: Config) { + func handleConfig(_ config: Config) async { guard let device = activeConnection?.device, let deviceNum = device.num, let longName = device.longName else { Logger.data.error("Attempt to process channel information when no connected device.") return } // Local config parses out the variants. Should we do that here maybe? - localConfig(config: config, context: context, nodeNum: Int64(truncatingIfNeeded: deviceNum), nodeLongName: longName) + await MeshPackets.shared.localConfig(config: config, nodeNum: Int64(truncatingIfNeeded: deviceNum), nodeLongName: longName) // Handle Timezone if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) { @@ -167,12 +173,12 @@ extension AccessoryManager { } } - func handleModuleConfig(_ moduleConfigPacket: ModuleConfig) { + func handleModuleConfig(_ moduleConfigPacket: ModuleConfig) async { guard let device = activeConnection?.device, let deviceNum = device.num, let longName = device.longName else { Logger.services.error("Attempt to process channel information when no connected device.") return } - moduleConfig(config: moduleConfigPacket, context: context, nodeNum: Int64(truncatingIfNeeded: deviceNum), nodeLongName: longName) + await MeshPackets.shared.moduleConfig(config: moduleConfigPacket, nodeNum: Int64(truncatingIfNeeded: deviceNum), nodeLongName: longName) // Get Canned Message Message List if the Module is Canned Messages if moduleConfigPacket.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(moduleConfigPacket.cannedMessage) { try? getCannedMessageModuleMessages(destNum: deviceNum, wantResponse: true) @@ -183,7 +189,7 @@ extension AccessoryManager { } } - func handleDeviceMetadata(_ metadata: DeviceMetadata) { + func handleDeviceMetadata(_ metadata: DeviceMetadata) async { // Note: moved firmware version check to be inline with connection process guard let device = activeConnection?.device, let deviceNum = device.num else { Logger.services.error("Attempt to process device metadata information when no connected device.") @@ -194,7 +200,7 @@ extension AccessoryManager { updateDevice(key: \.firmwareVersion, value: metadata.firmwareVersion) - deviceMetadataPacket(metadata: metadata, fromNum: deviceNum, context: context) + await MeshPackets.shared.deviceMetadataPacket(metadata: metadata, fromNum: deviceNum) } internal func tryClearExistingChannels() { @@ -225,17 +231,16 @@ extension AccessoryManager { } - func handleTextMessageAppPacket(_ packet: MeshPacket) { + func handleTextMessageAppPacket(_ packet: MeshPacket) async { guard let device = activeConnection?.device, let deviceNum = device.num else { Logger.services.error("Attempt to handle text message when no connected device.") return } - textMessageAppPacket( + await MeshPackets.shared.textMessageAppPacket( packet: packet, wantRangeTestPackets: wantRangeTestPackets, connectedNode: deviceNum, - context: context, appState: appState ) @@ -320,25 +325,27 @@ extension AccessoryManager { case .UNRECOGNIZED: Logger.mesh.info("\("📮 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") case .routerTextDirect: - Logger.mesh.info("\("💬 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") - textMessageAppPacket( - packet: packet, - wantRangeTestPackets: false, - connectedNode: connectedNodeNum, - storeForward: true, - context: context, - appState: appState - ) + Task { + Logger.mesh.info("\("💬 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + await MeshPackets.shared.textMessageAppPacket( + packet: packet, + wantRangeTestPackets: false, + connectedNode: connectedNodeNum, + storeForward: true, + appState: appState + ) + } case .routerTextBroadcast: - Logger.mesh.info("\("✉️ Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") - textMessageAppPacket( - packet: packet, - wantRangeTestPackets: false, - connectedNode: connectedNodeNum, - storeForward: true, - context: context, - appState: appState - ) + Task { + Logger.mesh.info("\("✉️ Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")") + await MeshPackets.shared.textMessageAppPacket( + packet: packet, + wantRangeTestPackets: false, + connectedNode: connectedNodeNum, + storeForward: true, + appState: appState + ) + } } } } diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+TAK.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+TAK.swift new file mode 100644 index 00000000..d6c96783 --- /dev/null +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+TAK.swift @@ -0,0 +1,209 @@ +// +// AccessoryManager+TAK.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import MeshtasticProtobufs +import OSLog + +extension AccessoryManager { + + // MARK: - TAK Server Initialization + + /// Initialize the TAK bridge when connected to a Meshtastic device + func initializeTAKBridge() { + let takServer = TAKServerManager.shared + + // Create the bridge + let bridge = TAKMeshtasticBridge( + accessoryManager: self, + takServerManager: takServer + ) + bridge.context = self.context + + // Assign bridge to server + takServer.bridge = bridge + + Logger.tak.info("TAK bridge initialized") + + // Start server if enabled + if takServer.enabled && !takServer.isRunning { + Task { + do { + try await takServer.start() + Logger.tak.info("TAK Server auto-started on connection") + } catch { + Logger.tak.error("Failed to auto-start TAK Server: \(error.localizedDescription)") + } + } + } + } + + /// Clean up TAK bridge when disconnecting + func cleanupTAKBridge() { + // Note: We don't stop the server here - it can continue running + // even without a Meshtastic connection (for TAK connectivity) + Logger.tak.info("TAK bridge cleanup") + } + + // MARK: - Send TAK Packet to Mesh + + /// Send a TAK packet to the Meshtastic mesh network + /// - Parameters: + /// - takPacket: The TAKPacket protobuf to send + /// - channel: Channel to send on (0 = default/primary) + func sendTAKPacket(_ takPacket: TAKPacket, channel: UInt32 = 0) async throws { + Logger.tak.debug("=== Sending TAKPacket to Mesh ===") + + guard let activeConnection else { + Logger.tak.error("Not connected to Meshtastic device") + throw AccessoryError.connectionFailed("Not connected to Meshtastic device") + } + + guard let deviceNum = activeConnection.device.num else { + Logger.tak.error("No device number available") + throw AccessoryError.connectionFailed("No device number available") + } + + Logger.tak.debug("Device num: \(deviceNum)") + + // Log TAKPacket details before serialization + Logger.tak.debug("TAKPacket to send:") + Logger.tak.debug(" hasContact: \(takPacket.hasContact)") + if takPacket.hasContact { + Logger.tak.debug(" callsign: \(takPacket.contact.callsign)") + Logger.tak.debug(" deviceCallsign: \(takPacket.contact.deviceCallsign)") + } + Logger.tak.debug(" hasGroup: \(takPacket.hasGroup)") + if takPacket.hasGroup { + Logger.tak.debug(" team: \(takPacket.group.team.rawValue)") + Logger.tak.debug(" role: \(takPacket.group.role.rawValue)") + } + Logger.tak.debug(" hasStatus: \(takPacket.hasStatus)") + if takPacket.hasStatus { + Logger.tak.debug(" battery: \(takPacket.status.battery)") + } + Logger.tak.debug(" payloadVariant: \(String(describing: takPacket.payloadVariant))") + + // Serialize the TAK packet + let serialized: Data + do { + serialized = try takPacket.serializedData() + Logger.tak.debug("Serialized TAKPacket: \(serialized.count) bytes") + Logger.tak.debug("Serialized hex: \(serialized.map { String(format: "%02x", $0) }.joined(separator: " "))") + } catch { + Logger.tak.error("Failed to serialize TAKPacket: \(error.localizedDescription)") + throw AccessoryError.ioFailed("Failed to serialize TAKPacket") + } + + // Build the mesh packet + var dataMessage = DataMessage() + dataMessage.portnum = .atakPlugin // Port 72 + dataMessage.payload = serialized + + var meshPacket = MeshPacket() + meshPacket.to = 0xFFFFFFFF // Broadcast + meshPacket.from = UInt32(deviceNum) + meshPacket.channel = channel + meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..= 2 && payload[0] == 0x08 && payload[1] == 0x01 { + Logger.tak.debug("Ignoring compressed TAKPacket (duplicate of uncompressed)") + return + } + + // Parse uncompressed TAKPacket protobuf + let takPacket: TAKPacket + do { + takPacket = try TAKPacket(serializedBytes: payload) + } catch { + Logger.tak.warning("Failed to parse TAKPacket from mesh packet: \(error.localizedDescription)") + Logger.tak.debug("Parse error details: \(error)") + Logger.tak.debug("Raw payload hex: \(payload.map { String(format: "%02x", $0) }.joined(separator: " "))") + return + } + + Logger.tak.info("Received TAKPacket from mesh node \(packet.from)") + Logger.tak.debug(" hasContact: \(takPacket.hasContact), hasGroup: \(takPacket.hasGroup), hasStatus: \(takPacket.hasStatus)") + Logger.tak.debug(" payloadVariant: \(String(describing: takPacket.payloadVariant))") + + // Forward to TAK clients via bridge + Task { + await TAKServerManager.shared.bridge?.broadcastToTAKClients(takPacket, from: packet.from) + } + } + + // MARK: - Handle ATAK Forwarder Packet (Port 257) + + /// Handle incoming ATAK_FORWARDER packet for generic CoT events + /// These are EXI-compressed CoT XML, possibly fountain-coded for large messages + func handleATAKForwarderPacket(_ packet: MeshPacket) { + guard case let .decoded(data) = packet.payloadVariant else { + Logger.tak.warning("Received ATAK_FORWARDER packet without decoded payload") + return + } + + Logger.tak.debug("Received ATAK_FORWARDER packet: \(data.payload.count) bytes from node \(packet.from)") + + // Process through GenericCoTHandler on main actor + let packetCopy = packet + let accessoryManagerRef = self + Task { @MainActor in + let handler = GenericCoTHandler.shared + handler.accessoryManager = accessoryManagerRef + + if let cotMessage = handler.handleIncomingForwarderPacket(packetCopy) { + // Forward to TAK clients via the server manager + await TAKServerManager.shared.broadcast(cotMessage) + Logger.tak.info("Forwarded generic CoT to TAK clients: \(cotMessage.type)") + } + } + } +} diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index 868a6e6f..f34b5ae4 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -166,7 +166,7 @@ extension AccessoryManager { // Update local database with the new node info // FUTURE: after https://github.com/meshtastic/firmware/pull/8495 is merged, `favorite: true` becomes `favorite: (connectedDeviceRole != DeviceRoles.clientBase)` - upsertNodeInfoPacket(packet: nodeMeshPacket, favorite: true, context: context) + await MeshPackets.shared.upsertNodeInfoPacket(packet: nodeMeshPacket, favorite: true) } } catch { Logger.data.error("Failed to decode contact data: \(error.localizedDescription, privacy: .public)") @@ -441,8 +441,6 @@ extension AccessoryManager { Logger.services.error("Error while sending saveChannelSet request. No active device.") throw AccessoryError.ioFailed("No active device") } - var i: Int32 = 0 - var myInfo: MyInfoEntity // Before we get started delete the existing channels from the myNodeInfo if !addChannels { tryClearExistingChannels() @@ -451,64 +449,74 @@ extension AccessoryManager { let decodedString = base64UrlString.base64urlToBase64() if let decodedData = Data(base64Encoded: decodedString) { let channelSet: ChannelSet = try ChannelSet(serializedBytes: decodedData) + + var myInfo: MyInfoEntity! + var i: Int32 = 0 + + if addChannels { + let fetchMyInfoRequest = MyInfoEntity.fetchRequest() + fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum)) + + let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) + if fetchedMyInfo.count != 1 { + throw AccessoryError.appError("MyInfo not found") + } + + // We are trying to add a channel so lets get the last index + myInfo = fetchedMyInfo[0] + i = Int32(myInfo.channels?.count ?? -1) + + // Bail out if the index is negative or bigger than our max of 8 + if i < 0 || i > 8 { + throw AccessoryError.appError("Index out of range \(i)") + } + } + for cs in channelSet.settings { + if addChannels { - // We are trying to add a channel so lets get the last index - let fetchMyInfoRequest = MyInfoEntity.fetchRequest() - fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum)) - do { - let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) - if fetchedMyInfo.count == 1 { - i = Int32(fetchedMyInfo[0].channels?.count ?? -1) - myInfo = fetchedMyInfo[0] - // Bail out if the index is negative or bigger than our max of 8 - if i < 0 || i > 8 { - throw AccessoryError.appError("Index out of range \(i)") - } - // Bail out if there are no channels or if the same channel name already exists - guard let mutableChannels = myInfo.channels!.mutableCopy() as? NSMutableOrderedSet else { - throw AccessoryError.appError("No channels or channel") - } - if mutableChannels.first(where: {($0 as AnyObject).name == cs.name }) is ChannelEntity { - throw AccessoryError.appError("Channel already exists") - } - } - } catch { - Logger.data.error("Failed to find a node MyInfo to save these channels to: \(error.localizedDescription, privacy: .public)") + guard let mutableChannels = myInfo.channels?.mutableCopy() as? NSMutableOrderedSet else { + throw AccessoryError.appError("No channels or channel") + } + + // Bail out if there are no channels or if the same channel name already exists + if mutableChannels.first(where: { ($0 as AnyObject).name == cs.name }) is ChannelEntity { + throw AccessoryError.appError("Channel already exists") } } var chan = Channel() - if i == 0 { - chan.role = Channel.Role.primary - } else { - chan.role = Channel.Role.secondary - } + chan.role = (i == 0) ? .primary : .secondary chan.settings = cs chan.index = i i += 1 var adminPacket = AdminMessage() adminPacket.setChannel = chan - var meshPacket: MeshPacket = MeshPacket() + + var meshPacket = MeshPacket() meshPacket.to = UInt32(deviceNum) - meshPacket.from = UInt32(deviceNum) + meshPacket.from = UInt32(deviceNum) meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. AsyncStream { - // Make sure we're connected - guard self.peripheral.state == .connected else { - throw AccessoryError.ioFailed("BLE peripheral not connected") - } - - return try await withTaskCancellationHandler { - try await discoverServices() - startRSSITask() - return self.getPacketStream() - } onCancel: { - Task { - await self.continueConnectionProcess(throwing: CancellationError()) - await self.notifyTransportOfDisconnect() + do { + // Make sure we're connected + guard self.peripheral.state == .connected else { + throw AccessoryError.ioFailed("BLE peripheral not connected") } + + return try await withTaskCancellationHandler { + try await discoverServices() + startRSSITask() + return self.getPacketStream() + } onCancel: { + Task { + await self.continueConnectionProcess(throwing: CancellationError()) + await self.notifyTransportOfDisconnect() + } + } + } catch { + // Before we throw, let the transport know we didn't successfully connect + await self.notifyTransportOfDisconnect() + throw error } } diff --git a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift index fc4953ac..8e3bbfba 100644 --- a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift +++ b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift @@ -206,30 +206,35 @@ actor BLETransport: Transport { throw AccessoryError.connectionFailed("Peripheral not found") } - if await self.activeConnection?.peripheral.state == .disconnected { - Logger.transport.error("🛜 [BLE] Connect request while an active (but disconnected)") - throw AccessoryError.connectionFailed("Connect request while an active connection exists") - } - - let returnConnection = try await withTaskCancellationHandler { - let newConnection: BLEConnection = try await withCheckedThrowingContinuation { cont in - if self.connectContinuation != nil || self.activeConnection != nil { - cont.resume(throwing: AccessoryError.connectionFailed("BLE transport is busy: already connecting or connected")) - return + do { + if await self.activeConnection?.peripheral.state == .disconnected { + Logger.transport.error("🛜 [BLE] Connect request while an active (but disconnected)") + throw AccessoryError.connectionFailed("Connect request while an active connection exists") + } + + let returnConnection = try await withTaskCancellationHandler { + let newConnection: BLEConnection = try await withCheckedThrowingContinuation { cont in + if self.connectContinuation != nil || self.activeConnection != nil { + cont.resume(throwing: AccessoryError.connectionFailed("BLE transport is busy: already connecting or connected")) + return + } + self.connectContinuation = cont + self.connectingPeripheral = peripheral.peripheral + centralManager.connect(peripheral.peripheral) + } + self.activeConnection = newConnection + return newConnection + } onCancel: { + Task { + await self.cancelConnectContinuation(for: peripheral.peripheral) } - self.connectContinuation = cont - self.connectingPeripheral = peripheral.peripheral - centralManager.connect(peripheral.peripheral) - } - self.activeConnection = newConnection - return newConnection - } onCancel: { - Task { - await self.cancelConnectContinuation(for: peripheral.peripheral) } + Logger.transport.debug("🛜 [BLE] Connect complete.") + return returnConnection + } catch { + connectionDidDisconnect(fromPeripheral: peripheral.peripheral) + throw error } - Logger.transport.debug("🛜 [BLE] Connect complete.") - return returnConnection } func handlePeripheralDisconnect(peripheral: CBPeripheral) { diff --git a/Meshtastic/Extensions/Logger.swift b/Meshtastic/Extensions/Logger.swift index a67f32d1..fb04f66f 100644 --- a/Meshtastic/Extensions/Logger.swift +++ b/Meshtastic/Extensions/Logger.swift @@ -36,6 +36,9 @@ extension Logger { /// All logs related to the transport layer static let transport = Logger(subsystem: subsystem, category: "🚚 Transport") + /// All logs related to TAK server and CoT messages + static let tak = Logger(subsystem: subsystem, category: "🎯 TAK") + /// Fetch from the logstore static public func fetch(predicateFormat: String) async throws -> [OSLogEntryLog] { diff --git a/Meshtastic/Helpers/LocalNotificationManager.swift b/Meshtastic/Helpers/LocalNotificationManager.swift index ffb716c2..4b478a30 100644 --- a/Meshtastic/Helpers/LocalNotificationManager.swift +++ b/Meshtastic/Helpers/LocalNotificationManager.swift @@ -2,6 +2,7 @@ import Foundation import SwiftUI import OSLog +@MainActor class LocalNotificationManager { var notifications = [Notification]() @@ -10,20 +11,23 @@ class LocalNotificationManager { let replyInputAction = UNTextInputNotificationAction(identifier: "messageNotification.replyInputAction", title: "Reply".localized, options: []) // Step 1 Request Permissions for notifications - private func requestAuthorization() { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in - - if granted == true && error == nil { - self.scheduleNotifications() + private func requestAuthorization() async { + do { + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) + if granted { + self.scheduleNotifications() } + } catch { + Logger.services.error("Error requesting notification authorization: \(error.localizedDescription, privacy: .public)") } } func schedule() { - UNUserNotificationCenter.current().getNotificationSettings { settings in + Task { @MainActor in + let settings = await UNUserNotificationCenter.current().notificationSettings() switch settings.authorizationStatus { case .notDetermined: - self.requestAuthorization() + await self.requestAuthorization() case .authorized, .provisional: self.scheduleNotifications() default: @@ -97,7 +101,7 @@ class LocalNotificationManager { for notification in notifications { if let userInfo = notification.content.userInfo["messageId"] as? Int64, userInfo == messageId { Logger.services.debug("Cancelling notification with id: \(notification.identifier)") - center.removePendingNotificationRequests(withIdentifiers: [notification.identifier]) + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [notification.identifier]) } } } diff --git a/Meshtastic/Helpers/Logger.swift b/Meshtastic/Helpers/Logger.swift deleted file mode 100644 index 35ee7337..00000000 --- a/Meshtastic/Helpers/Logger.swift +++ /dev/null @@ -1,19 +0,0 @@ -import OSLog - -extension Logger { - - /// The logger's subsystem. - private static var subsystem = Bundle.main.bundleIdentifier! - - /// All logs related to data such as decoding error, parsing issues, etc. - public static let data = Logger(subsystem: subsystem, category: "🗄️ Data") - - /// All logs related to the mesh - public static let mesh = Logger(subsystem: subsystem, category: "🕸️ Mesh") - - /// All logs related to services such as network calls, location, etc. - public static let services = Logger(subsystem: subsystem, category: "🍏 Services") - - /// All logs related to tracking and analytics. - public static let statistics = Logger(subsystem: subsystem, category: "📈 Stats") -} diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 8b7a7423..54e1661f 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -55,1040 +55,1048 @@ func generateMessageMarkdown (message: String) -> String { return message } -func localConfig (config: Config, context: NSManagedObjectContext, nodeNum: Int64, nodeLongName: String) { - switch config.payloadVariant { - case .bluetooth: - upsertBluetoothConfigPacket(config: config.bluetooth, nodeNum: nodeNum, context: context) - case .device: - upsertDeviceConfigPacket(config: config.device, nodeNum: nodeNum, context: context) - case .display: - upsertDisplayConfigPacket(config: config.display, nodeNum: nodeNum, context: context) - case .lora: - upsertLoRaConfigPacket(config: config.lora, nodeNum: nodeNum, context: context) - case .network: - upsertNetworkConfigPacket(config: config.network, nodeNum: nodeNum, context: context) - case .position: - upsertPositionConfigPacket(config: config.position, nodeNum: nodeNum, context: context) - case .power: - upsertPowerConfigPacket(config: config.power, nodeNum: nodeNum, context: context) - case .security: - upsertSecurityConfigPacket(config: config.security, nodeNum: nodeNum, context: context) - default: +actor MeshPackets { + static let shared = MeshPackets() + + // Create an actor-level background context + // We keep this alive so sequential writes happen on the same context (efficient) + lazy var backgroundContext: NSManagedObjectContext = { + let ctx = PersistenceController.shared.container.newBackgroundContext() + ctx.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy // Handle conflicts automatically + return ctx + }() + + func localConfig (config: Config, nodeNum: Int64, nodeLongName: String) async { + switch config.payloadVariant { + case .bluetooth: + await self.upsertBluetoothConfigPacket(config: config.bluetooth, nodeNum: nodeNum) + case .device: + await self.upsertDeviceConfigPacket(config: config.device, nodeNum: nodeNum) + case .display: + await self.upsertDisplayConfigPacket(config: config.display, nodeNum: nodeNum) + case .lora: + await self.upsertLoRaConfigPacket(config: config.lora, nodeNum: nodeNum) + case .network: + await self.upsertNetworkConfigPacket(config: config.network, nodeNum: nodeNum) + case .position: + await self.upsertPositionConfigPacket(config: config.position, nodeNum: nodeNum) + case .power: + await self.upsertPowerConfigPacket(config: config.power, nodeNum: nodeNum) + case .security: + await self.upsertSecurityConfigPacket(config: config.security, nodeNum: nodeNum) + default: #if DEBUG - Logger.services.error("⁉️ Unknown Config variant UNHANDLED \(config.payloadVariant.debugDescription, privacy: .public)") + Logger.services.error("⁉️ Unknown Config variant UNHANDLED \(config.payloadVariant.debugDescription, privacy: .public)") #endif + } } -} - -func moduleConfig (config: ModuleConfig, context: NSManagedObjectContext, nodeNum: Int64, nodeLongName: String) { - switch config.payloadVariant { - case .ambientLighting: - upsertAmbientLightingModuleConfigPacket(config: config.ambientLighting, nodeNum: nodeNum, context: context) - case .cannedMessage: - upsertCannedMessagesModuleConfigPacket(config: config.cannedMessage, nodeNum: nodeNum, context: context) - case .detectionSensor: - upsertDetectionSensorModuleConfigPacket(config: config.detectionSensor, nodeNum: nodeNum, context: context) - case .externalNotification: - upsertExternalNotificationModuleConfigPacket(config: config.externalNotification, nodeNum: nodeNum, context: context) - case .mqtt: - upsertMqttModuleConfigPacket(config: config.mqtt, nodeNum: nodeNum, context: context) - case .paxcounter: - upsertPaxCounterModuleConfigPacket(config: config.paxcounter, nodeNum: nodeNum, context: context) - case .rangeTest: - upsertRangeTestModuleConfigPacket(config: config.rangeTest, nodeNum: nodeNum, context: context) - case .serial: - upsertSerialModuleConfigPacket(config: config.serial, nodeNum: nodeNum, context: context) - case .telemetry: - upsertTelemetryModuleConfigPacket(config: config.telemetry, nodeNum: nodeNum, context: context) - case .storeForward: - upsertStoreForwardModuleConfigPacket(config: config.storeForward, nodeNum: nodeNum, context: context) - default: + + func moduleConfig (config: ModuleConfig, nodeNum: Int64, nodeLongName: String) async { + switch config.payloadVariant { + case .ambientLighting: + await self.upsertAmbientLightingModuleConfigPacket(config: config.ambientLighting, nodeNum: nodeNum) + case .cannedMessage: + await self.upsertCannedMessagesModuleConfigPacket(config: config.cannedMessage, nodeNum: nodeNum) + case .detectionSensor: + await self.upsertDetectionSensorModuleConfigPacket(config: config.detectionSensor, nodeNum: nodeNum) + case .externalNotification: + await self.upsertExternalNotificationModuleConfigPacket(config: config.externalNotification, nodeNum: nodeNum) + case .mqtt: + await self.upsertMqttModuleConfigPacket(config: config.mqtt, nodeNum: nodeNum) + case .paxcounter: + await self.upsertPaxCounterModuleConfigPacket(config: config.paxcounter, nodeNum: nodeNum) + case .rangeTest: + await self.upsertRangeTestModuleConfigPacket(config: config.rangeTest, nodeNum: nodeNum) + case .serial: + await self.upsertSerialModuleConfigPacket(config: config.serial, nodeNum: nodeNum) + case .telemetry: + await self.upsertTelemetryModuleConfigPacket(config: config.telemetry, nodeNum: nodeNum) + case .storeForward: + await self.upsertStoreForwardModuleConfigPacket(config: config.storeForward, nodeNum: nodeNum) + default: #if DEBUG - Logger.services.error("⁉️ Unknown Module Config variant UNHANDLED \(config.payloadVariant.debugDescription, privacy: .public)") + Logger.services.error("⁉️ Unknown Module Config variant UNHANDLED \(config.payloadVariant.debugDescription, privacy: .public)") #endif - } -} - -func myInfoPacket (myInfo: MyNodeInfo, peripheralId: String, context: NSManagedObjectContext) -> MyInfoEntity? { - - let logString = String.localizedStringWithFormat("MyInfo received: %@".localized, String(myInfo.myNodeNum)) - Logger.mesh.info("ℹ️ \(logString, privacy: .public)") - - let fetchMyInfoRequest = MyInfoEntity.fetchRequest() - fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(myInfo.myNodeNum)) - - do { - let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) - // Not Found Insert - if fetchedMyInfo.isEmpty { - - let myInfoEntity = MyInfoEntity(context: context) - myInfoEntity.peripheralId = peripheralId - myInfoEntity.myNodeNum = Int64(myInfo.myNodeNum) - myInfoEntity.rebootCount = Int32(myInfo.rebootCount) - myInfoEntity.deviceId = myInfo.deviceID - do { - try context.save() - Logger.data.info("💾 Saved a new myInfo for node: \(myInfo.myNodeNum.toHex(), privacy: .public)") - return myInfoEntity - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 Error Inserting New Core Data MyInfoEntity: \(nsError, privacy: .public)") - } - } else { - - fetchedMyInfo[0].peripheralId = peripheralId - fetchedMyInfo[0].myNodeNum = Int64(myInfo.myNodeNum) - fetchedMyInfo[0].rebootCount = Int32(myInfo.rebootCount) - - do { - try context.save() - Logger.data.info("💾 Updated myInfo for node: \(myInfo.myNodeNum.toHex(), privacy: .public)") - return fetchedMyInfo[0] - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 Error Updating Core Data MyInfoEntity: \(nsError, privacy: .public)") - } - } - } catch { - Logger.data.error("💥 Fetch MyInfo Error") - } - return nil -} - -func channelPacket (channel: Channel, fromNum: Int64, context: NSManagedObjectContext) { - - if channel.isInitialized && channel.hasSettings && channel.role != Channel.Role.disabled { - - let logString = String.localizedStringWithFormat("mesh.log.channel.received %d %@".localized, channel.index, String(fromNum)) - Logger.mesh.info("🎛️ \(logString, privacy: .public)") - - let fetchedMyInfoRequest = MyInfoEntity.fetchRequest() - fetchedMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", fromNum) - - do { - let fetchedMyInfo = try context.fetch(fetchedMyInfoRequest) - if fetchedMyInfo.count == 1 { - let newChannel = ChannelEntity(context: context) - newChannel.id = Int32(channel.index) - newChannel.index = Int32(channel.index) - newChannel.uplinkEnabled = channel.settings.uplinkEnabled - newChannel.downlinkEnabled = channel.settings.downlinkEnabled - newChannel.name = channel.settings.name - newChannel.role = Int32(channel.role.rawValue) - newChannel.psk = channel.settings.psk - if channel.settings.hasModuleSettings { - newChannel.positionPrecision = Int32(truncatingIfNeeded: channel.settings.moduleSettings.positionPrecision) - newChannel.mute = channel.settings.moduleSettings.isMuted - } - guard let mutableChannels = fetchedMyInfo[0].channels!.mutableCopy() as? NSMutableOrderedSet else { - return - } - if let oldChannel = mutableChannels.first(where: {($0 as AnyObject).index == newChannel.index }) as? ChannelEntity { - let index = mutableChannels.index(of: oldChannel as Any) - mutableChannels.replaceObject(at: index, with: newChannel) - } else { - mutableChannels.add(newChannel) - } - fetchedMyInfo[0].channels = mutableChannels.copy() as? NSOrderedSet - context.refresh(newChannel, mergeChanges: true) - do { - try context.save() - } catch { - Logger.data.error("💥 Failed to save channel: \(error.localizedDescription, privacy: .public)") - } - Logger.data.info("💾 Updated MyInfo channel \(channel.index, privacy: .public) from Channel App Packet For: \(fetchedMyInfo[0].myNodeNum, privacy: .public)") - } else if channel.role.rawValue > 0 { - Logger.data.error("💥Trying to save a channel to a MyInfo that does not exist: \(fromNum.toHex(), privacy: .public)") - } - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 Error Saving MyInfo Channel from ADMIN_APP \(nsError, privacy: .public)") } } -} - -func deviceMetadataPacket (metadata: DeviceMetadata, fromNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - if metadata.isInitialized { - let logString = String.localizedStringWithFormat("Device Metadata received from: %@".localized, fromNum.toHex()) - Logger.mesh.info("🏷️ \(logString, privacy: .public)") - - let fetchedNodeRequest = NodeInfoEntity.fetchRequest() - fetchedNodeRequest.predicate = NSPredicate(format: "num == %lld", fromNum) - - do { - let fetchedNode = try context.fetch(fetchedNodeRequest) - let newMetadata = DeviceMetadataEntity(context: context) - newMetadata.time = Date() - newMetadata.deviceStateVersion = Int32(metadata.deviceStateVersion) - newMetadata.canShutdown = metadata.canShutdown - newMetadata.hasWifi = metadata.hasWifi_p - newMetadata.hasBluetooth = metadata.hasBluetooth_p - newMetadata.hasEthernet = metadata.hasEthernet_p - newMetadata.role = Int32(metadata.role.rawValue) - newMetadata.positionFlags = Int32(metadata.positionFlags) - newMetadata.excludedModules = Int32(metadata.excludedModules) - // Swift does strings weird, this does work to get the version without the github hash - let lastDotIndex = metadata.firmwareVersion.lastIndex(of: ".") - var version = metadata.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: metadata.firmwareVersion))] - version = version.dropLast() - newMetadata.firmwareVersion = String(version) - if fetchedNode.count > 0 { - fetchedNode[0].metadata = newMetadata - } else { - - if fromNum > 0 { - let newNode = createNodeInfo(num: Int64(fromNum), context: context) - newNode.metadata = newMetadata - } - } - if sessionPasskey?.count != 0 { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - } catch { - Logger.data.error("💥 Failed to save device metadata: \(error.localizedDescription, privacy: .public)") - } - Logger.data.info("💾 Updated Device Metadata from Admin App Packet For: \(fromNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Saving MyInfo Channel from ADMIN_APP \(nsError, privacy: .public)") - } - } -} - -func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObjectContext, deferSave: Bool = false) -> NodeInfoEntity? { - - let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, String(nodeInfo.num)) - Logger.mesh.info("📟 \(logString, privacy: .public)") - - guard nodeInfo.num > 0 else { return nil } - - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeInfo.num)) - - do { - let fetchedNode = try context.fetch(fetchNodeInfoRequest) - // Not Found Insert - if fetchedNode.isEmpty && nodeInfo.num > 0 { - - let newNode = NodeInfoEntity(context: context) - newNode.id = Int64(nodeInfo.num) - newNode.num = Int64(nodeInfo.num) - newNode.channel = Int32(nodeInfo.channel) - newNode.favorite = nodeInfo.isFavorite - newNode.ignored = nodeInfo.isIgnored - newNode.hopsAway = Int32(nodeInfo.hopsAway) - - if nodeInfo.hasDeviceMetrics { - let telemetry = TelemetryEntity(context: context) - telemetry.batteryLevel = Int32(nodeInfo.deviceMetrics.batteryLevel) - telemetry.voltage = nodeInfo.deviceMetrics.voltage - telemetry.channelUtilization = nodeInfo.deviceMetrics.channelUtilization - telemetry.airUtilTx = nodeInfo.deviceMetrics.airUtilTx - var newTelemetries = [TelemetryEntity]() - newTelemetries.append(telemetry) - newNode.telemetries? = NSOrderedSet(array: newTelemetries) - } - if nodeInfo.lastHeard > 0 { - newNode.firstHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard))) - newNode.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard))) - } else { - newNode.firstHeard = Date() - newNode.lastHeard = Date() - } - newNode.snr = nodeInfo.snr - if nodeInfo.hasUser { - - let newUser = UserEntity(context: context) - newUser.userId = nodeInfo.num.toHex() - newUser.num = Int64(nodeInfo.num) - newUser.longName = nodeInfo.user.longName - newUser.shortName = nodeInfo.user.shortName - newUser.hwModel = String(describing: nodeInfo.user.hwModel).uppercased() - newUser.hwModelId = Int32(nodeInfo.user.hwModel.rawValue) - Task { - Api().loadDeviceHardwareData { (hw) in - let dh = hw.first(where: { $0.hwModel == newUser.hwModelId }) - newUser.hwDisplayName = dh?.displayName - } - } - newUser.isLicensed = nodeInfo.user.isLicensed - newUser.role = Int32(nodeInfo.user.role.rawValue) - if !nodeInfo.user.publicKey.isEmpty { - newUser.pkiEncrypted = true - newUser.publicKey = nodeInfo.user.publicKey - } - /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default - if nodeInfo.user.hasIsUnmessagable { - newUser.unmessagable = nodeInfo.user.isUnmessagable - } else { - let roles = [2, 4, 5, 6, 7, 10, 11] - let containsRole = roles.contains(Int(newUser.role)) - if containsRole { - newUser.unmessagable = true - } else { - newUser.unmessagable = false - }} - newNode.user = newUser - } else if nodeInfo.num > Constants.minimumNodeNum { - do { - let newUser = try createUser(num: Int64(nodeInfo.num), context: context) - newNode.user = newUser - } catch CoreDataError.invalidInput(let message) { - Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(nodeInfo.num, privacy: .public) Error: \(message, privacy: .public)") - } catch { - Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(nodeInfo.num, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") - } - } - - if (nodeInfo.position.longitudeI != 0 && nodeInfo.position.latitudeI != 0) && (nodeInfo.position.latitudeI != 373346000 && nodeInfo.position.longitudeI != -1220090000) { - let position = PositionEntity(context: context) - position.latest = true - position.seqNo = Int32(nodeInfo.position.seqNumber) - position.latitudeI = nodeInfo.position.latitudeI - position.longitudeI = nodeInfo.position.longitudeI - position.altitude = nodeInfo.position.altitude - position.satsInView = Int32(nodeInfo.position.satsInView) - position.speed = Int32(nodeInfo.position.groundSpeed) - position.heading = Int32(nodeInfo.position.groundTrack) - position.time = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.position.time))) - var newPostions = [PositionEntity]() - newPostions.append(position) - newNode.positions? = NSOrderedSet(array: newPostions) - } - - // Look for a MyInfo + + func myInfoPacket (myInfo: MyNodeInfo, peripheralId: String) async -> NSManagedObjectID? { + let context = self.backgroundContext + return await context.perform { + let logString = String.localizedStringWithFormat("MyInfo received: %@".localized, String(myInfo.myNodeNum)) + Logger.mesh.info("ℹ️ \(logString, privacy: .public)") + let fetchMyInfoRequest = MyInfoEntity.fetchRequest() - fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(nodeInfo.num)) - + fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(myInfo.myNodeNum)) + do { let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) - if fetchedMyInfo.count > 0 { - newNode.myInfo = fetchedMyInfo[0] - } - do { - if !deferSave { - try context.save() - Logger.data.info("💾 Saved a new Node Info For: \(String(nodeInfo.num), privacy: .public)") - } - return newNode - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Saving Core Data NodeInfoEntity: \(nsError, privacy: .public)") - } - } catch { - Logger.data.error("Fetch MyInfo Error") - } - } else if nodeInfo.num > 0 { - - fetchedNode[0].id = Int64(nodeInfo.num) - fetchedNode[0].num = Int64(nodeInfo.num) - fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard))) - fetchedNode[0].snr = nodeInfo.snr - fetchedNode[0].channel = Int32(nodeInfo.channel) - fetchedNode[0].favorite = nodeInfo.isFavorite - fetchedNode[0].ignored = nodeInfo.isIgnored - fetchedNode[0].hopsAway = Int32(nodeInfo.hopsAway) - - if nodeInfo.hasUser { - if fetchedNode[0].user == nil { - fetchedNode[0].user = UserEntity(context: context) - } - // Set the public key for a user if it is empty, don't update - if fetchedNode[0].user?.publicKey == nil && !nodeInfo.user.publicKey.isEmpty { - fetchedNode[0].user?.pkiEncrypted = true - fetchedNode[0].user?.publicKey = nodeInfo.user.publicKey - } - fetchedNode[0].user?.userId = nodeInfo.num.toHex() - fetchedNode[0].user?.num = Int64(nodeInfo.num) - fetchedNode[0].user?.numString = String(nodeInfo.num) - fetchedNode[0].user?.longName = nodeInfo.user.longName - fetchedNode[0].user?.shortName = nodeInfo.user.shortName - fetchedNode[0].user?.isLicensed = nodeInfo.user.isLicensed - fetchedNode[0].user?.role = Int32(nodeInfo.user.role.rawValue) - fetchedNode[0].user?.hwModel = String(describing: nodeInfo.user.hwModel).uppercased() - fetchedNode[0].user?.hwModelId = Int32(nodeInfo.user.hwModel.rawValue) - /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default - if nodeInfo.user.hasIsUnmessagable { - fetchedNode[0].user?.unmessagable = nodeInfo.user.isUnmessagable - } else { - let roles = [-1, 2, 4, 5, 6, 7, 10, 11] - let containsRole = roles.contains(Int(fetchedNode[0].user?.role ?? -1)) - if containsRole { - fetchedNode[0].user?.unmessagable = true - } else { - fetchedNode[0].user?.unmessagable = false - } - } - Task { - Api().loadDeviceHardwareData { (hw: [DeviceHardware]) in - guard !hw.isEmpty, - let firstNode = fetchedNode.first, - let user = firstNode.user else { - Logger.data.error("Error: Required DeviceHardware data is missing or array is empty.") - return - } - - let dh = hw.first(where: { $0.hwModel == user.hwModelId }) - - if let deviceHardware = dh { - firstNode.user?.hwDisplayName = deviceHardware.displayName - } else { - Logger.data.error("No matching hardware model found for ID: \(user.hwModelId, privacy: .public)") - } - } - } - } else { - if fetchedNode[0].user == nil && nodeInfo.num > Constants.minimumNodeNum { + // Not Found Insert + if fetchedMyInfo.isEmpty { + + let myInfoEntity = MyInfoEntity(context: context) + myInfoEntity.peripheralId = peripheralId + myInfoEntity.myNodeNum = Int64(myInfo.myNodeNum) + myInfoEntity.rebootCount = Int32(myInfo.rebootCount) + myInfoEntity.deviceId = myInfo.deviceID do { - let newUser = try createUser(num: Int64(nodeInfo.num), context: context) - fetchedNode[0].user = newUser - } catch CoreDataError.invalidInput(let message) { - Logger.data.error("Error Creating a new Core Data UserEntity on an existing node (Invalid Input) from node number: \(nodeInfo.num, privacy: .public) Error: \(message, privacy: .public)") - } catch { - Logger.data.error("Error Creating a new Core Data UserEntity on an existing node from node number: \(nodeInfo.num, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") - } - } - } - - if nodeInfo.hasDeviceMetrics { - - let newTelemetry = TelemetryEntity(context: context) - newTelemetry.batteryLevel = Int32(nodeInfo.deviceMetrics.batteryLevel) - newTelemetry.voltage = nodeInfo.deviceMetrics.voltage - newTelemetry.channelUtilization = nodeInfo.deviceMetrics.channelUtilization - newTelemetry.airUtilTx = nodeInfo.deviceMetrics.airUtilTx - guard let mutableTelemetries = fetchedNode[0].telemetries!.mutableCopy() as? NSMutableOrderedSet else { - return nil - } - mutableTelemetries.add(newTelemetry) - fetchedNode[0].telemetries = mutableTelemetries.copy() as? NSOrderedSet - } - - if nodeInfo.hasPosition { - - if (nodeInfo.position.longitudeI != 0 && nodeInfo.position.latitudeI != 0) && (nodeInfo.position.latitudeI != 373346000 && nodeInfo.position.longitudeI != -1220090000) { - - let position = PositionEntity(context: context) - position.latitudeI = nodeInfo.position.latitudeI - position.longitudeI = nodeInfo.position.longitudeI - position.altitude = nodeInfo.position.altitude - position.satsInView = Int32(nodeInfo.position.satsInView) - position.time = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.position.time))) - guard let mutablePositions = fetchedNode[0].positions!.mutableCopy() as? NSMutableOrderedSet else { - return nil - } - fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet - } - - } - - // Look for a MyInfo - let fetchMyInfoRequest = MyInfoEntity.fetchRequest() - fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(nodeInfo.num)) - - do { - let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) - if fetchedMyInfo.count > 0 { - fetchedNode[0].myInfo = fetchedMyInfo[0] - } - do { - if !deferSave { try context.save() - Logger.data.info("💾 [NodeInfo] saved for \(nodeInfo.num.toHex(), privacy: .public)") + Logger.data.info("💾 Saved a new myInfo for node: \(myInfo.myNodeNum.toHex(), privacy: .public)") + return myInfoEntity.objectID + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 Error Inserting New Core Data MyInfoEntity: \(nsError, privacy: .public)") + } + } else { + + fetchedMyInfo[0].peripheralId = peripheralId + fetchedMyInfo[0].myNodeNum = Int64(myInfo.myNodeNum) + fetchedMyInfo[0].rebootCount = Int32(myInfo.rebootCount) + + do { + try context.save() + Logger.data.info("💾 Updated myInfo for node: \(myInfo.myNodeNum.toHex(), privacy: .public)") + return fetchedMyInfo[0].objectID + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 Error Updating Core Data MyInfoEntity: \(nsError, privacy: .public)") } - return fetchedNode[0] - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 Error Saving Core Data NodeInfoEntity: \(nsError, privacy: .public)") } } catch { Logger.data.error("💥 Fetch MyInfo Error") } + return nil } - } catch { - Logger.data.error("💥 Fetch NodeInfoEntity Error") } - return nil -} - -func adminAppPacket (packet: MeshPacket, context: NSManagedObjectContext) { - - if let adminMessage = try? AdminMessage(serializedBytes: packet.decoded.payload) { - - if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getCannedMessageModuleMessagesResponse(adminMessage.getCannedMessageModuleMessagesResponse) { - - if let cmmc = try? CannedMessageModuleConfig(serializedBytes: packet.decoded.payload) { - let logString = String.localizedStringWithFormat("Canned Messages Messages Received For: %@".localized, packet.from.toHex()) - Logger.mesh.info("🥫 \(logString, privacy: .public)") - - let fetchNodeRequest = NodeInfoEntity.fetchRequest() - fetchNodeRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) - - do { - let fetchedNode = try context.fetch(fetchNodeRequest) - if fetchedNode.count == 1 { - let messages = String(cmmc.textFormatString()) - .replacingOccurrences(of: "11: ", with: "") - .replacingOccurrences(of: "\"", with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) - .components(separatedBy: "\n").first ?? "" - fetchedNode[0].cannedMessageConfig?.messages = messages - do { - try context.save() - Logger.data.info("💾 Updated Canned Messages Messages For: \(fetchedNode.first?.num.toHex() ?? "Unknown".localized, privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 Error Saving NodeInfoEntity from POSITION_APP \(nsError, privacy: .public)") - } - } - } catch { - Logger.data.error("💥 Error Deserializing ADMIN_APP packet.") - } - } - } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getChannelResponse(adminMessage.getChannelResponse) { - channelPacket(channel: adminMessage.getChannelResponse, fromNum: Int64(packet.from), context: context) - } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getDeviceMetadataResponse(adminMessage.getDeviceMetadataResponse) { - deviceMetadataPacket(metadata: adminMessage.getDeviceMetadataResponse, fromNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) - } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getConfigResponse(adminMessage.getConfigResponse) { - let config = adminMessage.getConfigResponse - if config.payloadVariant == Config.OneOf_PayloadVariant.bluetooth(config.bluetooth) { - upsertBluetoothConfigPacket(config: config.bluetooth, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) { - upsertDeviceConfigPacket(config: config.device, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.display(config.display) { - upsertDisplayConfigPacket(config: config.display, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.lora(config.lora) { - upsertLoRaConfigPacket(config: config.lora, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.network(config.network) { - upsertNetworkConfigPacket(config: config.network, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.position(config.position) { - upsertPositionConfigPacket(config: config.position, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.power(config.power) { - upsertPowerConfigPacket(config: config.power, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) - } else if config.payloadVariant == Config.OneOf_PayloadVariant.security(config.security) { - upsertSecurityConfigPacket(config: config.security, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) - } - } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getModuleConfigResponse(adminMessage.getModuleConfigResponse) { - let moduleConfig = adminMessage.getModuleConfigResponse - if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.ambientLighting(moduleConfig.ambientLighting) { - upsertAmbientLightingModuleConfigPacket(config: moduleConfig.ambientLighting, nodeNum: Int64(packet.from), context: context) - } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(moduleConfig.cannedMessage) { - upsertCannedMessagesModuleConfigPacket(config: moduleConfig.cannedMessage, nodeNum: Int64(packet.from), context: context) - } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.detectionSensor(moduleConfig.detectionSensor) { - upsertDetectionSensorModuleConfigPacket(config: moduleConfig.detectionSensor, nodeNum: Int64(packet.from), context: context) - } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.externalNotification(moduleConfig.externalNotification) { - upsertExternalNotificationModuleConfigPacket(config: moduleConfig.externalNotification, nodeNum: Int64(packet.from), context: context) - } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.mqtt(moduleConfig.mqtt) { - upsertMqttModuleConfigPacket(config: moduleConfig.mqtt, nodeNum: Int64(packet.from), context: context) - } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.rangeTest(moduleConfig.rangeTest) { - upsertRangeTestModuleConfigPacket(config: moduleConfig.rangeTest, nodeNum: Int64(packet.from), context: context) - } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.serial(moduleConfig.serial) { - upsertSerialModuleConfigPacket(config: moduleConfig.serial, nodeNum: Int64(packet.from), context: context) - } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.storeForward(moduleConfig.storeForward) { - upsertStoreForwardModuleConfigPacket(config: moduleConfig.storeForward, nodeNum: Int64(packet.from), context: context) - } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.telemetry(moduleConfig.telemetry) { - upsertTelemetryModuleConfigPacket(config: moduleConfig.telemetry, nodeNum: Int64(packet.from), context: context) - } - } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getRingtoneResponse(adminMessage.getRingtoneResponse) { - if let rt = try? RTTTLConfig(serializedBytes: packet.decoded.payload) { - upsertRtttlConfigPacket(ringtone: rt.ringtone, nodeNum: Int64(packet.from), context: context) - } - } else { - Logger.mesh.error("🕸️ MESH PACKET received Admin App UNHANDLED \((try? packet.decoded.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + + func channelPacket (channel: Channel, fromNum: Int64) async { + let context = self.backgroundContext + await context.perform { + self.channelPacket(channel: channel, fromNum: fromNum, context: context) } - // Save an ack for the admin message log for each admin message response received as we stopped sending acks if there is also a response to reduce airtime. - adminResponseAck(packet: packet, context: context) } -} - -func adminResponseAck (packet: MeshPacket, context: NSManagedObjectContext) { - - let fetchedAdminMessageRequest = MessageEntity.fetchRequest() - fetchedAdminMessageRequest.predicate = NSPredicate(format: "messageId == %lld", packet.decoded.requestID) - do { - let fetchedMessage = try context.fetch(fetchedAdminMessageRequest) - if fetchedMessage.count > 0 { - fetchedMessage[0].ackTimestamp = Int32(Date().timeIntervalSince1970) - fetchedMessage[0].ackError = Int32(RoutingError.none.rawValue) - fetchedMessage[0].receivedACK = true - fetchedMessage[0].realACK = true - fetchedMessage[0].relayNode = Int64(packet.relayNode) - fetchedMessage[0].ackSNR = packet.rxSnr - if fetchedMessage[0].fromUser != nil { - fetchedMessage[0].fromUser?.objectWillChange.send() - } + + nonisolated private func channelPacket (channel: Channel, fromNum: Int64, context: NSManagedObjectContext) { + if channel.isInitialized && channel.hasSettings && channel.role != Channel.Role.disabled { + let logString = String.localizedStringWithFormat("mesh.log.channel.received %d %@".localized, channel.index, String(fromNum)) + Logger.mesh.info("🎛️ \(logString, privacy: .public)") + + let fetchedMyInfoRequest = MyInfoEntity.fetchRequest() + fetchedMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", fromNum) + do { - try context.save() + let fetchedMyInfo = try context.fetch(fetchedMyInfoRequest) + if fetchedMyInfo.count == 1 { + let newChannel = ChannelEntity(context: context) + newChannel.id = Int32(channel.index) + newChannel.index = Int32(channel.index) + newChannel.uplinkEnabled = channel.settings.uplinkEnabled + newChannel.downlinkEnabled = channel.settings.downlinkEnabled + newChannel.name = channel.settings.name + newChannel.role = Int32(channel.role.rawValue) + newChannel.psk = channel.settings.psk + if channel.settings.hasModuleSettings { + newChannel.positionPrecision = Int32(truncatingIfNeeded: channel.settings.moduleSettings.positionPrecision) + newChannel.mute = channel.settings.moduleSettings.isMuted + } + guard let mutableChannels = fetchedMyInfo[0].channels!.mutableCopy() as? NSMutableOrderedSet else { + return + } + if let oldChannel = mutableChannels.first(where: {($0 as AnyObject).index == newChannel.index }) as? ChannelEntity { + let index = mutableChannels.index(of: oldChannel as Any) + mutableChannels.replaceObject(at: index, with: newChannel) + } else { + mutableChannels.add(newChannel) + } + fetchedMyInfo[0].channels = mutableChannels.copy() as? NSOrderedSet + context.refresh(newChannel, mergeChanges: true) + do { + try context.save() + } catch { + Logger.data.error("💥 Failed to save channel: \(error.localizedDescription, privacy: .public)") + } + Logger.data.info("💾 Updated MyInfo channel \(channel.index, privacy: .public) from Channel App Packet For: \(fetchedMyInfo[0].myNodeNum, privacy: .public)") + } else if channel.role.rawValue > 0 { + Logger.data.error("💥Trying to save a channel to a MyInfo that does not exist: \(fromNum.toHex(), privacy: .public)") + } } catch { - Logger.data.error("Failed to save admin message response as an ack: \(error.localizedDescription, privacy: .public)") + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 Error Saving MyInfo Channel from ADMIN_APP \(nsError, privacy: .public)") } } - } catch { - Logger.data.error("Failed to fetch admin message by requestID: \(error.localizedDescription, privacy: .public)") } -} -func paxCounterPacket (packet: MeshPacket, context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("PAX Counter message received from: %@".localized, String(packet.from)) - Logger.mesh.info("🧑‍🤝‍🧑 \(logString, privacy: .public)") - - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) - - do { - let fetchedNode = try context.fetch(fetchNodeInfoRequest) - - if let paxMessage = try? Paxcount(serializedBytes: packet.decoded.payload) { - - let newPax = PaxCounterEntity(context: context) - newPax.ble = Int32(truncatingIfNeeded: paxMessage.ble) - newPax.wifi = Int32(truncatingIfNeeded: paxMessage.wifi) - newPax.uptime = Int32(truncatingIfNeeded: paxMessage.uptime) - newPax.time = Date() - - if fetchedNode.count > 0 { - guard let mutablePax = fetchedNode[0].pax!.mutableCopy() as? NSMutableOrderedSet else { - return + + func deviceMetadataPacket (metadata: DeviceMetadata, fromNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.deviceMetadataPacket(metadata: metadata, fromNum: fromNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated private func deviceMetadataPacket (metadata: DeviceMetadata, fromNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + if metadata.isInitialized { + let logString = String.localizedStringWithFormat("Device Metadata received from: %@".localized, fromNum.toHex()) + Logger.mesh.info("🏷️ \(logString, privacy: .public)") + + let fetchedNodeRequest = NodeInfoEntity.fetchRequest() + fetchedNodeRequest.predicate = NSPredicate(format: "num == %lld", fromNum) + + do { + let fetchedNode = try context.fetch(fetchedNodeRequest) + let newMetadata = DeviceMetadataEntity(context: context) + newMetadata.time = Date() + newMetadata.deviceStateVersion = Int32(metadata.deviceStateVersion) + newMetadata.canShutdown = metadata.canShutdown + newMetadata.hasWifi = metadata.hasWifi_p + newMetadata.hasBluetooth = metadata.hasBluetooth_p + newMetadata.hasEthernet = metadata.hasEthernet_p + newMetadata.role = Int32(metadata.role.rawValue) + newMetadata.positionFlags = Int32(metadata.positionFlags) + newMetadata.excludedModules = Int32(metadata.excludedModules) + // Swift does strings weird, this does work to get the version without the github hash + let lastDotIndex = metadata.firmwareVersion.lastIndex(of: ".") + var version = metadata.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: metadata.firmwareVersion))] + version = version.dropLast() + newMetadata.firmwareVersion = String(version) + if fetchedNode.count > 0 { + fetchedNode[0].metadata = newMetadata + } else { + + if fromNum > 0 { + let newNode = createNodeInfo(num: Int64(fromNum), context: context) + newNode.metadata = newMetadata + } + } + if sessionPasskey?.count != 0 { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) } - mutablePax.add(newPax) - fetchedNode[0].pax = mutablePax do { try context.save() } catch { - Logger.data.error("Failed to save pax: \(error.localizedDescription, privacy: .public)") + Logger.data.error("💥 Failed to save device metadata: \(error.localizedDescription, privacy: .public)") } - } else { - Logger.data.info("Node Info Not Found") + Logger.data.info("💾 Updated Device Metadata from Admin App Packet For: \(fromNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving MyInfo Channel from ADMIN_APP \(nsError, privacy: .public)") } } - } catch { - } -} - -func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSManagedObjectContext) { - - if let routingMessage = try? Routing(serializedBytes: packet.decoded.payload) { - - let routingError = RoutingError(rawValue: routingMessage.errorReason.rawValue) - - let routingErrorString = routingError?.display ?? "Unknown".localized - let logString = String.localizedStringWithFormat("Routing received for RequestID: %@ Ack Status: %@".localized, String(packet.decoded.requestID), routingErrorString) - Logger.mesh.info("🕸️ \(logString, privacy: .public)") - - let fetchMessageRequest = MessageEntity.fetchRequest() - fetchMessageRequest.predicate = NSPredicate(format: "messageId == %lld", Int64(packet.decoded.requestID)) - - do { - let fetchedMessage = try context.fetch(fetchMessageRequest) - if fetchedMessage.count > 0 { - if fetchedMessage[0].toUser != nil { - // Real ACK from DM Recipient - if packet.to != packet.from { - fetchedMessage[0].realACK = true + + func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, deferSave: Bool = false) async -> NSManagedObjectID? { + let context = self.backgroundContext + return await context.perform { () -> NSManagedObjectID? in + let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, String(nodeInfo.num)) + Logger.mesh.info("📟 \(logString, privacy: .public)") + + guard nodeInfo.num > 0 else { return nil } + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeInfo.num)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Not Found Insert + if fetchedNode.isEmpty && nodeInfo.num > 0 { + + let newNode = NodeInfoEntity(context: context) + newNode.id = Int64(nodeInfo.num) + newNode.num = Int64(nodeInfo.num) + newNode.channel = Int32(nodeInfo.channel) + newNode.favorite = nodeInfo.isFavorite + newNode.ignored = nodeInfo.isIgnored + newNode.hopsAway = Int32(nodeInfo.hopsAway) + + if nodeInfo.hasDeviceMetrics { + let telemetry = TelemetryEntity(context: context) + telemetry.batteryLevel = Int32(nodeInfo.deviceMetrics.batteryLevel) + telemetry.voltage = nodeInfo.deviceMetrics.voltage + telemetry.channelUtilization = nodeInfo.deviceMetrics.channelUtilization + telemetry.airUtilTx = nodeInfo.deviceMetrics.airUtilTx + var newTelemetries = [TelemetryEntity]() + newTelemetries.append(telemetry) + newNode.telemetries? = NSOrderedSet(array: newTelemetries) } - } - fetchedMessage[0].relayNode = Int64(packet.relayNode) - fetchedMessage[0].ackError = Int32(routingMessage.errorReason.rawValue) - if routingMessage.errorReason == Routing.Error.none { - fetchedMessage[0].receivedACK = true - fetchedMessage[0].relays += 1 - } - - fetchedMessage[0].ackSNR = packet.rxSnr - if packet.rxTime > 0 { - fetchedMessage[0].ackTimestamp = Int32(truncatingIfNeeded: packet.rxTime) - } else { - fetchedMessage[0].ackTimestamp = Int32(Date().timeIntervalSince1970) - } - - if fetchedMessage[0].toUser != nil { - fetchedMessage[0].toUser!.objectWillChange.send() - } else { + if nodeInfo.lastHeard > 0 { + newNode.firstHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard))) + newNode.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard))) + } else { + newNode.firstHeard = Date() + newNode.lastHeard = Date() + } + newNode.snr = nodeInfo.snr + if nodeInfo.hasUser { + + let newUser = UserEntity(context: context) + newUser.userId = nodeInfo.num.toHex() + newUser.num = Int64(nodeInfo.num) + newUser.longName = nodeInfo.user.longName + newUser.shortName = nodeInfo.user.shortName + newUser.hwModel = String(describing: nodeInfo.user.hwModel).uppercased() + newUser.hwModelId = Int32(nodeInfo.user.hwModel.rawValue) + Task { + Api().loadDeviceHardwareData { (hw) in + let dh = hw.first(where: { $0.hwModel == newUser.hwModelId }) + newUser.hwDisplayName = dh?.displayName + } + } + newUser.isLicensed = nodeInfo.user.isLicensed + newUser.role = Int32(nodeInfo.user.role.rawValue) + if !nodeInfo.user.publicKey.isEmpty { + newUser.pkiEncrypted = true + newUser.publicKey = nodeInfo.user.publicKey + } + /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default + if nodeInfo.user.hasIsUnmessagable { + newUser.unmessagable = nodeInfo.user.isUnmessagable + } else { + let roles = [2, 4, 5, 6, 7, 10, 11] + let containsRole = roles.contains(Int(newUser.role)) + if containsRole { + newUser.unmessagable = true + } else { + newUser.unmessagable = false + }} + newNode.user = newUser + } else if nodeInfo.num > Constants.minimumNodeNum { + do { + let newUser = try createUser(num: Int64(nodeInfo.num), context: context) + newNode.user = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(nodeInfo.num, privacy: .public) Error: \(message, privacy: .public)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(nodeInfo.num, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + + if (nodeInfo.position.longitudeI != 0 && nodeInfo.position.latitudeI != 0) && (nodeInfo.position.latitudeI != 373346000 && nodeInfo.position.longitudeI != -1220090000) { + let position = PositionEntity(context: context) + position.latest = true + position.seqNo = Int32(nodeInfo.position.seqNumber) + position.latitudeI = nodeInfo.position.latitudeI + position.longitudeI = nodeInfo.position.longitudeI + position.altitude = nodeInfo.position.altitude + position.satsInView = Int32(nodeInfo.position.satsInView) + position.speed = Int32(nodeInfo.position.groundSpeed) + position.heading = Int32(nodeInfo.position.groundTrack) + position.time = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.position.time))) + var newPostions = [PositionEntity]() + newPostions.append(position) + newNode.positions? = NSOrderedSet(array: newPostions) + } + + // Look for a MyInfo let fetchMyInfoRequest = MyInfoEntity.fetchRequest() - fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", connectedNodeNum) + fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(nodeInfo.num)) + do { let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) if fetchedMyInfo.count > 0 { - - for ch in fetchedMyInfo[0].channels!.array as? [ChannelEntity] ?? [] where ch.index == packet.channel { - ch.objectWillChange.send() + newNode.myInfo = fetchedMyInfo[0] + } + do { + if !deferSave { + try context.save() + Logger.data.info("💾 Saved a new Node Info For: \(String(nodeInfo.num), privacy: .public)") + } + return newNode.objectID + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving Core Data NodeInfoEntity: \(nsError, privacy: .public)") + } + } catch { + Logger.data.error("Fetch MyInfo Error") + } + } else if nodeInfo.num > 0 { + + fetchedNode[0].id = Int64(nodeInfo.num) + fetchedNode[0].num = Int64(nodeInfo.num) + fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.lastHeard))) + fetchedNode[0].snr = nodeInfo.snr + fetchedNode[0].channel = Int32(nodeInfo.channel) + fetchedNode[0].favorite = nodeInfo.isFavorite + fetchedNode[0].ignored = nodeInfo.isIgnored + fetchedNode[0].hopsAway = Int32(nodeInfo.hopsAway) + + if nodeInfo.hasUser { + if fetchedNode[0].user == nil { + fetchedNode[0].user = UserEntity(context: context) + } + // Set the public key for a user if it is empty, don't update + if fetchedNode[0].user?.publicKey == nil && !nodeInfo.user.publicKey.isEmpty { + fetchedNode[0].user?.pkiEncrypted = true + fetchedNode[0].user?.publicKey = nodeInfo.user.publicKey + } + fetchedNode[0].user?.userId = nodeInfo.num.toHex() + fetchedNode[0].user?.num = Int64(nodeInfo.num) + fetchedNode[0].user?.numString = String(nodeInfo.num) + fetchedNode[0].user?.longName = nodeInfo.user.longName + fetchedNode[0].user?.shortName = nodeInfo.user.shortName + fetchedNode[0].user?.isLicensed = nodeInfo.user.isLicensed + fetchedNode[0].user?.role = Int32(nodeInfo.user.role.rawValue) + fetchedNode[0].user?.hwModel = String(describing: nodeInfo.user.hwModel).uppercased() + fetchedNode[0].user?.hwModelId = Int32(nodeInfo.user.hwModel.rawValue) + /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default + if nodeInfo.user.hasIsUnmessagable { + fetchedNode[0].user?.unmessagable = nodeInfo.user.isUnmessagable + } else { + let roles = [-1, 2, 4, 5, 6, 7, 10, 11] + let containsRole = roles.contains(Int(fetchedNode[0].user?.role ?? -1)) + if containsRole { + fetchedNode[0].user?.unmessagable = true + } else { + fetchedNode[0].user?.unmessagable = false } } - } catch { } - } - - } else { - return - } - try context.save() - Logger.data.info("💾 ACK Saved for Message: \(packet.decoded.requestID, privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Saving ACK for message: \(packet.id, privacy: .public) Error: \(nsError, privacy: .public)") - } - } -} - -func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManagedObjectContext) { - Task { @MainActor in - if let telemetryMessage = try? Telemetry(serializedBytes: packet.decoded.payload) { - if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) && telemetryMessage.variant != Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { - /// Other unhandled telemetry packets - return - } - let telemetry = TelemetryEntity(context: context) - let fetchNodeTelemetryRequest = NodeInfoEntity.fetchRequest() - fetchNodeTelemetryRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) - do { - let fetchedNode = try context.fetch(fetchNodeTelemetryRequest) - if fetchedNode.count == 1 { - /// Currently only Device Metrics and Environment Telemetry are supported in the app - if telemetryMessage.variant == Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) { - // Device Metrics - Logger.data.info("📈 [Telemetry] Device Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") - telemetry.airUtilTx = telemetryMessage.deviceMetrics.hasAirUtilTx.then(telemetryMessage.deviceMetrics.airUtilTx) - telemetry.channelUtilization = telemetryMessage.deviceMetrics.hasChannelUtilization.then(telemetryMessage.deviceMetrics.channelUtilization) - telemetry.batteryLevel = telemetryMessage.deviceMetrics.hasBatteryLevel.then(Int32(telemetryMessage.deviceMetrics.batteryLevel)) - telemetry.voltage = telemetryMessage.deviceMetrics.hasVoltage.then(telemetryMessage.deviceMetrics.voltage) - telemetry.uptimeSeconds = telemetryMessage.deviceMetrics.hasUptimeSeconds.then(Int32(telemetryMessage.deviceMetrics.uptimeSeconds)) - telemetry.metricsType = 0 - Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.deviceMetrics.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.deviceMetrics.airUtilTx, privacy: .public) for Node: \(packet.from.toHex(), privacy: .public)") - } else if telemetryMessage.variant == Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) { - // Environment Metrics - Logger.data.info("📈 [Telemetry] Environment Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") - telemetry.barometricPressure = telemetryMessage.environmentMetrics.hasBarometricPressure.then(telemetryMessage.environmentMetrics.barometricPressure) - telemetry.iaq = telemetryMessage.environmentMetrics.hasIaq.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq)) - telemetry.gasResistance = telemetryMessage.environmentMetrics.hasGasResistance.then(telemetryMessage.environmentMetrics.gasResistance) - telemetry.relativeHumidity = telemetryMessage.environmentMetrics.hasRelativeHumidity.then(telemetryMessage.environmentMetrics.relativeHumidity) - telemetry.temperature = telemetryMessage.environmentMetrics.hasTemperature.then(telemetryMessage.environmentMetrics.temperature) - telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current) - telemetry.voltage = telemetryMessage.environmentMetrics.hasVoltage.then(telemetryMessage.environmentMetrics.voltage) - telemetry.weight = telemetryMessage.environmentMetrics.hasWeight.then(telemetryMessage.environmentMetrics.weight) - telemetry.distance = telemetryMessage.environmentMetrics.hasDistance.then(telemetryMessage.environmentMetrics.distance) - telemetry.windSpeed = telemetryMessage.environmentMetrics.hasWindSpeed.then(telemetryMessage.environmentMetrics.windSpeed) - telemetry.windGust = telemetryMessage.environmentMetrics.hasWindGust.then(telemetryMessage.environmentMetrics.windGust) - telemetry.windLull = telemetryMessage.environmentMetrics.hasWindLull.then(telemetryMessage.environmentMetrics.windLull) - telemetry.windDirection = telemetryMessage.environmentMetrics.hasWindDirection.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection)) - telemetry.irLux = telemetryMessage.environmentMetrics.hasIrLux.then(telemetryMessage.environmentMetrics.irLux) - telemetry.lux = telemetryMessage.environmentMetrics.hasLux.then(telemetryMessage.environmentMetrics.lux) - telemetry.whiteLux = telemetryMessage.environmentMetrics.hasWhiteLux.then(telemetryMessage.environmentMetrics.whiteLux) - telemetry.uvLux = telemetryMessage.environmentMetrics.hasUvLux.then(telemetryMessage.environmentMetrics.uvLux) - telemetry.radiation = telemetryMessage.environmentMetrics.hasRadiation.then(telemetryMessage.environmentMetrics.radiation) - telemetry.rainfall1H = telemetryMessage.environmentMetrics.hasRainfall1H.then(telemetryMessage.environmentMetrics.rainfall1H) - telemetry.rainfall24H = telemetryMessage.environmentMetrics.hasRainfall24H.then(telemetryMessage.environmentMetrics.rainfall24H) - telemetry.soilTemperature = telemetryMessage.environmentMetrics.hasSoilTemperature.then(telemetryMessage.environmentMetrics.soilTemperature) - telemetry.soilMoisture = telemetryMessage.environmentMetrics.hasSoilMoisture.then(telemetryMessage.environmentMetrics.soilMoisture) - telemetry.metricsType = 1 - } else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { - // Local Stats for Live activity - telemetry.uptimeSeconds = Int32(telemetryMessage.localStats.uptimeSeconds) - telemetry.channelUtilization = telemetryMessage.localStats.channelUtilization - telemetry.airUtilTx = telemetryMessage.localStats.airUtilTx - telemetry.numPacketsTx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsTx) - telemetry.numPacketsRx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRx) - telemetry.numPacketsRxBad = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRxBad) - telemetry.numRxDupe = Int32(truncatingIfNeeded: telemetryMessage.localStats.numRxDupe) - telemetry.numTxRelay = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelay) - telemetry.numTxRelayCanceled = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelayCanceled) - telemetry.numOnlineNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numOnlineNodes) - telemetry.numTotalNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTotalNodes) - telemetry.metricsType = 4 - Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") - } else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { - Logger.data.info("📈 [Telemetry] Power Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") - telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage) - telemetry.powerCh1Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch1Current) - telemetry.powerCh2Voltage = telemetryMessage.powerMetrics.hasCh2Voltage.then(telemetryMessage.powerMetrics.ch2Voltage) - telemetry.powerCh2Current = telemetryMessage.powerMetrics.hasCh2Current.then(telemetryMessage.powerMetrics.ch2Current) - telemetry.powerCh3Voltage = telemetryMessage.powerMetrics.hasCh3Voltage.then(telemetryMessage.powerMetrics.ch3Voltage) - telemetry.powerCh3Current = telemetryMessage.powerMetrics.hasCh3Current.then(telemetryMessage.powerMetrics.ch3Current) - telemetry.metricsType = 2 - } - telemetry.snr = packet.rxSnr - telemetry.rssi = packet.rxRssi - telemetry.time = Date(timeIntervalSince1970: TimeInterval(Int64(truncatingIfNeeded: telemetryMessage.time))) - guard let mutableTelemetries = fetchedNode[0].telemetries!.mutableCopy() as? NSMutableOrderedSet else { - return - } - mutableTelemetries.add(telemetry) - if packet.rxTime > 0 { - fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(packet.rxTime)) - } else { - fetchedNode[0].lastHeard = Date() - } - fetchedNode[0].telemetries = mutableTelemetries.copy() as? NSOrderedSet - } - try context.save() - Logger.data.info("💾 [TelemetryEntity] of type \(MetricsTypes(rawValue: Int(telemetry.metricsType))?.name ?? "Unknown Metrics Type", privacy: .public) Saved for Node: \(packet.from.toHex(), privacy: .public)") - if telemetry.metricsType == 0 { - // Connected Device Metrics - // ------------------------ - // Low Battery notification - if connectedNode == Int64(packet.from) { - let batteryLevel = telemetry.batteryLevel ?? 0 - if UserDefaults.lowBatteryNotifications && batteryLevel > 0 && batteryLevel < 4 { - let manager = LocalNotificationManager() - manager.notifications = [ - Notification( - id: ("notification.id.\(UUID().uuidString)"), - title: "Critically Low Battery!", - subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")", - content: "Time to charge your radio, there is \(telemetry.batteryLevel?.formatted(.number) ?? Constants.nilValueIndicator)% battery remaining.", - target: "nodes", - path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" - ) - ] - manager.schedule() - } - } - } else if telemetry.metricsType == 4 { - // Update our live activity if there is one running, not available on mac -#if !targetEnvironment(macCatalyst) -#if canImport(ActivityKit) - - let fifteenMinutesLater = Calendar.current.date(byAdding: .minute, value: (Int(15) ), to: Date())! - let date = Date.now...fifteenMinutesLater - let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: telemetry.uptimeSeconds.map { UInt32($0) }, - channelUtilization: telemetry.channelUtilization, - airtime: telemetry.airUtilTx, - sentPackets: UInt32(telemetry.numPacketsTx), - receivedPackets: UInt32(telemetry.numPacketsRx), - badReceivedPackets: UInt32(telemetry.numPacketsRxBad), - dupeReceivedPackets: UInt32(telemetry.numRxDupe), - packetsSentRelay: UInt32(telemetry.numTxRelay), - packetsCanceledRelay: UInt32(telemetry.numTxRelayCanceled), - nodesOnline: UInt32(telemetry.numOnlineNodes), - totalNodes: UInt32(telemetry.numTotalNodes), - timerRange: date) - - let alertConfiguration = AlertConfiguration(title: "Mesh activity update", body: "Updated Node Stats Data.", sound: .default) - let updatedContent = ActivityContent(state: updatedMeshStatus, staleDate: nil) - - let meshActivity = Activity.activities.first(where: { $0.attributes.nodeNum == connectedNode }) - if meshActivity != nil { Task { - // await meshActivity?.update(updatedContent, alertConfiguration: alertConfiguration) - await meshActivity?.update(updatedContent) - Logger.services.debug("Updated live activity.") + Api().loadDeviceHardwareData { (hw: [DeviceHardware]) in + guard !hw.isEmpty, + let firstNode = fetchedNode.first, + let user = firstNode.user else { + Logger.data.error("Error: Required DeviceHardware data is missing or array is empty.") + return + } + + let dh = hw.first(where: { $0.hwModel == user.hwModelId }) + + if let deviceHardware = dh { + firstNode.user?.hwDisplayName = deviceHardware.displayName + } else { + Logger.data.error("No matching hardware model found for ID: \(user.hwModelId, privacy: .public)") + } + } } - } -#endif -#endif - } - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 Error Saving Telemetry for Node \(packet.from, privacy: .public) Error: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 Error Fetching NodeInfoEntity for Node \(packet.from.toHex(), privacy: .public)") - } - } -} - -func textMessageAppPacket( - packet: MeshPacket, - wantRangeTestPackets: Bool, - critical: Bool = false, - connectedNode: Int64, - storeForward: Bool = false, - context: NSManagedObjectContext, - appState: AppState? -) { - var messageText = String(bytes: packet.decoded.payload, encoding: .utf8) - let rangeRef = Reference(Int.self) - let rangeTestRegex = Regex { - "seq " - TryCapture(as: rangeRef) { - OneOrMore(.digit) - } transform: { match in - Int(match) - } - } - let rangeTest = messageText?.contains(rangeTestRegex) ?? false && messageText?.starts(with: "seq ") ?? false - - if !wantRangeTestPackets && rangeTest { - return - } - var storeForwardBroadcast = false - if storeForward { - if let storeAndForwardMessage = try? StoreAndForward(serializedBytes: packet.decoded.payload) { - messageText = String(bytes: storeAndForwardMessage.text, encoding: .utf8) - if storeAndForwardMessage.rr == .routerTextBroadcast { - storeForwardBroadcast = true - } - } - } - - if messageText?.count ?? 0 > 0 { - Logger.mesh.info("💬 \("Message received from the text message app.".localized, privacy: .public)") - let messageUsers = UserEntity.fetchRequest() - messageUsers.predicate = NSPredicate(format: "num IN %@", [packet.to, packet.from]) - do { - let fetchedUsers = try context.fetch(messageUsers) - let newMessage = MessageEntity(context: context) - newMessage.messageId = Int64(packet.id) - if packet.rxTime > 0 { - newMessage.messageTimestamp = Int32(bitPattern: packet.rxTime) - } else { - newMessage.messageTimestamp = Int32(Date().timeIntervalSince1970) - } - if packet.relayNode != 0 { - newMessage.relayNode = Int64(packet.relayNode) - } - newMessage.receivedACK = false - newMessage.snr = packet.rxSnr - newMessage.rssi = packet.rxRssi - newMessage.isEmoji = packet.decoded.emoji == 1 - newMessage.channel = Int32(packet.channel) - newMessage.portNum = Int32(packet.decoded.portnum.rawValue) - if packet.decoded.portnum == PortNum.detectionSensorApp { - if !UserDefaults.enableDetectionNotifications { - newMessage.read = true - } - } - if packet.decoded.replyID > 0 { - newMessage.replyID = Int64(packet.decoded.replyID) - } - // Updated logic for handling toUser - if fetchedUsers.first(where: { $0.num == packet.to }) != nil && packet.to != Constants.maximumNodeNum { - if !storeForwardBroadcast { - newMessage.toUser = fetchedUsers.first(where: { $0.num == packet.to }) - } else if storeForwardBroadcast { - // For S&F broadcast messages, treat as a channel message (not a DM) - newMessage.toUser = nil } else { - do { - let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.to), context: context) - newMessage.toUser = newUser - } catch CoreDataError.invalidInput(let message) { - Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.to, privacy: .public) Error: \(message, privacy: .public)") - } catch { - Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.to, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + if fetchedNode[0].user == nil && nodeInfo.num > Constants.minimumNodeNum { + do { + let newUser = try createUser(num: Int64(nodeInfo.num), context: context) + fetchedNode[0].user = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity on an existing node (Invalid Input) from node number: \(nodeInfo.num, privacy: .public) Error: \(message, privacy: .public)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity on an existing node from node number: \(nodeInfo.num, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } } } - } - if fetchedUsers.first(where: { $0.num == packet.from }) != nil { - newMessage.fromUser = fetchedUsers.first(where: { $0.num == packet.from }) - /// Set the public key for the message - if newMessage.fromUser?.pkiEncrypted ?? false && packet.pkiEncrypted { - newMessage.pkiEncrypted = true - newMessage.publicKey = packet.publicKey - } - /// Check for key mismatch - if let nodeKey = newMessage.fromUser?.publicKey { - if newMessage.toUser != nil && packet.pkiEncrypted && !packet.publicKey.isEmpty { - if nodeKey != newMessage.publicKey { - newMessage.fromUser?.keyMatch = false - newMessage.fromUser?.newPublicKey = newMessage.publicKey - let nodeKey = String(nodeKey.base64EncodedString()).prefix(8) - let messageKey = String(newMessage.publicKey?.base64EncodedString() ?? "No Key").prefix(8) - Logger.data.error("🔑 Key mismatch original key: \(nodeKey, privacy: .public) . . . new key: \(messageKey, privacy: .public) . . .") + if nodeInfo.hasDeviceMetrics { + + let newTelemetry = TelemetryEntity(context: context) + newTelemetry.batteryLevel = Int32(nodeInfo.deviceMetrics.batteryLevel) + newTelemetry.voltage = nodeInfo.deviceMetrics.voltage + newTelemetry.channelUtilization = nodeInfo.deviceMetrics.channelUtilization + newTelemetry.airUtilTx = nodeInfo.deviceMetrics.airUtilTx + guard let mutableTelemetries = fetchedNode[0].telemetries!.mutableCopy() as? NSMutableOrderedSet else { + return nil + } + mutableTelemetries.add(newTelemetry) + fetchedNode[0].telemetries = mutableTelemetries.copy() as? NSOrderedSet + } + + if nodeInfo.hasPosition { + + if (nodeInfo.position.longitudeI != 0 && nodeInfo.position.latitudeI != 0) && (nodeInfo.position.latitudeI != 373346000 && nodeInfo.position.longitudeI != -1220090000) { + + let position = PositionEntity(context: context) + position.latitudeI = nodeInfo.position.latitudeI + position.longitudeI = nodeInfo.position.longitudeI + position.altitude = nodeInfo.position.altitude + position.satsInView = Int32(nodeInfo.position.satsInView) + position.time = Date(timeIntervalSince1970: TimeInterval(Int64(nodeInfo.position.time))) + guard let mutablePositions = fetchedNode[0].positions!.mutableCopy() as? NSMutableOrderedSet else { + return nil } + fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet } - } else if packet.pkiEncrypted { - /// We have no key, set it if it is not empty - if !packet.publicKey.isEmpty { - newMessage.fromUser?.pkiEncrypted = true - newMessage.fromUser?.publicKey = packet.publicKey - } + } - } else { - /// 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 - 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)") - } catch { - Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") - } - } - if packet.rxTime > 0 { - newMessage.fromUser?.userNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) - } else { - newMessage.fromUser?.userNode?.lastHeard = Date() - } - newMessage.messagePayload = messageText - newMessage.messagePayloadMarkdown = generateMessageMarkdown(message: messageText!) - if packet.to != Constants.maximumNodeNum && newMessage.fromUser != nil { - newMessage.fromUser?.lastMessage = Date() - } - var messageSaved = false - do { - try context.save() - Logger.data.info("💾 Saved a new message for \(newMessage.messageId, privacy: .public)") - messageSaved = true - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Failed to save new MessageEntity \(nsError, privacy: .public)") - } - // Send notifications if the message saved properly to core data - if messageSaved { - if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications { - return - } - if newMessage.fromUser != nil && newMessage.toUser != nil { - // Set Unread Message Indicators - if packet.to == connectedNode { - let unreadCount = newMessage.toUser?.unreadMessages(context: context, skipLastMessageCheck: true) ?? 0 // skipLastMessageCheck=true because we don't update lastMessage on our own connected node - Task { @MainActor in - appState?.unreadDirectMessages = unreadCount - } - } - if !(newMessage.fromUser?.mute ?? false) && newMessage.isEmoji == false { - // Create an iOS Notification for the received DM message - let manager = LocalNotificationManager() - manager.notifications = [ - Notification( - id: ("notification.id.\(newMessage.messageId)"), - title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)", - subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", - content: messageText!, - target: "messages", - path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)", - messageId: newMessage.messageId, - channel: newMessage.channel, - userNum: Int64(packet.from), - critical: critical - ) - ] - manager.schedule() - Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)") - } - } else if newMessage.fromUser != nil && newMessage.toUser == nil { + + // Look for a MyInfo let fetchMyInfoRequest = MyInfoEntity.fetchRequest() - fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedNode)) + fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(nodeInfo.num)) + do { let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) - if !fetchedMyInfo.isEmpty { - appState?.unreadChannelMessages = fetchedMyInfo[0].unreadMessages(context: context) - for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] { - if channel.index == newMessage.channel { - context.refresh(channel, mergeChanges: true) + if fetchedMyInfo.count > 0 { + fetchedNode[0].myInfo = fetchedMyInfo[0] + } + do { + if !deferSave { + try context.save() + Logger.data.info("💾 [NodeInfo] saved for \(nodeInfo.num.toHex(), privacy: .public)") + } + return fetchedNode[0].objectID + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 Error Saving Core Data NodeInfoEntity: \(nsError, privacy: .public)") + } + } catch { + Logger.data.error("💥 Fetch MyInfo Error") + } + } + } catch { + Logger.data.error("💥 Fetch NodeInfoEntity Error") + } + return nil + } + } + + func adminAppPacket (packet: MeshPacket) async { + let context = self.backgroundContext + await context.perform { + if let adminMessage = try? AdminMessage(serializedBytes: packet.decoded.payload) { + + if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getCannedMessageModuleMessagesResponse(adminMessage.getCannedMessageModuleMessagesResponse) { + + if let cmmc = try? CannedMessageModuleConfig(serializedBytes: packet.decoded.payload) { + let logString = String.localizedStringWithFormat("Canned Messages Messages Received For: %@".localized, packet.from.toHex()) + Logger.mesh.info("🥫 \(logString, privacy: .public)") + + let fetchNodeRequest = NodeInfoEntity.fetchRequest() + fetchNodeRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + let fetchedNode = try context.fetch(fetchNodeRequest) + if fetchedNode.count == 1 { + let messages = String(cmmc.textFormatString()) + .replacingOccurrences(of: "11: ", with: "") + .replacingOccurrences(of: "\"", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: "\n").first ?? "" + fetchedNode[0].cannedMessageConfig?.messages = messages + do { + try context.save() + Logger.data.info("💾 Updated Canned Messages Messages For: \(fetchedNode.first?.num.toHex() ?? "Unknown".localized, privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 Error Saving NodeInfoEntity from POSITION_APP \(nsError, privacy: .public)") } - if channel.index == newMessage.channel && !channel.mute && UserDefaults.channelMessageNotifications && newMessage.isEmoji == false { - // Create an iOS Notification for the received channel message + } + } catch { + Logger.data.error("💥 Error Deserializing ADMIN_APP packet.") + } + } + } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getChannelResponse(adminMessage.getChannelResponse) { + self.channelPacket(channel: adminMessage.getChannelResponse, fromNum: Int64(packet.from), context: context) + } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getDeviceMetadataResponse(adminMessage.getDeviceMetadataResponse) { + self.deviceMetadataPacket(metadata: adminMessage.getDeviceMetadataResponse, fromNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) + } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getConfigResponse(adminMessage.getConfigResponse) { + let config = adminMessage.getConfigResponse + if config.payloadVariant == Config.OneOf_PayloadVariant.bluetooth(config.bluetooth) { + MeshPackets.shared.upsertBluetoothConfigPacket(config: config.bluetooth, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) { + MeshPackets.shared.upsertDeviceConfigPacket(config: config.device, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.display(config.display) { + self.upsertDisplayConfigPacket(config: config.display, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.lora(config.lora) { + self.upsertLoRaConfigPacket(config: config.lora, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.network(config.network) { + self.upsertNetworkConfigPacket(config: config.network, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.position(config.position) { + self.upsertPositionConfigPacket(config: config.position, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.power(config.power) { + self.upsertPowerConfigPacket(config: config.power, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) + } else if config.payloadVariant == Config.OneOf_PayloadVariant.security(config.security) { + self.upsertSecurityConfigPacket(config: config.security, nodeNum: Int64(packet.from), sessionPasskey: adminMessage.sessionPasskey, context: context) + } + } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getModuleConfigResponse(adminMessage.getModuleConfigResponse) { + let moduleConfig = adminMessage.getModuleConfigResponse + if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.ambientLighting(moduleConfig.ambientLighting) { + self.upsertAmbientLightingModuleConfigPacket(config: moduleConfig.ambientLighting, nodeNum: Int64(packet.from), context: context) + } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(moduleConfig.cannedMessage) { + self.upsertCannedMessagesModuleConfigPacket(config: moduleConfig.cannedMessage, nodeNum: Int64(packet.from), context: context) + } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.detectionSensor(moduleConfig.detectionSensor) { + self.upsertDetectionSensorModuleConfigPacket(config: moduleConfig.detectionSensor, nodeNum: Int64(packet.from), context: context) + } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.externalNotification(moduleConfig.externalNotification) { + self.upsertExternalNotificationModuleConfigPacket(config: moduleConfig.externalNotification, nodeNum: Int64(packet.from), context: context) + } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.mqtt(moduleConfig.mqtt) { + self.upsertMqttModuleConfigPacket(config: moduleConfig.mqtt, nodeNum: Int64(packet.from), context: context) + } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.rangeTest(moduleConfig.rangeTest) { + self.upsertRangeTestModuleConfigPacket(config: moduleConfig.rangeTest, nodeNum: Int64(packet.from), context: context) + } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.serial(moduleConfig.serial) { + self.upsertSerialModuleConfigPacket(config: moduleConfig.serial, nodeNum: Int64(packet.from), context: context) + } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.storeForward(moduleConfig.storeForward) { + self.upsertStoreForwardModuleConfigPacket(config: moduleConfig.storeForward, nodeNum: Int64(packet.from), context: context) + } else if moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.telemetry(moduleConfig.telemetry) { + self.upsertTelemetryModuleConfigPacket(config: moduleConfig.telemetry, nodeNum: Int64(packet.from), context: context) + } + } else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.getRingtoneResponse(adminMessage.getRingtoneResponse) { + if let rt = try? RTTTLConfig(serializedBytes: packet.decoded.payload) { + self.upsertRtttlConfigPacket(ringtone: rt.ringtone, nodeNum: Int64(packet.from), context: context) + } + } else { + Logger.mesh.error("🕸️ MESH PACKET received Admin App UNHANDLED \((try? packet.decoded.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + } + // Save an ack for the admin message log for each admin message response received as we stopped sending acks if there is also a response to reduce airtime. + self.adminResponseAck(packet: packet, context: context) + } + } + } + + nonisolated private func adminResponseAck (packet: MeshPacket, context: NSManagedObjectContext) { + let fetchedAdminMessageRequest = MessageEntity.fetchRequest() + fetchedAdminMessageRequest.predicate = NSPredicate(format: "messageId == %lld", packet.decoded.requestID) + do { + let fetchedMessage = try context.fetch(fetchedAdminMessageRequest) + if fetchedMessage.count > 0 { + fetchedMessage[0].ackTimestamp = Int32(Date().timeIntervalSince1970) + fetchedMessage[0].ackError = Int32(RoutingError.none.rawValue) + fetchedMessage[0].receivedACK = true + fetchedMessage[0].realACK = true + fetchedMessage[0].relayNode = Int64(packet.relayNode) + fetchedMessage[0].ackSNR = packet.rxSnr + if fetchedMessage[0].fromUser != nil { + fetchedMessage[0].fromUser?.objectWillChange.send() + } + do { + try context.save() + } catch { + Logger.data.error("Failed to save admin message response as an ack: \(error.localizedDescription, privacy: .public)") + } + } + } catch { + Logger.data.error("Failed to fetch admin message by requestID: \(error.localizedDescription, privacy: .public)") + } + + } + + func paxCounterPacket (packet: MeshPacket) async { + let context = self.backgroundContext + await context.perform { + let logString = String.localizedStringWithFormat("PAX Counter message received from: %@".localized, String(packet.from)) + Logger.mesh.info("🧑‍🤝‍🧑 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + + if let paxMessage = try? Paxcount(serializedBytes: packet.decoded.payload) { + + let newPax = PaxCounterEntity(context: context) + newPax.ble = Int32(truncatingIfNeeded: paxMessage.ble) + newPax.wifi = Int32(truncatingIfNeeded: paxMessage.wifi) + newPax.uptime = Int32(truncatingIfNeeded: paxMessage.uptime) + newPax.time = Date() + + if fetchedNode.count > 0 { + guard let mutablePax = fetchedNode[0].pax!.mutableCopy() as? NSMutableOrderedSet else { + return + } + mutablePax.add(newPax) + fetchedNode[0].pax = mutablePax + do { + try context.save() + } catch { + Logger.data.error("Failed to save pax: \(error.localizedDescription, privacy: .public)") + } + } else { + Logger.data.info("Node Info Not Found") + } + } + } catch { + + } + } + } + + func routingPacket (packet: MeshPacket, connectedNodeNum: Int64) async { + let context = self.backgroundContext + await context.perform { + if let routingMessage = try? Routing(serializedBytes: packet.decoded.payload) { + + let routingError = RoutingError(rawValue: routingMessage.errorReason.rawValue) + + let routingErrorString = routingError?.display ?? "Unknown".localized + let logString = String.localizedStringWithFormat("Routing received for RequestID: %@ Ack Status: %@".localized, String(packet.decoded.requestID), routingErrorString) + Logger.mesh.info("🕸️ \(logString, privacy: .public)") + + let fetchMessageRequest = MessageEntity.fetchRequest() + fetchMessageRequest.predicate = NSPredicate(format: "messageId == %lld", Int64(packet.decoded.requestID)) + + do { + let fetchedMessage = try context.fetch(fetchMessageRequest) + if fetchedMessage.count > 0 { + if fetchedMessage[0].toUser != nil { + // Real ACK from DM Recipient + if packet.to != packet.from { + fetchedMessage[0].realACK = true + } + } + fetchedMessage[0].relayNode = Int64(packet.relayNode) + fetchedMessage[0].ackError = Int32(routingMessage.errorReason.rawValue) + if routingMessage.errorReason == Routing.Error.none { + fetchedMessage[0].receivedACK = true + fetchedMessage[0].relays += 1 + } + + fetchedMessage[0].ackSNR = packet.rxSnr + if packet.rxTime > 0 { + fetchedMessage[0].ackTimestamp = Int32(truncatingIfNeeded: packet.rxTime) + } else { + fetchedMessage[0].ackTimestamp = Int32(Date().timeIntervalSince1970) + } + + if fetchedMessage[0].toUser != nil { + fetchedMessage[0].toUser!.objectWillChange.send() + } else { + let fetchMyInfoRequest = MyInfoEntity.fetchRequest() + fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", connectedNodeNum) + do { + let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) + if fetchedMyInfo.count > 0 { + + for ch in fetchedMyInfo[0].channels!.array as? [ChannelEntity] ?? [] where ch.index == packet.channel { + ch.objectWillChange.send() + } + } + } catch { } + } + + } else { + return + } + try context.save() + Logger.data.info("💾 ACK Saved for Message: \(packet.decoded.requestID, privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving ACK for message: \(packet.id, privacy: .public) Error: \(nsError, privacy: .public)") + } + } + } + } + + func telemetryPacket(packet: MeshPacket, connectedNode: Int64) async { + let context = self.backgroundContext + + await context.perform { + if let telemetryMessage = try? Telemetry(serializedBytes: packet.decoded.payload) { + if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) && telemetryMessage.variant != Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { + /// Other unhandled telemetry packets + return + } + let telemetry = TelemetryEntity(context: context) + let fetchNodeTelemetryRequest = NodeInfoEntity.fetchRequest() + fetchNodeTelemetryRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + do { + let fetchedNode = try context.fetch(fetchNodeTelemetryRequest) + if fetchedNode.count == 1 { + /// Currently only Device Metrics and Environment Telemetry are supported in the app + if telemetryMessage.variant == Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) { + // Device Metrics + Logger.data.info("📈 [Telemetry] Device Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") + telemetry.airUtilTx = telemetryMessage.deviceMetrics.hasAirUtilTx.then(telemetryMessage.deviceMetrics.airUtilTx) + telemetry.channelUtilization = telemetryMessage.deviceMetrics.hasChannelUtilization.then(telemetryMessage.deviceMetrics.channelUtilization) + telemetry.batteryLevel = telemetryMessage.deviceMetrics.hasBatteryLevel.then(Int32(telemetryMessage.deviceMetrics.batteryLevel)) + telemetry.voltage = telemetryMessage.deviceMetrics.hasVoltage.then(telemetryMessage.deviceMetrics.voltage) + telemetry.uptimeSeconds = telemetryMessage.deviceMetrics.hasUptimeSeconds.then(Int32(telemetryMessage.deviceMetrics.uptimeSeconds)) + telemetry.metricsType = 0 + Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.deviceMetrics.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.deviceMetrics.airUtilTx, privacy: .public) for Node: \(packet.from.toHex(), privacy: .public)") + } else if telemetryMessage.variant == Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) { + // Environment Metrics + Logger.data.info("📈 [Telemetry] Environment Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") + telemetry.barometricPressure = telemetryMessage.environmentMetrics.hasBarometricPressure.then(telemetryMessage.environmentMetrics.barometricPressure) + telemetry.iaq = telemetryMessage.environmentMetrics.hasIaq.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq)) + telemetry.gasResistance = telemetryMessage.environmentMetrics.hasGasResistance.then(telemetryMessage.environmentMetrics.gasResistance) + telemetry.relativeHumidity = telemetryMessage.environmentMetrics.hasRelativeHumidity.then(telemetryMessage.environmentMetrics.relativeHumidity) + telemetry.temperature = telemetryMessage.environmentMetrics.hasTemperature.then(telemetryMessage.environmentMetrics.temperature) + telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current) + telemetry.voltage = telemetryMessage.environmentMetrics.hasVoltage.then(telemetryMessage.environmentMetrics.voltage) + telemetry.weight = telemetryMessage.environmentMetrics.hasWeight.then(telemetryMessage.environmentMetrics.weight) + telemetry.distance = telemetryMessage.environmentMetrics.hasDistance.then(telemetryMessage.environmentMetrics.distance) + telemetry.windSpeed = telemetryMessage.environmentMetrics.hasWindSpeed.then(telemetryMessage.environmentMetrics.windSpeed) + telemetry.windGust = telemetryMessage.environmentMetrics.hasWindGust.then(telemetryMessage.environmentMetrics.windGust) + telemetry.windLull = telemetryMessage.environmentMetrics.hasWindLull.then(telemetryMessage.environmentMetrics.windLull) + telemetry.windDirection = telemetryMessage.environmentMetrics.hasWindDirection.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection)) + telemetry.irLux = telemetryMessage.environmentMetrics.hasIrLux.then(telemetryMessage.environmentMetrics.irLux) + telemetry.lux = telemetryMessage.environmentMetrics.hasLux.then(telemetryMessage.environmentMetrics.lux) + telemetry.whiteLux = telemetryMessage.environmentMetrics.hasWhiteLux.then(telemetryMessage.environmentMetrics.whiteLux) + telemetry.uvLux = telemetryMessage.environmentMetrics.hasUvLux.then(telemetryMessage.environmentMetrics.uvLux) + telemetry.radiation = telemetryMessage.environmentMetrics.hasRadiation.then(telemetryMessage.environmentMetrics.radiation) + telemetry.rainfall1H = telemetryMessage.environmentMetrics.hasRainfall1H.then(telemetryMessage.environmentMetrics.rainfall1H) + telemetry.rainfall24H = telemetryMessage.environmentMetrics.hasRainfall24H.then(telemetryMessage.environmentMetrics.rainfall24H) + telemetry.soilTemperature = telemetryMessage.environmentMetrics.hasSoilTemperature.then(telemetryMessage.environmentMetrics.soilTemperature) + telemetry.soilMoisture = telemetryMessage.environmentMetrics.hasSoilMoisture.then(telemetryMessage.environmentMetrics.soilMoisture) + telemetry.metricsType = 1 + } else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { + // Local Stats for Live activity + telemetry.uptimeSeconds = Int32(telemetryMessage.localStats.uptimeSeconds) + telemetry.channelUtilization = telemetryMessage.localStats.channelUtilization + telemetry.airUtilTx = telemetryMessage.localStats.airUtilTx + telemetry.numPacketsTx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsTx) + telemetry.numPacketsRx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRx) + telemetry.numPacketsRxBad = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRxBad) + telemetry.numRxDupe = Int32(truncatingIfNeeded: telemetryMessage.localStats.numRxDupe) + telemetry.numTxRelay = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelay) + telemetry.numTxRelayCanceled = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelayCanceled) + telemetry.numOnlineNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numOnlineNodes) + telemetry.numTotalNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTotalNodes) + telemetry.metricsType = 4 + Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") + } else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { + Logger.data.info("📈 [Telemetry] Power Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") + telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage) + telemetry.powerCh1Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch1Current) + telemetry.powerCh2Voltage = telemetryMessage.powerMetrics.hasCh2Voltage.then(telemetryMessage.powerMetrics.ch2Voltage) + telemetry.powerCh2Current = telemetryMessage.powerMetrics.hasCh2Current.then(telemetryMessage.powerMetrics.ch2Current) + telemetry.powerCh3Voltage = telemetryMessage.powerMetrics.hasCh3Voltage.then(telemetryMessage.powerMetrics.ch3Voltage) + telemetry.powerCh3Current = telemetryMessage.powerMetrics.hasCh3Current.then(telemetryMessage.powerMetrics.ch3Current) + telemetry.metricsType = 2 + } + telemetry.snr = packet.rxSnr + telemetry.rssi = packet.rxRssi + telemetry.time = Date(timeIntervalSince1970: TimeInterval(Int64(truncatingIfNeeded: telemetryMessage.time))) + guard let mutableTelemetries = fetchedNode[0].telemetries!.mutableCopy() as? NSMutableOrderedSet else { + return + } + mutableTelemetries.add(telemetry) + if packet.rxTime > 0 { + fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(packet.rxTime)) + } else { + fetchedNode[0].lastHeard = Date() + } + fetchedNode[0].telemetries = mutableTelemetries.copy() as? NSOrderedSet + } + try context.save() + Logger.data.info("💾 [TelemetryEntity] of type \(MetricsTypes(rawValue: Int(telemetry.metricsType))?.name ?? "Unknown Metrics Type", privacy: .public) Saved for Node: \(packet.from.toHex(), privacy: .public)") + if telemetry.metricsType == 0 { + // Connected Device Metrics + // ------------------------ + // Low Battery notification + if connectedNode == Int64(packet.from) { + let batteryLevel = telemetry.batteryLevel ?? 0 + Task {@MainActor in + if UserDefaults.lowBatteryNotifications && batteryLevel > 0 && batteryLevel < 4 { + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: ("notification.id.\(UUID().uuidString)"), + title: "Critically Low Battery!", + subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")", + content: "Time to charge your radio, there is \(telemetry.batteryLevel?.formatted(.number) ?? Constants.nilValueIndicator)% battery remaining.", + target: "nodes", + path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" + ) + ] + manager.schedule() + } + } + } + } else if telemetry.metricsType == 4 { + // Update our live activity if there is one running, not available on mac +#if !targetEnvironment(macCatalyst) +#if canImport(ActivityKit) + + let fifteenMinutesLater = Calendar.current.date(byAdding: .minute, value: (Int(15) ), to: Date())! + let date = Date.now...fifteenMinutesLater + let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: telemetry.uptimeSeconds.map { UInt32($0) }, + channelUtilization: telemetry.channelUtilization, + airtime: telemetry.airUtilTx, + sentPackets: UInt32(telemetry.numPacketsTx), + receivedPackets: UInt32(telemetry.numPacketsRx), + badReceivedPackets: UInt32(telemetry.numPacketsRxBad), + dupeReceivedPackets: UInt32(telemetry.numRxDupe), + packetsSentRelay: UInt32(telemetry.numTxRelay), + packetsCanceledRelay: UInt32(telemetry.numTxRelayCanceled), + nodesOnline: UInt32(telemetry.numOnlineNodes), + totalNodes: UInt32(telemetry.numTotalNodes), + timerRange: date) + + let alertConfiguration = AlertConfiguration(title: "Mesh activity update", body: "Updated Node Stats Data.", sound: .default) + let updatedContent = ActivityContent(state: updatedMeshStatus, staleDate: nil) + + let meshActivity = Activity.activities.first(where: { $0.attributes.nodeNum == connectedNode }) + if meshActivity != nil { + Task { + // await meshActivity?.update(updatedContent, alertConfiguration: alertConfiguration) + await meshActivity?.update(updatedContent) + Logger.services.debug("Updated live activity.") + } + } +#endif +#endif + } + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 Error Saving Telemetry for Node \(packet.from, privacy: .public) Error: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 Error Fetching NodeInfoEntity for Node \(packet.from.toHex(), privacy: .public)") + } + } + } + + func textMessageAppPacket( + packet: MeshPacket, + wantRangeTestPackets: Bool, + critical: Bool = false, + connectedNode: Int64, + storeForward: Bool = false, + appState: AppState? + ) async { + let context = self.backgroundContext + await context.perform { + var messageText = String(bytes: packet.decoded.payload, encoding: .utf8) + let rangeRef = Reference(Int.self) + let rangeTestRegex = Regex { + "seq " + TryCapture(as: rangeRef) { + OneOrMore(.digit) + } transform: { match in + Int(match) + } + } + let rangeTest = messageText?.contains(rangeTestRegex) ?? false && messageText?.starts(with: "seq ") ?? false + + if !wantRangeTestPackets && rangeTest { + return + } + var storeForwardBroadcast = false + if storeForward { + if let storeAndForwardMessage = try? StoreAndForward(serializedBytes: packet.decoded.payload) { + messageText = String(bytes: storeAndForwardMessage.text, encoding: .utf8) + if storeAndForwardMessage.rr == .routerTextBroadcast { + storeForwardBroadcast = true + } + } + } + + if messageText?.count ?? 0 > 0 { + Logger.mesh.info("💬 \("Message received from the text message app.".localized, privacy: .public)") + let messageUsers = UserEntity.fetchRequest() + messageUsers.predicate = NSPredicate(format: "num IN %@", [packet.to, packet.from]) + do { + let fetchedUsers = try context.fetch(messageUsers) + let newMessage = MessageEntity(context: context) + newMessage.messageId = Int64(packet.id) + if packet.rxTime > 0 { + newMessage.messageTimestamp = Int32(bitPattern: packet.rxTime) + } else { + newMessage.messageTimestamp = Int32(Date().timeIntervalSince1970) + } + if packet.relayNode != 0 { + newMessage.relayNode = Int64(packet.relayNode) + } + newMessage.receivedACK = false + newMessage.snr = packet.rxSnr + newMessage.rssi = packet.rxRssi + newMessage.isEmoji = packet.decoded.emoji == 1 + newMessage.channel = Int32(packet.channel) + newMessage.portNum = Int32(packet.decoded.portnum.rawValue) + if packet.decoded.portnum == PortNum.detectionSensorApp { + if !UserDefaults.enableDetectionNotifications { + newMessage.read = true + } + } + if packet.decoded.replyID > 0 { + newMessage.replyID = Int64(packet.decoded.replyID) + } + // Updated logic for handling toUser + if fetchedUsers.first(where: { $0.num == packet.to }) != nil && packet.to != Constants.maximumNodeNum { + if !storeForwardBroadcast { + newMessage.toUser = fetchedUsers.first(where: { $0.num == packet.to }) + } else if storeForwardBroadcast { + // For S&F broadcast messages, treat as a channel message (not a DM) + newMessage.toUser = nil + } else { + do { + let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.to), context: context) + newMessage.toUser = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.to, privacy: .public) Error: \(message, privacy: .public)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.to, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + } + if fetchedUsers.first(where: { $0.num == packet.from }) != nil { + newMessage.fromUser = fetchedUsers.first(where: { $0.num == packet.from }) + /// Set the public key for the message + if newMessage.fromUser?.pkiEncrypted ?? false && packet.pkiEncrypted { + newMessage.pkiEncrypted = true + newMessage.publicKey = packet.publicKey + } + + /// Check for key mismatch + if let nodeKey = newMessage.fromUser?.publicKey { + if newMessage.toUser != nil && packet.pkiEncrypted && !packet.publicKey.isEmpty { + if nodeKey != newMessage.publicKey { + newMessage.fromUser?.keyMatch = false + newMessage.fromUser?.newPublicKey = newMessage.publicKey + let nodeKey = String(nodeKey.base64EncodedString()).prefix(8) + let messageKey = String(newMessage.publicKey?.base64EncodedString() ?? "No Key").prefix(8) + Logger.data.error("🔑 Key mismatch original key: \(nodeKey, privacy: .public) . . . new key: \(messageKey, privacy: .public) . . .") + } + } + } else if packet.pkiEncrypted { + /// We have no key, set it if it is not empty + if !packet.publicKey.isEmpty { + newMessage.fromUser?.pkiEncrypted = true + newMessage.fromUser?.publicKey = packet.publicKey + } + } + } else { + /// 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 + 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)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + if packet.rxTime > 0 { + newMessage.fromUser?.userNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + } else { + newMessage.fromUser?.userNode?.lastHeard = Date() + } + newMessage.messagePayload = messageText + newMessage.messagePayloadMarkdown = generateMessageMarkdown(message: messageText!) + if packet.to != Constants.maximumNodeNum && newMessage.fromUser != nil { + newMessage.fromUser?.lastMessage = Date() + } + var messageSaved = false + do { + try context.save() + Logger.data.info("💾 Saved a new message for \(newMessage.messageId, privacy: .public)") + messageSaved = true + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Failed to save new MessageEntity \(nsError, privacy: .public)") + } + // Send notifications if the message saved properly to core data + if messageSaved { + if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications { + return + } + if newMessage.fromUser != nil && newMessage.toUser != nil { + // Set Unread Message Indicators + if packet.to == connectedNode { + let unreadCount = newMessage.toUser?.unreadMessages(context: context, skipLastMessageCheck: true) ?? 0 // skipLastMessageCheck=true because we don't update lastMessage on our own connected node + Task { @MainActor in + appState?.unreadDirectMessages = unreadCount + } + } + if !(newMessage.fromUser?.mute ?? false) && newMessage.isEmoji == false { + // Create an iOS Notification for the received DM message + Task {@MainActor in let manager = LocalNotificationManager() manager.notifications = [ Notification( @@ -1097,134 +1105,177 @@ func textMessageAppPacket( subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", content: messageText!, target: "messages", - path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)", + path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)", messageId: newMessage.messageId, channel: newMessage.channel, - userNum: Int64(newMessage.fromUser?.userId ?? "0"), + userNum: Int64(packet.from), critical: critical ) ] manager.schedule() + Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)") } } + } else if newMessage.fromUser != nil && newMessage.toUser == nil { + let fetchMyInfoRequest = MyInfoEntity.fetchRequest() + fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedNode)) + do { + let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) + if !fetchedMyInfo.isEmpty { + Task {@MainActor in + appState?.unreadChannelMessages = fetchedMyInfo[0].unreadMessages(context: context) + for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] { + if channel.index == newMessage.channel { + context.refresh(channel, mergeChanges: true) + } + if channel.index == newMessage.channel && !channel.mute && UserDefaults.channelMessageNotifications && newMessage.isEmoji == false { + // Create an iOS Notification for the received channel message + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: ("notification.id.\(newMessage.messageId)"), + title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)", + subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", + content: messageText!, + target: "messages", + path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.isEmoji ? newMessage.replyID : newMessage.messageId)", + messageId: newMessage.messageId, + channel: newMessage.channel, + userNum: Int64(newMessage.fromUser?.userId ?? "0"), + critical: critical + ) + ] + manager.schedule() + Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)") + } + } + } + } + } catch { + // Handle error + } } - } catch { - // Handle error } + } catch { + Logger.data.error("Fetch Message To and From Users Error") } } - } catch { - Logger.data.error("Fetch Message To and From Users Error") } } -} - -func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Waypoint Packet received from node: %@".localized, String(packet.from)) - Logger.mesh.info("📍 \(logString, privacy: .public)") - - do { - if let waypointMessage = try? Waypoint(serializedBytes: packet.decoded.payload) { - // Fetch waypoint by waypointMessage.id, not packet.id - 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 = "?" - let fetchNodeRequest = NodeInfoEntity.fetchRequest() - fetchNodeRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + func waypointPacket (packet: MeshPacket) async { + let context = self.backgroundContext + await context.perform { + let logString = String.localizedStringWithFormat("Waypoint Packet received from node: %@".localized, String(packet.from)) + Logger.mesh.info("📍 \(logString, privacy: .public)") + do { - let fetchedNode = try context.fetch(fetchNodeRequest) - if let node = fetchedNode.first, let user = node.user { - nodeShortName = user.shortName ?? node.user?.userId ?? String(packet.from.toHex()) - } - } catch { - Logger.data.error("Failed to fetch NodeInfoEntity for node \(packet.from.toHex(), privacy: .public): \(error)") - } - if fetchedWaypoint.isEmpty { - // Create a new waypoint - let waypoint = WaypointEntity(context: context) - waypoint.id = Int64(waypointMessage.id) // Use waypointMessage.id - waypoint.name = waypointMessage.name - waypoint.longDescription = waypointMessage.description_p - waypoint.latitudeI = waypointMessage.latitudeI - waypoint.longitudeI = waypointMessage.longitudeI - waypoint.icon = Int64(waypointMessage.icon) - waypoint.locked = Int64(waypointMessage.lockedTo) - if waypointMessage.expire >= 1 { - waypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) - } else { - waypoint.expire = nil - } - waypoint.created = Date() - do { - try context.save() - Logger.data.info("💾 Added Node Waypoint App Packet For: \(waypoint.id, privacy: .public)") - let manager = LocalNotificationManager() - let icon = String(UnicodeScalar(Int(waypoint.icon)) ?? "📍") - let latitude = Double(waypoint.latitudeI) / 1e7 - let longitude = Double(waypoint.longitudeI) / 1e7 - manager.notifications = [ - Notification( - id: ("notification.id.\(waypoint.id)"), - title: "New Waypoint From \(nodeShortName)", - subtitle: "\(icon) \(waypoint.name ?? "Dropped Pin")", - content: "\(waypoint.longDescription ?? "\(latitude), \(longitude)")", - target: "map", - path: "meshtastic:///map?waypointid=\(waypoint.id)" - ) - ] - Logger.data.debug("meshtastic:///map?waypointid=\(waypoint.id, privacy: .public)") - manager.schedule() - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") - } - } else { - // Update existing waypoint - let existingWaypoint = fetchedWaypoint[0] - if existingWaypoint.locked == 0 || existingWaypoint.locked == packet.from { - let currentTime = Int64(Date().timeIntervalSince1970) - if waypointMessage.expire > 0 && waypointMessage.expire <= currentTime { - context.delete(existingWaypoint) + if let waypointMessage = try? Waypoint(serializedBytes: packet.decoded.payload) { + // 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 = "?" + let fetchNodeRequest = NodeInfoEntity.fetchRequest() + fetchNodeRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + do { + let fetchedNode = try context.fetch(fetchNodeRequest) + if let node = fetchedNode.first, let user = node.user { + nodeShortName = user.shortName ?? node.user?.userId ?? String(packet.from.toHex()) + } + } catch { + Logger.data.error("Failed to fetch NodeInfoEntity for node \(packet.from.toHex(), privacy: .public): \(error)") + } + if fetchedWaypoint.isEmpty { + // Create a new waypoint + let waypoint = WaypointEntity(context: context) + waypoint.id = Int64(waypointMessage.id) // Use waypointMessage.id + waypoint.name = waypointMessage.name + waypoint.longDescription = waypointMessage.description_p + waypoint.latitudeI = waypointMessage.latitudeI + waypoint.longitudeI = waypointMessage.longitudeI + waypoint.icon = Int64(waypointMessage.icon) + waypoint.locked = Int64(waypointMessage.lockedTo) + if waypointMessage.expire >= 1 { + waypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) + } else { + waypoint.expire = nil + } + waypoint.created = Date() do { try context.save() - Logger.data.info("💾 Deleted a waypoint") + Logger.data.info("💾 Added Node Waypoint App Packet For: \(waypoint.id, privacy: .public)") + + Task { @MainActor in + let manager = LocalNotificationManager() + let icon = String(UnicodeScalar(Int(waypoint.icon)) ?? "📍") + let latitude = Double(waypoint.latitudeI) / 1e7 + let longitude = Double(waypoint.longitudeI) / 1e7 + manager.notifications = [ + Notification( + id: ("notification.id.\(waypoint.id)"), + title: "New Waypoint From \(nodeShortName)", + subtitle: "\(icon) \(waypoint.name ?? "Dropped Pin")", + content: "\(waypoint.longDescription ?? "\(latitude), \(longitude)")", + target: "map", + path: "meshtastic:///map?waypointid=\(waypoint.id)" + ) + ] + Logger.data.debug("meshtastic:///map?waypointid=\(waypoint.id, privacy: .public)") + manager.schedule() + } } catch { context.rollback() let nsError = error as NSError Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") } } else { - existingWaypoint.name = waypointMessage.name - existingWaypoint.longDescription = waypointMessage.description_p - existingWaypoint.latitudeI = waypointMessage.latitudeI - existingWaypoint.longitudeI = waypointMessage.longitudeI - existingWaypoint.icon = Int64(waypointMessage.icon) - existingWaypoint.locked = Int64(waypointMessage.lockedTo) - if waypointMessage.expire >= 1 { - existingWaypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) - } else { - existingWaypoint.expire = nil - } - existingWaypoint.lastUpdated = Date() - do { - try context.save() - Logger.data.info("💾 Updated Node Waypoint App Packet For: \(existingWaypoint.id, privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") + // Update existing waypoint + let existingWaypoint = fetchedWaypoint[0] + if existingWaypoint.locked == 0 || existingWaypoint.locked == packet.from { + let currentTime = Int64(Date().timeIntervalSince1970) + if waypointMessage.expire > 0 && waypointMessage.expire <= currentTime { + context.delete(existingWaypoint) + do { + try context.save() + Logger.data.info("💾 Deleted a waypoint") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") + } + } else { + existingWaypoint.name = waypointMessage.name + existingWaypoint.longDescription = waypointMessage.description_p + existingWaypoint.latitudeI = waypointMessage.latitudeI + existingWaypoint.longitudeI = waypointMessage.longitudeI + existingWaypoint.icon = Int64(waypointMessage.icon) + existingWaypoint.locked = Int64(waypointMessage.lockedTo) + if waypointMessage.expire >= 1 { + existingWaypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) + } else { + existingWaypoint.expire = nil + } + existingWaypoint.lastUpdated = Date() + do { + try context.save() + Logger.data.info("💾 Updated Node Waypoint App Packet For: \(existingWaypoint.id, privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") + } + } } } } + } catch { + Logger.mesh.error("Error Deserializing WAYPOINT_APP packet.") } } - } catch { - Logger.mesh.error("Error Deserializing WAYPOINT_APP packet.") } } + diff --git a/Meshtastic/Helpers/TAK/CoTMessage.swift b/Meshtastic/Helpers/TAK/CoTMessage.swift new file mode 100644 index 00000000..12aff014 --- /dev/null +++ b/Meshtastic/Helpers/TAK/CoTMessage.swift @@ -0,0 +1,544 @@ +// +// CoTMessage.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import MeshtasticProtobufs +import CoreLocation + +/// Cursor on Target (CoT) message representation +/// Handles both parsing incoming CoT XML and generating outgoing CoT XML +struct CoTMessage: Identifiable, Sendable { + let id = UUID() + + // MARK: - Core CoT Event Attributes + + /// Unique identifier for this event + var uid: String + + /// CoT type (e.g., "a-f-G-U-C" for friendly ground unit, "b-t-f" for chat) + var type: String + + /// Event generation time + var time: Date + + /// Start of event validity + var start: Date + + /// When this event becomes stale + var stale: Date + + /// How the event was generated (e.g., "m-g" for machine GPS, "h-g-i-g-o" for human generated) + var how: String + + // MARK: - Point Element (Location) + + /// Latitude in degrees + var latitude: Double + + /// Longitude in degrees + var longitude: Double + + /// Height above ellipsoid in meters + var hae: Double + + /// Circular error in meters + var ce: Double + + /// Linear error in meters + var le: Double + + // MARK: - Detail Elements + + /// Contact information (callsign, endpoint) + var contact: CoTContact? + + /// Group/team assignment + var group: CoTGroup? + + /// Device status (battery) + var status: CoTStatus? + + /// Movement track (speed, course) + var track: CoTTrack? + + /// Chat message details + var chat: CoTChat? + + /// Remarks/comments text + var remarks: String? + + /// Raw detail XML content for elements we don't explicitly parse + /// Used to preserve generic CoT elements (colors, shapes, labels, etc.) + var rawDetailXML: String? + + // MARK: - Initialization + + init( + uid: String, + type: String, + time: Date = Date(), + start: Date = Date(), + stale: Date = Date().addingTimeInterval(600), + how: String = "m-g", + latitude: Double = 0, + longitude: Double = 0, + hae: Double = 9999999.0, + ce: Double = 9999999.0, + le: Double = 9999999.0, + contact: CoTContact? = nil, + group: CoTGroup? = nil, + status: CoTStatus? = nil, + track: CoTTrack? = nil, + chat: CoTChat? = nil, + remarks: String? = nil, + rawDetailXML: String? = nil + ) { + self.uid = uid + self.type = type + self.time = time + self.start = start + self.stale = stale + self.how = how + self.latitude = latitude + self.longitude = longitude + self.hae = hae + self.ce = ce + self.le = le + self.contact = contact + self.group = group + self.status = status + self.track = track + self.chat = chat + self.remarks = remarks + self.rawDetailXML = rawDetailXML + } + + // MARK: - Factory Methods + + /// Create a PLI (Position Location Information) message for a friendly ground unit + static func pli( + uid: String, + callsign: String, + latitude: Double, + longitude: Double, + altitude: Double = 9999999.0, + speed: Double = 0, + course: Double = 0, + team: String = "Cyan", + role: String = "Team Member", + battery: Int = 100, + staleMinutes: Int = 10 + ) -> CoTMessage { + let now = Date() + return CoTMessage( + uid: uid, + type: "a-f-G-U-C", + time: now, + start: now, + stale: now.addingTimeInterval(TimeInterval(staleMinutes * 60)), + how: "m-g", + latitude: latitude, + longitude: longitude, + hae: altitude, + ce: 9999999.0, + le: 9999999.0, + contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"), + group: CoTGroup(name: team, role: role), + status: CoTStatus(battery: battery), + track: CoTTrack(speed: speed, course: course) + ) + } + + /// Create a chat message (b-t-f type for outgoing) + static func chat( + senderUid: String, + senderCallsign: String, + message: String, + chatroom: String = "All Chat Rooms" + ) -> CoTMessage { + let now = Date() + let messageId = UUID().uuidString + return CoTMessage( + uid: "GeoChat.\(senderUid).\(chatroom).\(messageId)", + type: "b-t-f", + time: now, + start: now, + stale: now.addingTimeInterval(86400), + how: "h-g-i-g-o", + latitude: 0, + longitude: 0, + hae: 9999999.0, + ce: 9999999.0, + le: 9999999.0, + chat: CoTChat( + message: message, + senderCallsign: senderCallsign, + chatroom: chatroom + ), + remarks: message + ) + } + + // MARK: - Create from Meshtastic TAKPacket + + /// Convert Meshtastic TAKPacket protobuf to CoT message + static func fromTAKPacket(_ takPacket: TAKPacket, deviceUid: String? = nil) -> CoTMessage? { + let currentDate = Date() + let staleDate = currentDate.addingTimeInterval(10 * 60) // 10 minute stale + + // Handle PLI (Position Location Information) + if case .pli(let pli) = takPacket.payloadVariant { + // Validate we have required fields + guard takPacket.hasContact, + pli.latitudeI != 0 || pli.longitudeI != 0 else { + return nil + } + + // Parse device_callsign in case it contains smuggled messageId (shouldn't for PLI, but be safe) + let (actualDeviceCallsign, _) = TAKMeshtasticBridge.parseDeviceCallsign(takPacket.contact.deviceCallsign) + let uid = actualDeviceCallsign.isEmpty + ? (deviceUid ?? UUID().uuidString) + : actualDeviceCallsign + + return CoTMessage( + uid: uid, + type: "a-f-G-U-C", + time: currentDate, + start: currentDate, + stale: staleDate, + how: "m-g", + latitude: Double(pli.latitudeI) * 1e-7, + longitude: Double(pli.longitudeI) * 1e-7, + hae: Double(pli.altitude), + ce: 9999999.0, + le: 9999999.0, + contact: CoTContact( + callsign: takPacket.contact.callsign, + endpoint: "0.0.0.0:4242:tcp" + ), + group: takPacket.hasGroup ? CoTGroup( + name: takPacket.group.team.cotColorName, + role: takPacket.group.role.cotRoleName + ) : CoTGroup(name: "Cyan", role: "Team Member"), + status: takPacket.hasStatus ? CoTStatus( + battery: Int(takPacket.status.battery) + ) : nil, + track: CoTTrack( + speed: Double(pli.speed), + course: Double(pli.course) + ) + ) + } + + // Handle GeoChat + if case .chat(let geoChat) = takPacket.payloadVariant { + // Parse device_callsign which may contain smuggled messageId + // Format: "|" or just "" + let rawDeviceCallsign = takPacket.hasContact ? takPacket.contact.deviceCallsign : "" + let (actualDeviceCallsign, smuggledMessageId) = TAKMeshtasticBridge.parseDeviceCallsign(rawDeviceCallsign) + + let uid = actualDeviceCallsign.isEmpty + ? (deviceUid ?? UUID().uuidString) + : actualDeviceCallsign + + let chatroom = geoChat.hasTo ? geoChat.to : "All Chat Rooms" + // Use smuggled messageId if present, otherwise generate new one + let messageId = smuggledMessageId ?? UUID().uuidString + + return CoTMessage( + uid: "GeoChat.\(uid).\(chatroom).\(messageId)", + type: "b-t-f", + time: currentDate, + start: currentDate, + stale: currentDate.addingTimeInterval(86400), + how: "h-g-i-g-o", + latitude: 0, + longitude: 0, + hae: 9999999.0, + ce: 9999999.0, + le: 9999999.0, + contact: takPacket.hasContact ? CoTContact( + callsign: takPacket.contact.callsign, + endpoint: "0.0.0.0:4242:tcp" + ) : nil, + chat: CoTChat( + message: geoChat.message, + senderCallsign: takPacket.hasContact ? takPacket.contact.callsign : nil, + chatroom: chatroom + ), + remarks: geoChat.message + ) + } + + return nil + } + + // MARK: - XML Generation + + /// Generate CoT XML string for transmission to TAK clients + func toXML() -> String { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + var cot = "" + cot += "= 3 { + // Expected GeoChat format: GeoChat.. + senderUid = String(components[1]) + messageId = String(components[2]) + } else { + // Malformed GeoChat UID; fall back safely + senderUid = uid + messageId = uid + } + } else { + // Non-GeoChat UID; use uid as both sender and stable message identifier + senderUid = uid + messageId = uid + } + cot += "<__chat parent='RootContactGroup' groupOwner='false' " + cot += "messageId='\(messageId)' " + cot += "chatroom='\(chat.chatroom.xmlEscaped)' id='\(chat.chatroom.xmlEscaped)' " + cot += "senderCallsign='\(chat.senderCallsign?.xmlEscaped ?? "")'>" + cot += "", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } +} + +// MARK: - Team/Role Extensions for Meshtastic Protobufs + +extension Team { + /// Convert Meshtastic Team enum to CoT color name + var cotColorName: String { + switch self { + 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 .unspecifedColor: return "Cyan" + case .UNRECOGNIZED: return "Cyan" + } + } + + /// Create Team from CoT color name + static func fromColorName(_ name: String) -> Team { + switch name.lowercased() { + 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 "dark blue", "darkblue": return .darkBlue + case "blue": return .blue + case "cyan": return .cyan + case "teal": return .teal + case "green": return .green + case "dark green", "darkgreen": return .darkGreen + case "brown": return .brown + default: return .cyan + } + } +} + +extension MemberRole { + /// Convert Meshtastic MemberRole enum to CoT role name + var cotRoleName: String { + switch self { + 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 .unspecifed: return "Team Member" + case .UNRECOGNIZED: return "Team Member" + } + } + + /// Create MemberRole from CoT role name + static func fromRoleName(_ name: String) -> MemberRole { + switch name.lowercased() { + case "team member": return .teamMember + case "team lead": return .teamLead + case "hq", "headquarters": return .hq + case "sniper": return .sniper + case "medic": return .medic + case "forward observer": return .forwardObserver + case "rto": return .rto + case "k9": return .k9 + default: return .teamMember + } + } +} + +// MARK: - XML Parsing + +extension CoTMessage { + /// Parse a CoT XML string into a CoTMessage + /// - Parameter xml: The CoT XML string + /// - Returns: Parsed CoTMessage, or nil if parsing failed + static func parse(from xml: String) -> CoTMessage? { + guard let data = xml.data(using: .utf8) else { + return nil + } + + // Use the existing CoTXMLParser class + let parser = CoTXMLParser(data: data) + do { + return try parser.parse() + } catch { + return nil + } + } +} diff --git a/Meshtastic/Helpers/TAK/CoTXMLParser.swift b/Meshtastic/Helpers/TAK/CoTXMLParser.swift new file mode 100644 index 00000000..7f9325e2 --- /dev/null +++ b/Meshtastic/Helpers/TAK/CoTXMLParser.swift @@ -0,0 +1,335 @@ +// +// CoTXMLParser.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import OSLog + +/// XML Parser delegate for parsing incoming CoT (Cursor on Target) messages from TAK clients +final class CoTXMLParser: NSObject, XMLParserDelegate { + private let data: Data + private var cotMessage: CoTMessage? + private var parseError: Error? + + // Current parsing state + private var currentElement = "" + private var currentText = "" + + // Temporary attribute storage during parsing + private var eventAttributes: [String: String] = [:] + private var pointAttributes: [String: String] = [:] + private var contactAttributes: [String: String] = [:] + private var groupAttributes: [String: String] = [:] + private var statusAttributes: [String: String] = [:] + private var trackAttributes: [String: String] = [:] + private var chatAttributes: [String: String] = [:] + private var chatgrpAttributes: [String: String] = [:] + private var remarksAttributes: [String: String] = [:] + private var remarksText = "" + private var linkAttributes: [String: String] = [:] + + // Track element hierarchy for nested elements + private var elementStack: [String] = [] + + // Raw detail XML for unrecognized elements (markers, shapes, colors, etc.) + private var rawDetailXML = "" + private var isCapturingRawDetail = false + private var rawDetailDepth = 0 + + // Known detail elements we handle explicitly + private let knownDetailElements: Set = [ + "contact", "__group", "status", "track", "__chat", "chatgrp", + "remarks", "link", "uid", "__serverdestination" + ] + + init(data: Data) { + self.data = data + } + + /// Parse the XML data and return a CoTMessage + func parse() throws -> CoTMessage { + let parser = XMLParser(data: data) + parser.delegate = self + parser.shouldProcessNamespaces = false + parser.shouldReportNamespacePrefixes = false + + guard parser.parse() else { + if let error = parseError { + throw error + } + throw CoTParseError.parseFailed(parser.parserError?.localizedDescription ?? "Unknown error") + } + + guard let message = cotMessage else { + throw CoTParseError.invalidMessage + } + + return message + } + + // MARK: - XMLParserDelegate + + func parser(_ parser: XMLParser, didStartElement elementName: String, + namespaceURI: String?, qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:]) { + elementStack.append(elementName) + currentElement = elementName + currentText = "" + + // Check if we're inside and this is an unrecognized element + let isInsideDetail = elementStack.contains("detail") && elementName != "detail" + + if isCapturingRawDetail { + // Continue capturing nested elements + rawDetailDepth += 1 + rawDetailXML += buildOpeningTag(elementName, attributes: attributeDict) + } else if isInsideDetail && !knownDetailElements.contains(elementName) { + // Start capturing this unrecognized element + isCapturingRawDetail = true + rawDetailDepth = 1 + rawDetailXML += buildOpeningTag(elementName, attributes: attributeDict) + } + + switch elementName { + case "event": + eventAttributes = attributeDict + case "point": + pointAttributes = attributeDict + case "contact": + contactAttributes = attributeDict + case "__group": + groupAttributes = attributeDict + case "status": + statusAttributes = attributeDict + case "track": + trackAttributes = attributeDict + case "__chat": + chatAttributes = attributeDict + case "chatgrp": + chatgrpAttributes = attributeDict + case "remarks": + remarksAttributes = attributeDict + case "link": + linkAttributes = attributeDict + default: + break + } + } + + /// Build an XML opening tag with attributes + private func buildOpeningTag(_ elementName: String, attributes: [String: String]) -> String { + var tag = "<\(elementName)" + for (key, value) in attributes { + tag += " \(key)='\(value.xmlEscaped)'" + } + tag += ">" + return tag + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + currentText += string + + // Capture text content for raw detail elements + if isCapturingRawDetail { + rawDetailXML += string.xmlEscaped + } + } + + func parser(_ parser: XMLParser, didEndElement elementName: String, + namespaceURI: String?, qualifiedName qName: String?) { + if elementName == "remarks" { + remarksText = currentText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Handle raw detail element closing + if isCapturingRawDetail { + rawDetailXML += "" + rawDetailDepth -= 1 + if rawDetailDepth == 0 { + isCapturingRawDetail = false + } + } + + if elementName == "event" { + buildCoTMessage() + } + + elementStack.removeLast() + currentElement = elementStack.last ?? "" + } + + func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { + self.parseError = parseError + Logger.tak.error("CoT XML parse error: \(parseError.localizedDescription)") + } + + // MARK: - Build CoTMessage + + private func buildCoTMessage() { + Logger.tak.debug("=== Building CoTMessage from XML ===") + Logger.tak.debug("Event attributes: \(self.eventAttributes)") + Logger.tak.debug("Point attributes: \(self.pointAttributes)") + Logger.tak.debug("Contact attributes: \(self.contactAttributes)") + Logger.tak.debug("Group attributes: \(self.groupAttributes)") + Logger.tak.debug("Status attributes: \(self.statusAttributes)") + Logger.tak.debug("Track attributes: \(self.trackAttributes)") + Logger.tak.debug("Chat attributes: \(self.chatAttributes)") + Logger.tak.debug("Remarks text: \(self.remarksText)") + + // Parse timestamps + let time = parseDate(eventAttributes["time"]) + let start = parseDate(eventAttributes["start"]) + let stale = parseDate(eventAttributes["stale"]) + + // Build contact if present + var contact: CoTContact? + if !contactAttributes.isEmpty { + contact = CoTContact( + callsign: contactAttributes["callsign"] ?? "", + endpoint: contactAttributes["endpoint"], + phone: contactAttributes["phone"] + ) + Logger.tak.debug("Parsed contact: callsign=\(contact?.callsign ?? "nil")") + } + + // Build group if present + var group: CoTGroup? + if !groupAttributes.isEmpty { + group = CoTGroup( + name: groupAttributes["name"] ?? "Cyan", + role: groupAttributes["role"] ?? "Team Member" + ) + Logger.tak.debug("Parsed group: name=\(group?.name ?? "nil"), role=\(group?.role ?? "nil")") + } + + // Build status if present + var status: CoTStatus? + if let batteryStr = statusAttributes["battery"], let battery = Int(batteryStr) { + status = CoTStatus(battery: battery) + Logger.tak.debug("Parsed status: battery=\(battery)") + } + + // Build track if present + var track: CoTTrack? + if !trackAttributes.isEmpty { + let speed = Double(trackAttributes["speed"] ?? "0") ?? 0 + let course = Double(trackAttributes["course"] ?? "0") ?? 0 + track = CoTTrack(speed: speed, course: course) + Logger.tak.debug("Parsed track: speed=\(speed), course=\(course)") + } + + // Build chat if present + var chat: CoTChat? + if !chatAttributes.isEmpty { + chat = CoTChat( + message: remarksText, + senderCallsign: chatAttributes["senderCallsign"], + chatroom: chatAttributes["chatroom"] ?? chatAttributes["id"] ?? "All Chat Rooms" + ) + Logger.tak.debug("Parsed chat: message=\(self.remarksText.prefix(50)), chatroom=\(chat?.chatroom ?? "nil")") + } + + let uid = eventAttributes["uid"] ?? UUID().uuidString + let type = eventAttributes["type"] ?? "a-f-G-U-C" + let latitude = Double(pointAttributes["lat"] ?? "0") ?? 0 + let longitude = Double(pointAttributes["lon"] ?? "0") ?? 0 + let hae = Double(pointAttributes["hae"] ?? "9999999") ?? 9999999 + + Logger.tak.debug("Building CoTMessage: uid=\(uid), type=\(type)") + Logger.tak.debug(" location: lat=\(latitude), lon=\(longitude), hae=\(hae)") + + cotMessage = CoTMessage( + uid: uid, + type: type, + time: time, + start: start, + stale: stale, + how: eventAttributes["how"] ?? "m-g", + latitude: latitude, + longitude: longitude, + hae: hae, + ce: Double(pointAttributes["ce"] ?? "9999999") ?? 9999999, + le: Double(pointAttributes["le"] ?? "9999999") ?? 9999999, + contact: contact, + group: group, + status: status, + track: track, + chat: chat, + remarks: chat == nil && !remarksText.isEmpty ? remarksText : nil, + rawDetailXML: rawDetailXML.isEmpty ? nil : rawDetailXML + ) + + if !rawDetailXML.isEmpty { + Logger.tak.debug("Captured raw detail XML: \(self.rawDetailXML.prefix(200))...") + } + + Logger.tak.debug("=== CoTMessage built successfully ===") + } + + // MARK: - Date Parsing + + private func parseDate(_ string: String?) -> Date { + guard let string else { return Date() } + + // Try ISO8601 with fractional seconds first + let formatterWithFractional = ISO8601DateFormatter() + formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatterWithFractional.date(from: string) { + return date + } + + // Try ISO8601 without fractional seconds + let formatterWithoutFractional = ISO8601DateFormatter() + formatterWithoutFractional.formatOptions = [.withInternetDateTime] + if let date = formatterWithoutFractional.date(from: string) { + return date + } + + // Try basic date formatter + let basicFormatter = DateFormatter() + basicFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + basicFormatter.timeZone = TimeZone(identifier: "UTC") + if let date = basicFormatter.date(from: string) { + return date + } + + Logger.tak.warning("Failed to parse CoT date: \(string)") + return Date() + } +} + +// MARK: - Parse Error + +enum CoTParseError: LocalizedError { + case parseFailed(String) + case invalidMessage + case emptyData + + var errorDescription: String? { + switch self { + case .parseFailed(let reason): + return "Failed to parse CoT XML: \(reason)" + case .invalidMessage: + return "Invalid CoT message structure" + case .emptyData: + return "Empty data received" + } + } +} + +// MARK: - CoTMessage Parsing Extension + +extension CoTMessage { + /// Parse CoT XML data into a CoTMessage (throwing version) + static func parseData(_ data: Data) throws -> CoTMessage { + guard !data.isEmpty else { + throw CoTParseError.emptyData + } + + let parser = CoTXMLParser(data: data) + return try parser.parse() + } +} diff --git a/Meshtastic/Helpers/TAK/EXICodec.swift b/Meshtastic/Helpers/TAK/EXICodec.swift new file mode 100644 index 00000000..e1881e08 --- /dev/null +++ b/Meshtastic/Helpers/TAK/EXICodec.swift @@ -0,0 +1,148 @@ +// +// EXICodec.swift +// Meshtastic +// +// Zlib compression for CoT events over mesh network. +// Uses standard zlib format (78 xx header) for Android interoperability. +// +// IMPORTANT: Uses C zlib library directly to produce standard zlib format. +// Apple's Compression framework produces raw deflate which is NOT compatible +// with Android's standard zlib decompressor. +// +// Zlib header bytes: +// - 78 01: No compression +// - 78 9C: Default compression (what we use) +// - 78 DA: Best compression +// + +import Foundation +import zlib +import OSLog + +/// Codec for compressing/decompressing CoT XML using standard zlib +/// Named EXICodec for historical reasons - now uses zlib for Android compatibility +final class EXICodec { + + static let shared = EXICodec() + + private init() {} + + // MARK: - Compression + + /// Compress CoT XML to binary format using zlib + /// - Parameter xml: The CoT XML string + /// - Returns: Compressed data (78 9C header), or nil if compression failed + func compress(_ xml: String) -> Data? { + guard let xmlData = xml.data(using: .utf8) else { + Logger.tak.error("Zlib: Failed to convert XML to UTF-8 data") + return nil + } + + // Use standard zlib compression (produces 78 9C header that Android expects) + guard let compressed = compressZlib(xmlData) else { + Logger.tak.warning("Zlib: Compression failed, using raw data") + return xmlData + } + + let ratio = Double(compressed.count) / Double(xmlData.count) * 100 + Logger.tak.info("Zlib: Compressed \(xmlData.count) → \(compressed.count) bytes (\(String(format: "%.1f", ratio))%)") + + // Log first few bytes to verify format (should start with 78 9C) + if compressed.count >= 2 { + Logger.tak.debug("Zlib: Header: \(String(format: "%02X %02X", compressed[0], compressed[1]))") + } + + return compressed + } + + /// Decompress zlib data to CoT XML + /// - Parameter data: The compressed data (expects 78 xx header) + /// - Returns: Decompressed XML string, or nil if decompression failed + func decompress(_ data: Data) -> String? { + // Log header for debugging + if data.count >= 2 { + Logger.tak.debug("Zlib: Decompressing data with header: \(String(format: "%02X %02X", data[0], data[1]))") + } + + // Try standard zlib decompression (78 xx header) + if let decompressed = decompressZlib(data) { + if let xml = String(data: decompressed, encoding: .utf8) { + Logger.tak.debug("Zlib: Decompressed \(data.count) → \(decompressed.count) bytes") + return xml + } + } + + // Fallback: try interpreting as raw UTF-8 (uncompressed) + if let xml = String(data: data, encoding: .utf8) { + Logger.tak.debug("Zlib: Data was uncompressed UTF-8 (\(data.count) bytes)") + return xml + } + + Logger.tak.error("Zlib: Failed to decompress data (\(data.count) bytes)") + return nil + } + + // MARK: - Zlib Implementation + + /// Compress data using standard zlib format (78 9C header) + /// Uses C zlib library directly for Android compatibility + private func compressZlib(_ data: Data) -> Data? { + // Calculate maximum compressed size + var compressedLength = compressBound(uLong(data.count)) + var compressed = Data(count: Int(compressedLength)) + + let result = compressed.withUnsafeMutableBytes { destPtr in + data.withUnsafeBytes { srcPtr in + compress2( + destPtr.bindMemory(to: Bytef.self).baseAddress!, + &compressedLength, + srcPtr.bindMemory(to: Bytef.self).baseAddress!, + uLong(data.count), + Z_DEFAULT_COMPRESSION + ) + } + } + + guard result == Z_OK else { + Logger.tak.error("Zlib: compress2 failed with code \(result)") + return nil + } + + return compressed.prefix(Int(compressedLength)) + } + + /// Decompress standard zlib data (78 xx header) + private func decompressZlib(_ data: Data) -> Data? { + // Estimate uncompressed size (start with 10x, will retry if needed) + var uncompressedLength = uLong(data.count * 10) + var maxAttempts = 3 + + while maxAttempts > 0 { + var uncompressed = Data(count: Int(uncompressedLength)) + + let result = uncompressed.withUnsafeMutableBytes { destPtr in + data.withUnsafeBytes { srcPtr in + uncompress( + destPtr.bindMemory(to: Bytef.self).baseAddress!, + &uncompressedLength, + srcPtr.bindMemory(to: Bytef.self).baseAddress!, + uLong(data.count) + ) + } + } + + if result == Z_OK { + return uncompressed.prefix(Int(uncompressedLength)) + } else if result == Z_BUF_ERROR { + // Buffer too small, try larger + uncompressedLength *= 2 + maxAttempts -= 1 + } else { + Logger.tak.debug("Zlib: uncompress failed with code \(result)") + return nil + } + } + + return nil + } +} diff --git a/Meshtastic/Helpers/TAK/FountainCodec.swift b/Meshtastic/Helpers/TAK/FountainCodec.swift new file mode 100644 index 00000000..1a8317fe --- /dev/null +++ b/Meshtastic/Helpers/TAK/FountainCodec.swift @@ -0,0 +1,630 @@ +// +// FountainCodec.swift +// Meshtastic +// +// Fountain code (LT codes) implementation for reliable transfer over lossy mesh networks +// Based on the ATAK Meshtastic plugin protocol +// + +import Foundation +import CryptoKit +import OSLog + +// MARK: - Constants + +enum FountainConstants { + /// Magic bytes identifying fountain packets: "FTN" + static let magic: [UInt8] = [0x46, 0x54, 0x4E] + + /// Maximum payload size per block + static let blockSize = 220 + + /// Header size for data blocks + static let dataHeaderSize = 11 + + /// Size threshold for fountain coding (below this, send directly) + static let fountainThreshold = 233 + + /// Transfer type: CoT event + static let transferTypeCot: UInt8 = 0x00 + + /// Transfer type: File transfer + static let transferTypeFile: UInt8 = 0x01 + + /// ACK type: Transfer complete + static let ackTypeComplete: UInt8 = 0x02 + + /// ACK type: Need more blocks + static let ackTypeNeedMore: UInt8 = 0x03 + + /// ACK packet size + static let ackPacketSize = 19 +} + +// MARK: - Fountain Packet Types + +/// A received fountain block with its metadata +struct FountainBlock { + let seed: UInt16 + var indices: Set + var payload: Data + + func copy() -> FountainBlock { + return FountainBlock(seed: seed, indices: indices, payload: payload) + } +} + +/// State for receiving a fountain-coded transfer +class FountainReceiveState { + let transferId: UInt32 + // swiftlint:disable:next identifier_name + let K: Int + let totalLength: Int + var blocks: [FountainBlock] = [] + let createdAt: Date + + // swiftlint:disable:next identifier_name + init(transferId: UInt32, K: Int, totalLength: Int) { + self.transferId = transferId + self.K = K + self.totalLength = totalLength + self.createdAt = Date() + } + + func addBlock(_ block: FountainBlock) { + // Don't add duplicate seeds + if !blocks.contains(where: { $0.seed == block.seed }) { + blocks.append(block) + } + } + + var isExpired: Bool { + // Expire after 60 seconds + return Date().timeIntervalSince(createdAt) > 60 + } +} + +/// Parsed fountain data block header +struct FountainDataHeader { + let transferId: UInt32 // 24-bit, stored in lower 24 bits + let seed: UInt16 + // swiftlint:disable:next identifier_name + let K: UInt8 + let totalLength: UInt16 +} + +/// Parsed fountain ACK packet +struct FountainAck { + let transferId: UInt32 + let type: UInt8 + let received: UInt16 + let needed: UInt16 + let dataHash: Data +} + +// MARK: - Java-Compatible Random Number Generator + +/// Java's java.util.Random implementation (Linear Congruential Generator) +/// CRITICAL: Must match Java exactly for Android interoperability +struct JavaRandom { + private var seed: Int64 + + init(seed: Int64) { + // Java's Random constructor: (seed ^ 0x5DEECE66DL) & ((1L << 48) - 1) + self.seed = (seed ^ 0x5DEECE66D) & ((Int64(1) << 48) - 1) + } + + /// Generate next random bits (Java's protected next(int bits) method) + mutating func next(bits: Int) -> Int32 { + // seed = (seed * 0x5DEECE66DL + 0xBL) & ((1L << 48) - 1) + seed = (seed &* 0x5DEECE66D &+ 0xB) & ((Int64(1) << 48) - 1) + return Int32(truncatingIfNeeded: seed >> (48 - bits)) + } + + /// Generate random int in [0, bound) - matches Java's nextInt(int bound) + mutating func nextInt(bound: Int) -> Int { + guard bound > 0 else { return 0 } + + // Power of 2 optimization + if (bound & -bound) == bound { + return Int((Int64(bound) &* Int64(next(bits: 31))) >> 31) + } + + // Rejection sampling to avoid modulo bias + var bits: Int32 + var val: Int + repeat { + bits = next(bits: 31) + val = Int(bits) % bound + } while bits - Int32(val) + Int32(bound - 1) < 0 + + return val + } + + /// Generate random double in [0.0, 1.0) - matches Java's nextDouble() + mutating func nextDouble() -> Double { + let high = Int64(next(bits: 26)) + let low = Int64(next(bits: 27)) + return Double((high << 27) + low) / Double(Int64(1) << 53) + } +} + +// MARK: - Fountain Codec + +/// Encoder and decoder for fountain-coded transfers +final class FountainCodec { + + static let shared = FountainCodec() + + private var receiveStates: [UInt32: FountainReceiveState] = [:] + + private init() {} + + // MARK: - Transfer ID Generation + + /// Generate a unique random 24-bit transfer ID + /// CRITICAL: Must be random to avoid collisions with recent transfers + func generateTransferId() -> UInt32 { + let random = UInt32.random(in: 0...0xFFFFFF) + let time = UInt32(Date().timeIntervalSince1970) & 0xFFFF + return (random ^ time) & 0xFFFFFF + } + + // MARK: - Encoding + + /// Encode data into fountain-coded blocks + /// - Parameters: + /// - data: The data to encode (should include transfer type prefix) + /// - transferId: Unique transfer ID for this transmission + /// - Returns: Array of encoded block packets ready for transmission + func encode(data: Data, transferId: UInt32) -> [Data] { + // Guard against empty data + guard !data.isEmpty else { + Logger.tak.warning("Fountain encode: empty data") + return [] + } + // swiftlint:disable:next identifier_name + let K = max(1, Int(ceil(Double(data.count) / Double(FountainConstants.blockSize)))) + let overhead = getAdaptiveOverhead(K) + let blocksToSend = max(1, Int(ceil(Double(K) * (1.0 + overhead)))) + + // Split into source blocks (pad last block with zeros) + let sourceBlocks = splitIntoBlocks(data: data, K: K) + + // Debug: Log source block hashes to verify they're different + for (i, block) in sourceBlocks.enumerated() { + let hash = block.prefix(8).map { String(format: "%02X", $0) }.joined() + Logger.tak.debug("Fountain sourceBlock[\(i)]: first 8 bytes = \(hash)") + } + + var packets: [Data] = [] + + for i in 0.. [Data] { + var blocks: [Data] = [] + for i in 0.. Data { + var packet = Data() + + // Magic bytes + packet.append(contentsOf: FountainConstants.magic) + + // Transfer ID (24-bit, big-endian) + packet.append(UInt8((transferId >> 16) & 0xFF)) + packet.append(UInt8((transferId >> 8) & 0xFF)) + packet.append(UInt8(transferId & 0xFF)) + + // Seed (16-bit, big-endian) + packet.append(UInt8((seed >> 8) & 0xFF)) + packet.append(UInt8(seed & 0xFF)) + + // K (number of source blocks) + packet.append(K) + + // Total length (16-bit, big-endian) + packet.append(UInt8((totalLength >> 8) & 0xFF)) + packet.append(UInt8(totalLength & 0xFF)) + + // Payload + packet.append(payload) + + return packet + } + + // MARK: - Decoding + + /// Check if data is a fountain packet + static func isFountainPacket(_ data: Data) -> Bool { + guard data.count >= 3 else { return false } + return data[0] == FountainConstants.magic[0] + && data[1] == FountainConstants.magic[1] + && data[2] == FountainConstants.magic[2] + } + + /// Parse a fountain data block header + func parseDataHeader(_ data: Data) -> FountainDataHeader? { + guard data.count >= FountainConstants.dataHeaderSize else { return nil } + guard Self.isFountainPacket(data) else { return nil } + + let transferId = (UInt32(data[3]) << 16) | (UInt32(data[4]) << 8) | UInt32(data[5]) + let seed = (UInt16(data[6]) << 8) | UInt16(data[7]) + // swiftlint:disable:next identifier_name + let K = data[8] + let totalLength = (UInt16(data[9]) << 8) | UInt16(data[10]) + + return FountainDataHeader(transferId: transferId, seed: seed, K: K, totalLength: totalLength) + } + + /// Handle an incoming fountain packet + /// - Parameters: + /// - data: The raw packet data + /// - senderNodeId: ID of the sending node + /// - Returns: Decoded data if transfer is complete, nil otherwise + func handleIncomingPacket(_ data: Data, senderNodeId: UInt32) -> (data: Data, transferId: UInt32)? { + // Clean up expired states + cleanupExpiredStates() + + guard let header = parseDataHeader(data) else { + Logger.tak.warning("Invalid fountain packet header") + return nil + } + + let payload = data.dropFirst(FountainConstants.dataHeaderSize) + guard payload.count == FountainConstants.blockSize else { + Logger.tak.warning("Invalid fountain payload size: \(payload.count)") + return nil + } + + // Get or create receive state + let state: FountainReceiveState + if let existing = receiveStates[header.transferId] { + state = existing + } else { + state = FountainReceiveState( + transferId: header.transferId, + K: Int(header.K), + totalLength: Int(header.totalLength) + ) + receiveStates[header.transferId] = state + Logger.tak.debug("New fountain transfer: id=\(header.transferId), K=\(header.K), len=\(header.totalLength)") + } + + // Regenerate source indices from seed + let indices = regenerateIndices(seed: header.seed, K: state.K, transferId: header.transferId) + + // Add block + let block = FountainBlock(seed: header.seed, indices: indices, payload: Data(payload)) + state.addBlock(block) + + Logger.tak.debug("Fountain block received: xferId=\(header.transferId), seed=\(header.seed), blocks=\(state.blocks.count)/\(state.K)") + + // Try to decode if we have enough blocks + if state.blocks.count >= state.K { + if let decoded = peelingDecode(state) { + // Remove completed state + receiveStates.removeValue(forKey: header.transferId) + Logger.tak.info("Fountain decode complete: \(decoded.count) bytes from \(state.blocks.count) blocks") + return (decoded, header.transferId) + } + } + + return nil + } + + /// Build an ACK packet + func buildAck(transferId: UInt32, type: UInt8, received: UInt16, needed: UInt16, dataHash: Data) -> Data { + var packet = Data() + + // Magic bytes + packet.append(contentsOf: FountainConstants.magic) + + // Transfer ID (24-bit, big-endian) + packet.append(UInt8((transferId >> 16) & 0xFF)) + packet.append(UInt8((transferId >> 8) & 0xFF)) + packet.append(UInt8(transferId & 0xFF)) + + // Type + packet.append(type) + + // Received (16-bit, big-endian) + packet.append(UInt8((received >> 8) & 0xFF)) + packet.append(UInt8(received & 0xFF)) + + // Needed (16-bit, big-endian) + packet.append(UInt8((needed >> 8) & 0xFF)) + packet.append(UInt8(needed & 0xFF)) + + // Data hash (8 bytes) + packet.append(dataHash.prefix(8)) + + return packet + } + + /// Parse an ACK packet + func parseAck(_ data: Data) -> FountainAck? { + guard data.count >= FountainConstants.ackPacketSize else { return nil } + guard Self.isFountainPacket(data) else { return nil } + + let transferId = (UInt32(data[3]) << 16) | (UInt32(data[4]) << 8) | UInt32(data[5]) + let type = data[6] + let received = (UInt16(data[7]) << 8) | UInt16(data[8]) + let needed = (UInt16(data[9]) << 8) | UInt16(data[10]) + let dataHash = Data(data[11..<19]) + + return FountainAck(transferId: transferId, type: type, received: received, needed: needed, dataHash: dataHash) + } + + // MARK: - Peeling Decoder + + /// Decode using the peeling algorithm + private func peelingDecode(_ state: FountainReceiveState) -> Data? { + var decoded: [Int: Data] = [:] + var workingBlocks = state.blocks.map { $0.copy() } + + var progress = true + while progress && decoded.count < state.K { + progress = false + + for i in 0..= state.K else { + Logger.tak.debug("Peeling decode incomplete: \(decoded.count)/\(state.K) blocks decoded") + return nil + } + + // Reassemble original data + var result = Data() + for i in 0.. Double { + if K <= 10 { return 0.50 } // 50% for very small + else if K <= 50 { return 0.25 } // 25% for small + else { return 0.15 } // 15% for larger + } + + /// Generate deterministic seed from transfer ID and block index + private func generateSeed(transferId: UInt32, blockIndex: Int) -> UInt16 { + let combined = Int(transferId) * 31337 + blockIndex * 7919 + return UInt16(combined & 0xFFFF) + } + + /// Generate indices for encoding a block + /// CRITICAL: Must match Android's exact algorithm for interoperability + /// Android uses Java's java.util.Random (LCG) with specific block 0 handling + // swiftlint:disable:next identifier_name + private func generateBlockIndices(seed: UInt16, K: Int, blockIndex: Int) -> Set { + var rng = JavaRandom(seed: Int64(seed)) + + // ALWAYS sample degree first (advances RNG state) - matches Android + let sampledDegree = sampleRobustSolitonDegree(&rng, K: K) + + // For block 0: ignore sampled degree, use degree=1 instead + // For other blocks: use the sampled degree + // This matches Android's isFirstBlock logic + let degree = (blockIndex == 0) ? 1 : sampledDegree + + // Select indices with RNG now advanced past degree sampling + return selectIndices(&rng, K: K, degree: degree) + } + + /// Regenerate source indices from seed (must match sender's algorithm) + /// CRITICAL: Must use same RNG flow as generateBlockIndices for Android interop + // swiftlint:disable:next identifier_name + private func regenerateIndices(seed: UInt16, K: Int, transferId: UInt32) -> Set { + var rng = JavaRandom(seed: Int64(seed)) + + // ALWAYS sample degree first (advances RNG state) - matches Android + let sampledDegree = sampleRobustSolitonDegree(&rng, K: K) + + // Check if this is block 0 (forced degree=1) + let expectedSeed0 = generateSeed(transferId: transferId, blockIndex: 0) + let degree = (seed == expectedSeed0) ? 1 : sampledDegree + + // Select indices with RNG now advanced past degree sampling + return selectIndices(&rng, K: K, degree: degree) + } + + /// Select source block indices using provided RNG + /// Matches Android's selectIndices algorithm exactly + // swiftlint:disable:next identifier_name + private func selectIndices(_ rng: inout JavaRandom, K: Int, degree: Int) -> Set { + var indices = Set() + + // Select 'degree' unique indices + while indices.count < degree && indices.count < K { + let idx = rng.nextInt(bound: K) + indices.insert(idx) + } + + return indices + } + + /// Sample degree from Robust Soliton distribution using provided RNG + /// Matches Android's sampleDegree algorithm exactly + // swiftlint:disable:next identifier_name + // swiftlint:disable:next identifier_name + private func sampleRobustSolitonDegree(_ rng: inout JavaRandom, K: Int) -> Int { + let cdf = buildRobustSolitonCDF(K: K) + let u = rng.nextDouble() + + for d in 1...K { + if u <= cdf[d] { + return d + } + } + return K + } + + /// Build CDF for Robust Soliton distribution + // swiftlint:disable:next identifier_name + private func buildRobustSolitonCDF(K: Int, c: Double = 0.1, delta: Double = 0.5) -> [Double] { + // Guard against K <= 0 + guard K > 0 else { + return [1.0] // Single element CDF + } + + // Ideal Soliton distribution + var rho = [Double](repeating: 0, count: K + 1) + rho[1] = 1.0 / Double(K) + for d in 2...K { + rho[d] = 1.0 / (Double(d) * Double(d - 1)) + } + + // Robust Soliton addition (tau) + // swiftlint:disable:next identifier_name + let R = c * log(Double(K) / delta) * sqrt(Double(K)) + var tau = [Double](repeating: 0, count: K + 1) + let threshold = Int(Double(K) / R) + + for d in 1...K { + if d < threshold { + tau[d] = R / (Double(d) * Double(K)) + } else if d == threshold { + tau[d] = R * log(R / delta) / Double(K) + } + } + + // Combine and normalize + var mu = [Double](repeating: 0, count: K + 1) + var sum = 0.0 + for d in 1...K { + mu[d] = rho[d] + tau[d] + sum += mu[d] + } + + // Build CDF + var cdf = [Double](repeating: 0, count: K + 1) + var cumulative = 0.0 + for d in 1...K { + cumulative += mu[d] / sum + cdf[d] = cumulative + } + + return cdf + } + + /// XOR two data blocks + private func xor(_ a: Data, _ b: Data) -> Data { + // IMPORTANT: Rebase inputs to ensure 0-based indices + // Data slices keep original indices which causes crashes when accessing [i] + let aData = a.startIndex == 0 ? a : Data(a) + let bData = b.startIndex == 0 ? b : Data(b) + + var result = Data(count: max(aData.count, bData.count)) + for i in 0.. Data { + let digest = SHA256.hash(data: data) + return Data(digest.prefix(8)) + } + + /// Clean up expired receive states + private func cleanupExpiredStates() { + let expiredIds = receiveStates.filter { $0.value.isExpired }.map { $0.key } + for id in expiredIds { + receiveStates.removeValue(forKey: id) + Logger.tak.debug("Cleaned up expired fountain state: \(id)") + } + } +} diff --git a/Meshtastic/Helpers/TAK/GenericCoTHandler.swift b/Meshtastic/Helpers/TAK/GenericCoTHandler.swift new file mode 100644 index 00000000..6ed357fd --- /dev/null +++ b/Meshtastic/Helpers/TAK/GenericCoTHandler.swift @@ -0,0 +1,399 @@ +// +// GenericCoTHandler.swift +// Meshtastic +// +// Handles generic CoT events that don't map to TAKPacket protobuf +// Uses EXI compression and Fountain codes for reliable transfer +// + +import Foundation +import MeshtasticProtobufs +import OSLog + +/// Port numbers for TAK communication +enum TAKPortNum: UInt32 { + /// TAKPacket protobuf (PLI, GeoChat) - small, structured messages + case atakPlugin = 72 + + /// EXI-compressed CoT XML - generic/large messages, fountain coded + case atakForwarder = 257 +} + +/// Handler for generic CoT events over the mesh network +@MainActor +final class GenericCoTHandler { + + static let shared = GenericCoTHandler() + + weak var accessoryManager: AccessoryManager? + + /// Pending outgoing fountain transfers awaiting ACK + private var pendingTransfers: [UInt32: PendingTransfer] = [:] + + private init() {} + + // MARK: - Outgoing CoT Classification + + /// Determine how a CoT message should be sent + enum CoTSendMethod { + /// Use TAKPacket.pli on ATAK_PLUGIN port + case takPacketPLI + /// Use TAKPacket.chat on ATAK_PLUGIN port + case takPacketChat + /// Use EXI compression on ATAK_FORWARDER port (small, no fountain) + case exiDirect + /// Use EXI + Fountain coding on ATAK_FORWARDER port (large) + case exiFountain + } + + /// Classify a CoT message to determine send method + func classifySendMethod(for cot: CoTMessage) -> CoTSendMethod { + // Self PLI (position) + if cot.type.hasPrefix("a-f-G") || cot.type.hasPrefix("a-f-g") { + return .takPacketPLI + } + + // GeoChat + if cot.type == "b-t-f" { + return .takPacketChat + } + + // Everything else goes through EXI/Forwarder + // Check compressed size to determine if fountain coding needed + let xml = cot.toXML() + if let compressed = EXICodec.shared.compress(xml) { + // +1 for transfer type byte + if compressed.count + 1 < FountainConstants.fountainThreshold { + return .exiDirect + } else { + return .exiFountain + } + } + + // Fallback to direct (compression failed, use raw) + return .exiDirect + } + + // MARK: - Sending Generic CoT + + /// Send a generic CoT event (markers, shapes, routes, etc.) + /// - Parameters: + /// - cot: The CoT message to send + /// - channel: Meshtastic channel (0 = primary) + func sendGenericCoT(_ cot: CoTMessage, channel: UInt32 = 0) async throws { + guard let accessoryManager else { + throw GenericCoTError.notConnected + } + + guard accessoryManager.isConnected else { + throw GenericCoTError.notConnected + } + + // Compress to EXI + let xml = cot.toXML() + guard let exiData = EXICodec.shared.compress(xml) else { + throw GenericCoTError.compressionFailed + } + + // Prepend transfer type + var payload = Data([FountainConstants.transferTypeCot]) + payload.append(exiData) + + Logger.tak.debug("Generic CoT: type=\(cot.type), xml=\(xml.count)B, compressed=\(payload.count)B") + + // Check if small enough to send directly + if payload.count < FountainConstants.fountainThreshold { + try await sendDirect(payload, channel: channel) + } else { + try await sendFountainCoded(payload, channel: channel) + } + } + + /// Send small payload directly (no fountain coding) + private func sendDirect(_ payload: Data, channel: UInt32) async throws { + guard let accessoryManager, let activeConnection = accessoryManager.activeConnection else { + throw GenericCoTError.notConnected + } + + guard let deviceNum = activeConnection.device.num else { + throw GenericCoTError.noDeviceNumber + } + + var dataMessage = DataMessage() + dataMessage.portnum = .atakForwarder // Port 257 + dataMessage.payload = payload + + var meshPacket = MeshPacket() + meshPacket.to = 0xFFFFFFFF // Broadcast + meshPacket.from = UInt32(deviceNum) + meshPacket.channel = channel + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. CoTMessage? { + guard case let .decoded(data) = packet.payloadVariant else { + Logger.tak.warning("ATAK_FORWARDER packet without decoded payload") + return nil + } + + let payload = data.payload + guard !payload.isEmpty else { + Logger.tak.warning("Empty ATAK_FORWARDER payload") + return nil + } + + // Check if this is a fountain packet (starts with "FTN" magic) + if FountainCodec.isFountainPacket(payload) { + // Distinguish between ACK (19 bytes) and data block (231 bytes) + // ACK: magic(3) + transferId(3) + type(1) + received(2) + needed(2) + hash(8) = 19 + // Data: magic(3) + transferId(3) + seed(2) + K(1) + totalLen(2) + payload(220) = 231 + if payload.count == FountainConstants.ackPacketSize { + // This is a fountain ACK - handle it and return nil (no CoT to forward) + handleIncomingAck(payload, from: packet.from) + return nil + } + return handleFountainPacket(payload, from: packet.from) + } + + // Direct packet (not fountain coded) + return handleDirectPacket(payload, from: packet.from) + } + + /// Handle direct (non-fountain) packet + private func handleDirectPacket(_ payload: Data, from nodeNum: UInt32) -> CoTMessage? { + guard payload.count > 1 else { + Logger.tak.warning("Direct packet too short: \(payload.count) bytes") + return nil + } + + let transferType = payload[0] + let exiData = payload.dropFirst() + + guard transferType == FountainConstants.transferTypeCot else { + Logger.tak.debug("Ignoring non-CoT transfer type: \(transferType)") + return nil + } + + // Decompress EXI to XML + guard let xml = EXICodec.shared.decompress(Data(exiData)) else { + Logger.tak.warning("Failed to decompress EXI data from node \(nodeNum)") + return nil + } + + // Parse CoT XML + guard let cot = CoTMessage.parse(from: xml) else { + Logger.tak.warning("Failed to parse CoT XML from node \(nodeNum)") + return nil + } + + Logger.tak.info("Received generic CoT from node \(nodeNum): \(cot.type)") + return cot + } + + /// Handle fountain-coded packet + private func handleFountainPacket(_ payload: Data, from nodeNum: UInt32) -> CoTMessage? { + // Pass to fountain codec + guard let (decodedData, transferId) = FountainCodec.shared.handleIncomingPacket(payload, senderNodeId: nodeNum) else { + // Not yet complete, waiting for more blocks + return nil + } + + // Transfer complete - send ACK (twice for redundancy) + let hash = FountainCodec.computeHash(decodedData) + Task { + await sendFountainAck(transferId: transferId, hash: hash, to: nodeNum) + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms delay + await sendFountainAck(transferId: transferId, hash: hash, to: nodeNum) + } + + // Extract transfer type and data + guard decodedData.count > 1 else { + Logger.tak.warning("Decoded fountain data too short") + return nil + } + + let transferType = decodedData[0] + let exiData = decodedData.dropFirst() + + guard transferType == FountainConstants.transferTypeCot else { + Logger.tak.debug("Ignoring non-CoT fountain transfer type: \(transferType)") + return nil + } + + // Decompress EXI to XML + guard let xml = EXICodec.shared.decompress(Data(exiData)) else { + Logger.tak.warning("Failed to decompress fountain EXI data") + return nil + } + + // Parse CoT XML + guard let cot = CoTMessage.parse(from: xml) else { + Logger.tak.warning("Failed to parse fountain CoT XML") + return nil + } + + Logger.tak.info("Received fountain-coded CoT from node \(nodeNum): \(cot.type)") + return cot + } + + /// Send fountain ACK + private func sendFountainAck(transferId: UInt32, hash: Data, to nodeNum: UInt32) async { + guard let accessoryManager, let activeConnection = accessoryManager.activeConnection else { + return + } + + guard let deviceNum = activeConnection.device.num else { + return + } + + let ackPacket = FountainCodec.shared.buildAck( + transferId: transferId, + type: FountainConstants.ackTypeComplete, + received: 0, + needed: 0, + dataHash: hash + ) + + var dataMessage = DataMessage() + dataMessage.portnum = .atakForwarder + dataMessage.payload = ackPacket + + var meshPacket = MeshPacket() + meshPacket.to = nodeNum + meshPacket.from = UInt32(deviceNum) + meshPacket.id = UInt32.random(in: UInt32(UInt8.max).. Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag, + kSecReturnRef as String: true + ] + var item: CFTypeRef? + return SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess + } + + /// Get the bundled CA certificate data for sharing to TAK + func getBundledCACertificateData() -> Data? { + let pemURL = Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "ca", withExtension: "pem") + + guard let url = pemURL, let pemData = try? Data(contentsOf: url) else { + return nil + } + return pemData + } + + /// Get URL to bundled CA certificate for sharing + func getBundledCACertificateURL() -> URL? { + return Bundle.main.url(forResource: "ca", withExtension: "pem", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "ca", withExtension: "pem") + } + + /// Get the bundled server P12 data for sharing to TAK (used as truststore) + func getBundledServerP12Data() -> Data? { + let p12URL = Bundle.main.url(forResource: "server", withExtension: "p12", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "server", withExtension: "p12") + + guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else { + return nil + } + return p12Data + } + + /// Get the password for bundled certificates (for data package) + func getBundledCertificatePassword() -> String { + return bundledPassword + } + + /// Get the bundled client P12 data for sharing to TAK (for mutual TLS) + func getBundledClientP12Data() -> Data? { + let p12URL = Bundle.main.url(forResource: "client", withExtension: "p12", subdirectory: "Certificates") + ?? Bundle.main.url(forResource: "client", withExtension: "p12") + + guard let url = p12URL, let p12Data = try? Data(contentsOf: url) else { + return nil + } + return p12Data + } + + /// Check if a bundled client certificate exists + func hasBundledClientCertificate() -> Bool { + return getBundledClientP12Data() != nil + } + + // MARK: - Active Certificate Data (for Data Package) + + /// Get the active server P12 data (custom if available, otherwise bundled) + /// Used for generating data packages + func getActiveServerP12Data() -> Data? { + // Check for custom certificate first + if hasCustomServerCertificate(), + let customData = getCustomServerP12DataFromKeychain() { + Logger.tak.debug("Using custom server P12 for data package") + return customData + } + // Fall back to bundled + Logger.tak.debug("Using bundled server P12 for data package") + return getBundledServerP12Data() + } + + /// Get the active client P12 data (custom if available, otherwise bundled) + /// Used for generating data packages + func getActiveClientP12Data() -> Data? { + // Check for custom certificate first + if let customData = getCustomClientP12DataFromKeychain() { + Logger.tak.debug("Using custom client P12 for data package") + return customData + } + // Fall back to bundled + Logger.tak.debug("Using bundled client P12 for data package") + return getBundledClientP12Data() + } + + /// Get the password for the active server certificate + func getActiveServerCertificatePassword() -> String { + if hasCustomServerCertificate(), + let customPassword = getCustomServerP12PasswordFromKeychain() { + return customPassword + } + return bundledPassword + } + + /// Get the password for the active client certificate + func getActiveClientCertificatePassword() -> String { + if let customPassword = getCustomClientP12PasswordFromKeychain() { + return customPassword + } + return bundledPassword + } + + /// Import a custom client P12 certificate (for data package generation) + func importCustomClientP12(data: Data, password: String) { + storeCustomClientP12InKeychain(p12Data: data, password: password) + Logger.tak.info("Custom client P12 imported for data package") + } + + /// Check if custom client P12 is available + func hasCustomClientP12() -> Bool { + return getCustomClientP12DataFromKeychain() != nil + } + + /// Clear custom certificate data (called when resetting to defaults) + private func clearCustomCertificateData() { + // Clear server P12 from Keychain + deleteCustomServerP12FromKeychain() + + // Clear client P12 from Keychain + deleteCustomClientP12FromKeychain() + + Logger.tak.debug("Cleared custom certificate data") + } + + // MARK: - Server Identity (PKCS#12) + + /// Import server identity from PKCS#12 (.p12) file data + /// - Parameters: + /// - p12Data: The raw PKCS#12 file data + /// - password: Password to decrypt the PKCS#12 file + /// - isCustom: Whether this is a user-imported custom certificate (default: true) + /// - Returns: The imported SecIdentity + func importServerIdentity(from p12Data: Data, password: String, isCustom: Bool = true) throws -> SecIdentity { + let options: [String: Any] = [kSecImportExportPassphrase as String: password] + var items: CFArray? + + let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &items) + + guard status == errSecSuccess else { + Logger.tak.error("Failed to import PKCS#12: \(status)") + throw TAKCertificateError.importFailed(status) + } + + guard let itemArray = items as? [[String: Any]], + let firstItem = itemArray.first, + let identity = firstItem[kSecImportItemIdentity as String] as! SecIdentity? else { // swiftlint:disable:this force_cast + throw TAKCertificateError.noIdentityFound + } + + // Store in Keychain for persistence + try storeServerIdentity(identity, isCustom: isCustom) + + // Store the raw P12 data and password for data package generation (only for custom certs) + if isCustom { + storeCustomServerP12InKeychain(p12Data: p12Data, password: password) + Logger.tak.debug("Stored custom server P12 data for data package generation in Keychain") + } + + Logger.tak.info("Server identity imported successfully (custom: \(isCustom))") + return identity + } + + /// Store custom server PKCS#12 data and its password in the Keychain + private func storeCustomServerP12InKeychain(p12Data: Data, password: String) { + let service = "com.meshtastic.tak" + + // Helper to upsert a generic password item + func upsertKeychainItem(account: String, value: Data) -> OSStatus { + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + kSecValueData as String: value + ] + + return SecItemAdd(addQuery as CFDictionary, nil) + } + + let dataStatus = upsertKeychainItem(account: customServerP12DataKey, value: p12Data) + if dataStatus != errSecSuccess { + Logger.tak.error("Failed to store custom server P12 data in Keychain: \(dataStatus)") + } + + if let passwordData = password.data(using: .utf8) { + let passwordStatus = upsertKeychainItem(account: customServerP12PasswordKey, value: passwordData) + if passwordStatus != errSecSuccess { + Logger.tak.error("Failed to store custom server P12 password in Keychain: \(passwordStatus)") + } + } else { + Logger.tak.error("Failed to encode custom server P12 password as UTF-8 data") + } + } + + /// Retrieve custom server P12 data from Keychain + private func getCustomServerP12DataFromKeychain() -> Data? { + let service = "com.meshtastic.tak" + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: customServerP12DataKey, + kSecReturnData as String: true + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, let data = item as? Data else { + return nil + } + + return data + } + + /// Retrieve custom server P12 password from Keychain + private func getCustomServerP12PasswordFromKeychain() -> String? { + let service = "com.meshtastic.tak" + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: customServerP12PasswordKey, + kSecReturnData as String: true + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, + let data = item as? Data, + let password = String(data: data, encoding: .utf8) else { + return nil + } + + return password + } + + /// Delete custom server P12 data from Keychain + private func deleteCustomServerP12FromKeychain() { + let service = "com.meshtastic.tak" + + let dataQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: customServerP12DataKey + ] + SecItemDelete(dataQuery as CFDictionary) + + let passwordQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: customServerP12PasswordKey + ] + SecItemDelete(passwordQuery as CFDictionary) + } + + /// Store custom client PKCS#12 data and its password in the Keychain + private func storeCustomClientP12InKeychain(p12Data: Data, password: String) { + let service = "com.meshtastic.tak" + + // Helper to upsert a generic password item + func upsertKeychainItem(account: String, value: Data) -> OSStatus { + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + kSecValueData as String: value + ] + + return SecItemAdd(addQuery as CFDictionary, nil) + } + + let dataStatus = upsertKeychainItem(account: customClientP12DataKey, value: p12Data) + if dataStatus != errSecSuccess { + Logger.tak.error("Failed to store custom client P12 data in Keychain: \(dataStatus)") + } + + if let passwordData = password.data(using: .utf8) { + let passwordStatus = upsertKeychainItem(account: customClientP12PasswordKey, value: passwordData) + if passwordStatus != errSecSuccess { + Logger.tak.error("Failed to store custom client P12 password in Keychain: \(passwordStatus)") + } + } else { + Logger.tak.error("Failed to encode custom client P12 password as UTF-8 data") + } + } + + /// Retrieve custom client P12 data from Keychain + private func getCustomClientP12DataFromKeychain() -> Data? { + let service = "com.meshtastic.tak" + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: customClientP12DataKey, + kSecReturnData as String: true + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, let data = item as? Data else { + return nil + } + + return data + } + + /// Retrieve custom client P12 password from Keychain + private func getCustomClientP12PasswordFromKeychain() -> String? { + let service = "com.meshtastic.tak" + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: customClientP12PasswordKey, + kSecReturnData as String: true + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + guard status == errSecSuccess, + let data = item as? Data, + let password = String(data: data, encoding: .utf8) else { + return nil + } + + return password + } + + /// Delete custom client P12 data from Keychain + private func deleteCustomClientP12FromKeychain() { + let service = "com.meshtastic.tak" + + let dataQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: customClientP12DataKey + ] + SecItemDelete(dataQuery as CFDictionary) + + let passwordQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: customClientP12PasswordKey + ] + SecItemDelete(passwordQuery as CFDictionary) + } + /// Store server identity in Keychain + private func storeServerIdentity(_ identity: SecIdentity, isCustom: Bool = true) throws { + let tag = isCustom ? serverIdentityCustomTag : serverIdentityTag + + // First delete any existing identity with this tag + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: tag + ] + SecItemDelete(deleteQuery as CFDictionary) + + // If storing custom cert, also delete the bundled one (custom takes precedence) + if isCustom { + let deleteBundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag + ] + SecItemDelete(deleteBundledQuery as CFDictionary) + } + + // Add new identity + let addQuery: [String: Any] = [ + kSecValueRef as String: identity, + kSecAttrLabel as String: tag, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + Logger.tak.error("Failed to store server identity in Keychain: \(status)") + throw TAKCertificateError.keychainError(status) + } + } + + /// Retrieve stored server identity from Keychain + /// Custom certificates take precedence over bundled ones + func getServerIdentity() -> SecIdentity? { + // First try custom certificate + let customQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag, + kSecReturnRef as String: true + ] + + var item: CFTypeRef? + var status = SecItemCopyMatching(customQuery as CFDictionary, &item) + + if status == errSecSuccess { + return (item as! SecIdentity) // swiftlint:disable:this force_cast + } + + // Fall back to bundled certificate + let bundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag, + kSecReturnRef as String: true + ] + + status = SecItemCopyMatching(bundledQuery as CFDictionary, &item) + + guard status == errSecSuccess else { + if status != errSecItemNotFound { + Logger.tak.warning("Failed to retrieve server identity: \(status)") + } + return nil + } + + return (item as! SecIdentity) // swiftlint:disable:this force_cast + } + + /// Check if server certificate is configured + func hasServerCertificate() -> Bool { + return getServerIdentity() != nil + } + + /// Delete custom server identity and reload bundled default + func deleteServerIdentity() { + // Delete custom certificate + let customQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag + ] + let customStatus = SecItemDelete(customQuery as CFDictionary) + + // Delete bundled certificate too + let bundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag + ] + let bundledStatus = SecItemDelete(bundledQuery as CFDictionary) + + if customStatus == errSecSuccess || bundledStatus == errSecSuccess { + Logger.tak.info("Server identity deleted") + } + + // Reload bundled default + loadBundledServerIdentity() + } + + /// Reset to bundled default certificate (deletes custom certificate) + func resetToDefaultServerCertificate() { + // Clear custom certificate data from Keychain + clearCustomCertificateData() + + // Delete custom certificate + let customQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityCustomTag + ] + SecItemDelete(customQuery as CFDictionary) + + // Delete existing bundled and reload + let bundledQuery: [String: Any] = [ + kSecClass as String: kSecClassIdentity, + kSecAttrLabel as String: serverIdentityTag + ] + SecItemDelete(bundledQuery as CFDictionary) + + loadBundledServerIdentity() + Logger.tak.info("Reset to bundled default server certificate") + } + + /// Get certificate info for display purposes + func getServerCertificateInfo() -> String? { + guard let identity = getServerIdentity() else { return nil } + + var certificate: SecCertificate? + let status = SecIdentityCopyCertificate(identity, &certificate) + guard status == errSecSuccess, let cert = certificate else { return nil } + + let isCustom = hasCustomServerCertificate() + let prefix = isCustom ? "Custom: " : "Default: " + + if let summary = SecCertificateCopySubjectSummary(cert) as String? { + return prefix + summary + } + + return prefix + "Certificate loaded" + } + + // MARK: - Client CA Certificates (PEM) + + /// Import client CA certificate from PEM file data + /// - Parameter pemData: The raw PEM file data + /// - Returns: The imported SecCertificate + func importClientCACertificate(from pemData: Data) throws -> SecCertificate { + // Extract DER data from PEM format + let derData = try extractDERFromPEM(pemData) + + guard let certificate = SecCertificateCreateWithData(nil, derData as CFData) else { + throw TAKCertificateError.invalidCertificate + } + + // Store in Keychain + try storeClientCACertificate(certificate) + + Logger.tak.info("Client CA certificate imported successfully") + return certificate + } + + /// Extract DER-encoded certificate data from PEM format + private func extractDERFromPEM(_ pemData: Data) throws -> Data { + guard let pemString = String(data: pemData, encoding: .utf8) else { + throw TAKCertificateError.invalidPEM + } + + // Remove PEM headers and whitespace + let base64 = pemString + .replacingOccurrences(of: "-----BEGIN CERTIFICATE-----", with: "") + .replacingOccurrences(of: "-----END CERTIFICATE-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespaces) + + guard let derData = Data(base64Encoded: base64) else { + throw TAKCertificateError.invalidPEM + } + + return derData + } + + /// Store client CA certificate in Keychain + private func storeClientCACertificate(_ certificate: SecCertificate) throws { + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecValueRef as String: certificate, + kSecAttrLabel as String: clientCATag, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + + // Ignore duplicate item errors (certificate already imported) + guard status == errSecSuccess || status == errSecDuplicateItem else { + Logger.tak.error("Failed to store client CA certificate: \(status)") + throw TAKCertificateError.keychainError(status) + } + } + + /// Get all stored client CA certificates + func getClientCACertificates() -> [SecCertificate] { + let query: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: clientCATag, + kSecReturnRef as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var items: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &items) + + guard status == errSecSuccess else { + if status != errSecItemNotFound { + Logger.tak.warning("Failed to retrieve client CA certificates: \(status)") + } + return [] + } + + // Handle both single item and array returns + if let certificates = items as? [SecCertificate] { + return certificates + } else if let certificate = items as! SecCertificate? { // swiftlint:disable:this force_cast + return [certificate] + } + + return [] + } + + /// Check if at least one client CA certificate is configured + func hasClientCACertificate() -> Bool { + return !getClientCACertificates().isEmpty + } + + /// Delete all client CA certificates from Keychain + func deleteClientCACertificates() { + let query: [String: Any] = [ + kSecClass as String: kSecClassCertificate, + kSecAttrLabel as String: clientCATag + ] + let status = SecItemDelete(query as CFDictionary) + if status == errSecSuccess || status == errSecItemNotFound { + Logger.tak.info("Client CA certificates deleted") + } + } + + /// Get info about stored client CA certificates for display + func getClientCACertificateInfo() -> [String] { + let certificates = getClientCACertificates() + return certificates.compactMap { cert in + SecCertificateCopySubjectSummary(cert) as String? + } + } + + // MARK: - Certificate Validation + + /// Validate a client certificate against the stored CA certificates + func validateClientCertificate(_ trust: SecTrust) -> Bool { + let caCertificates = getClientCACertificates() + + guard !caCertificates.isEmpty else { + Logger.tak.warning("No client CA certificates configured for validation") + return false + } + + // Set the anchor certificates (trusted CAs) + SecTrustSetAnchorCertificates(trust, caCertificates as CFArray) + SecTrustSetAnchorCertificatesOnly(trust, true) + + var error: CFError? + let isValid = SecTrustEvaluateWithError(trust, &error) + + if !isValid { + Logger.tak.warning("Client certificate validation failed: \(error?.localizedDescription ?? "unknown")") + } + + return isValid + } +} + +// MARK: - Certificate Errors + +enum TAKCertificateError: LocalizedError { + case importFailed(OSStatus) + case noIdentityFound + case invalidCertificate + case invalidPEM + case keychainError(OSStatus) + case certificateExpired + case certificateNotYetValid + + var errorDescription: String? { + switch self { + case .importFailed(let status): + return "Failed to import PKCS#12: \(securityErrorMessage(status))" + case .noIdentityFound: + return "No identity (certificate + private key) found in PKCS#12 file" + case .invalidCertificate: + return "Invalid certificate data" + case .invalidPEM: + return "Invalid PEM format - ensure file contains a valid certificate" + case .keychainError(let status): + return "Keychain error: \(securityErrorMessage(status))" + case .certificateExpired: + return "Certificate has expired" + case .certificateNotYetValid: + return "Certificate is not yet valid" + } + } + + private func securityErrorMessage(_ status: OSStatus) -> String { + if let message = SecCopyErrorMessageString(status, nil) { + return message as String + } + return "Error code: \(status)" + } +} diff --git a/Meshtastic/Helpers/TAK/TAKConnection.swift b/Meshtastic/Helpers/TAK/TAKConnection.swift new file mode 100644 index 00000000..b4678f06 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKConnection.swift @@ -0,0 +1,497 @@ +// +// TAKConnection.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import Network +import OSLog + +/// Actor managing a single TAK client TLS connection +/// Handles CoT XML streaming protocol (messages delimited by ) +/// Implements TAK Protocol negotiation and keepalive +actor TAKConnection { + private let connection: NWConnection + private var messageBuffer = Data() + private var readerTask: Task? + private var keepaliveTask: Task? + private var continuation: AsyncStream.Continuation? + + // CoT XML message delimiters (from StreamingCotProtocol.java) + private let startTag = " AsyncStream { + AsyncStream { continuation in + self.continuation = continuation + + continuation.onTermination = { [weak self] _ in + Task { [weak self] in + await self?.disconnect() + } + } + + // Set up state handler + connection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + Task { + await self.handleStateChange(state) + } + } + + // Start the connection + connection.start(queue: DispatchQueue(label: "tak.connection.\(UUID().uuidString)")) + } + } + + /// Handle connection state changes + private func handleStateChange(_ state: NWConnection.State) { + switch state { + case .ready: + isConnected = true + Logger.tak.info("TAK client connected: \(self.connection.endpoint.debugDescription)") + + // Extract client certificate info if available + extractClientInfo() + + // Notify connected + let info = clientInfo ?? TAKClientInfo(endpoint: connection.endpoint, connectedAt: Date()) + continuation?.yield(.connected(info)) + + // Send protocol support advertisement + Task { + await sendProtocolSupport() + } + + // Start reading data + startReading() + + // Start keepalive task + startKeepalive() + + case .failed(let error): + Logger.tak.error("TAK connection failed: \(error.localizedDescription)") + isConnected = false + continuation?.yield(.error(error)) + continuation?.yield(.disconnected) + continuation?.finish() + + case .cancelled: + Logger.tak.info("TAK connection cancelled") + isConnected = false + continuation?.yield(.disconnected) + continuation?.finish() + + case .waiting(let error): + Logger.tak.warning("TAK connection waiting: \(error.localizedDescription)") + + case .preparing: + Logger.tak.debug("TAK connection preparing") + + case .setup: + Logger.tak.debug("TAK connection setup") + + @unknown default: + break + } + } + + /// Extract client information from the TLS session + private func extractClientInfo() { + // Client callsign/uid will be updated when first CoT message is received + // For now just create basic client info with endpoint + clientInfo = TAKClientInfo( + endpoint: connection.endpoint, + callsign: nil, + uid: nil, + connectedAt: Date() + ) + Logger.tak.info("TAK client connected from: \(self.connection.endpoint.debugDescription)") + } + + /// Start the reader task to continuously read from the connection + private func startReading() { + readerTask = Task { + while !Task.isCancelled && isConnected { + do { + let data = try await receiveData() + if !data.isEmpty { + processReceivedData(data) + } + } catch { + if !Task.isCancelled { + Logger.tak.error("TAK read error: \(error.localizedDescription)") + continuation?.yield(.error(error)) + continuation?.yield(.disconnected) + } + break + } + } + } + } + + /// Receive data from the connection + private func receiveData() async throws -> Data { + try await withCheckedThrowingContinuation { cont in + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { content, _, isComplete, error in + if let error { + cont.resume(throwing: error) + return + } + if isComplete { + cont.resume(throwing: TAKConnectionError.connectionClosed) + return + } + if let content { + cont.resume(returning: content) + } else { + cont.resume(returning: Data()) + } + } + } + } + + /// Process received data using streaming CoT protocol + /// Based on StreamingCotProtocol.java parsing logic from TAK Server + private func processReceivedData(_ newData: Data) { + messageBuffer.append(newData) + + // Search for complete CoT messages (delimited by ) + while let endRange = messageBuffer.range(of: endTag) { + // Find the start tag before this end tag + guard let startRange = messageBuffer.range(of: startTag) else { + // No start tag found, discard data up to end tag + Logger.tak.warning("CoT end tag without start tag, discarding") + messageBuffer.removeSubrange(.. maxMessageSize { + Logger.tak.warning("Message buffer exceeded limit (\(self.messageBuffer.count) bytes), clearing") + messageBuffer.removeAll() + } + } + + /// Parse XML data and yield the message event + private func parseAndYieldMessage(_ data: Data) { + // Log raw XML for debugging + if let xmlString = String(data: data, encoding: .utf8) { + Logger.tak.debug("=== Received CoT XML (\(data.count) bytes) ===") + Logger.tak.debug("\(xmlString)") + Logger.tak.debug("=== End Raw XML ===") + } + + do { + let cotMessage = try CoTMessage.parseData(data) + + // Handle TAK Protocol control messages + if cotMessage.type.hasPrefix("t-x-takp") { + Logger.tak.debug("Handling TAK Protocol control message: \(cotMessage.type)") + Task { + await handleProtocolControl(cotMessage) + } + return // Don't forward control messages to app + } + + // Handle ping/pong messages (don't forward, just acknowledge) + if cotMessage.type == "t-x-c-t" || cotMessage.uid == "ping" { + Logger.tak.debug("Received ping from client") + return + } + + // Update client info if we got contact details + if let contact = cotMessage.contact { + if clientInfo?.callsign == nil { + clientInfo?.callsign = contact.callsign + } + if clientInfo?.uid == nil { + clientInfo?.uid = cotMessage.uid + } + // Update the connected event with new info + if let info = clientInfo { + continuation?.yield(.clientInfoUpdated(info)) + } + } + + Logger.tak.info("Received CoT message: type=\(cotMessage.type), uid=\(cotMessage.uid)") + Logger.tak.debug(" contact: \(cotMessage.contact?.callsign ?? "nil")") + Logger.tak.debug(" lat/lon: \(cotMessage.latitude), \(cotMessage.longitude)") + continuation?.yield(.message(cotMessage)) + + } catch { + Logger.tak.warning("Failed to parse CoT message: \(error.localizedDescription)") + // Log the raw XML for debugging + if let xmlString = String(data: data, encoding: .utf8) { + let snippet = String(xmlString.prefix(500)) + Logger.tak.debug("Failed Raw CoT XML: \(snippet)") + } + } + } + + // MARK: - Protocol Negotiation + + /// Send TAK Protocol Support advertisement to client + /// This tells the client what protocol versions we support (Version 0 = XML only) + private func sendProtocolSupport() async { + let now = ISO8601DateFormatter().string(from: Date()) + let stale = ISO8601DateFormatter().string(from: Date().addingTimeInterval(60)) + + // TAK Protocol Support message - advertise version 0 (XML) only + // Type t-x-takp-v indicates TAK Protocol version advertisement + let xml = """ + + + + + + + + + """ + + do { + try await sendRawXML(xml) + Logger.tak.info("Sent TakProtocolSupport to client (version 0 - XML)") + } catch { + Logger.tak.error("Failed to send TakProtocolSupport: \(error.localizedDescription)") + } + } + + /// Handle TAK Protocol control messages (TakRequest, etc.) + private func handleProtocolControl(_ cotMessage: CoTMessage) async { + // Check for protocol request in the raw XML + // Type t-x-takp-q is a protocol request from client + if cotMessage.type == "t-x-takp-q" { + await sendProtocolResponse(accepted: true) + } + } + + /// Send protocol response to client + private func sendProtocolResponse(accepted: Bool) async { + let now = ISO8601DateFormatter().string(from: Date()) + let stale = ISO8601DateFormatter().string(from: Date().addingTimeInterval(60)) + + // Type t-x-takp-r is TAK Protocol response + let xml = """ + + + + + + + + + """ + + do { + try await sendRawXML(xml) + protocolNegotiated = true + Logger.tak.info("Sent TakResponse (accepted: \(accepted))") + } catch { + Logger.tak.error("Failed to send TakResponse: \(error.localizedDescription)") + } + } + + // MARK: - Keepalive + + /// Start the keepalive task to send periodic pings + private func startKeepalive() { + keepaliveTask = Task { + while !Task.isCancelled && isConnected { + do { + try await Task.sleep(nanoseconds: keepaliveInterval) + if isConnected { + await sendKeepalive() + } + } catch { + break + } + } + } + } + + /// Send a keepalive/ping message to client + private func sendKeepalive() async { + let now = ISO8601DateFormatter().string(from: Date()) + let stale = ISO8601DateFormatter().string(from: Date().addingTimeInterval(120)) + + // t-x-c-t is a ping/keepalive type, t-x-d-d is also used for takPong + let xml = """ + + + + + """ + + do { + try await sendRawXML(xml) + Logger.tak.debug("Sent keepalive to client") + } catch { + Logger.tak.warning("Failed to send keepalive: \(error.localizedDescription)") + } + } + + // MARK: - Send Methods + + /// Send raw XML string to the client + private func sendRawXML(_ xml: String) async throws { + guard isConnected else { + throw TAKConnectionError.notConnected + } + + guard let data = xml.data(using: .utf8) else { + throw TAKConnectionError.encodingFailed + } + + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + cont.resume(throwing: error) + } else { + cont.resume() + } + }) + } + } + + /// Send a CoT message to this client + func send(_ cotMessage: CoTMessage) async throws { + guard isConnected else { + throw TAKConnectionError.notConnected + } + + let xml = cotMessage.toXML() + guard let data = xml.data(using: .utf8) else { + throw TAKConnectionError.encodingFailed + } + + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + cont.resume(throwing: error) + } else { + cont.resume() + } + }) + } + + Logger.tak.debug("Sent CoT message to client: type=\(cotMessage.type)") + } + + /// Disconnect this client + func disconnect() { + guard isConnected else { return } + + Logger.tak.info("Disconnecting TAK client: \(self.connection.endpoint.debugDescription)") + + isConnected = false + readerTask?.cancel() + readerTask = nil + keepaliveTask?.cancel() + keepaliveTask = nil + connection.cancel() + messageBuffer.removeAll() + + continuation?.yield(.disconnected) + continuation?.finish() + continuation = nil + } +} + +// MARK: - Supporting Types + +/// Information about a connected TAK client +struct TAKClientInfo: Identifiable, Sendable { + let id = UUID() + let endpoint: NWEndpoint + var callsign: String? + var uid: String? + let connectedAt: Date + + init(endpoint: NWEndpoint, callsign: String? = nil, uid: String? = nil, connectedAt: Date = Date()) { + self.endpoint = endpoint + self.callsign = callsign + self.uid = uid + self.connectedAt = connectedAt + } + + var displayName: String { + callsign ?? uid ?? endpoint.debugDescription + } +} + +/// Events emitted by a TAK connection +enum TAKConnectionEvent: Sendable { + case connected(TAKClientInfo) + case clientInfoUpdated(TAKClientInfo) + case message(CoTMessage) + case disconnected + case error(Error) +} + +/// Errors specific to TAK connections +enum TAKConnectionError: LocalizedError { + case connectionClosed + case notConnected + case encodingFailed + case sendFailed(String) + + var errorDescription: String? { + switch self { + case .connectionClosed: + return "Connection was closed" + case .notConnected: + return "Not connected" + case .encodingFailed: + return "Failed to encode CoT message" + case .sendFailed(let reason): + return "Failed to send: \(reason)" + } + } +} + diff --git a/Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift b/Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift new file mode 100644 index 00000000..01b34156 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift @@ -0,0 +1,290 @@ +// +// TAKDataPackageGenerator.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import OSLog +import UIKit + +/// Generates TAK data packages (.zip) for configuring TAK clients +/// to connect to the Meshtastic TAK server +final class TAKDataPackageGenerator { + + static let shared = TAKDataPackageGenerator() + + private init() {} + + // MARK: - Data Package Generation + + /// Generate a TAK data package for TAK client configuration + /// - Parameters: + /// - serverHost: The server hostname/IP (default: 127.0.0.1 for localhost) + /// - port: The server port + /// - useTLS: Whether to use TLS (ssl) with mTLS or plain TCP + /// - description: Description shown in TAK client + /// - userCertName: Optional custom name for the user client certificate (without .p12 extension) + /// - Returns: URL to the generated zip file, or nil if generation failed + func generateDataPackage( + serverHost: String = "127.0.0.1", + port: Int, + useTLS: Bool = true, + description: String = "Meshtastic TAK Server", + userCertName: String? = nil + ) -> URL? { + let fileManager = FileManager.default + + // Create temporary directory for package contents + let packageName = "Meshtastic_TAK_Server" + let tempDir = fileManager.temporaryDirectory.appendingPathComponent(packageName) + + do { + // Clean up any existing temp directory + if fileManager.fileExists(atPath: tempDir.path) { + try fileManager.removeItem(at: tempDir) + } + try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true) + + // Determine user client certificate filename + let userClientCertFileName: String + if let customName = userCertName { + userClientCertFileName = "\(customName).p12" + } else { + // Use device name as default (sanitize for filename safety) + let deviceName = UIDevice.current.name + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "'", with: "") + .replacingOccurrences(of: "\"", with: "") + userClientCertFileName = "\(deviceName).p12" + } + + // Generate preference file at package root (flat structure for TAK client compatibility) + let prefFileName = "meshtastic-server.pref" + let configPref = generateConfigPref( + serverHost: serverHost, + port: port, + useTLS: useTLS, + description: description, + userClientCertFileName: userClientCertFileName + ) + let configPrefURL = tempDir.appendingPathComponent(prefFileName) + try configPref.write(to: configPrefURL, atomically: true, encoding: .utf8) + Logger.tak.debug("Created \(prefFileName)") + + // Copy certificates (only needed for TLS/mTLS mode) + if useTLS { + // Truststore (server cert for verifying server) - uses custom if available + if let serverP12Data = TAKCertificateManager.shared.getActiveServerP12Data() { + let truststoreURL = tempDir.appendingPathComponent("truststore.p12") + try serverP12Data.write(to: truststoreURL) + Logger.tak.debug("Created truststore.p12 (custom: \(TAKCertificateManager.shared.hasCustomServerCertificate()))") + } else { + Logger.tak.warning("No server certificate data available") + } + + // User client certificate for mTLS - uses custom if available + if let clientP12Data = TAKCertificateManager.shared.getActiveClientP12Data() { + let clientURL = tempDir.appendingPathComponent(userClientCertFileName) + try clientP12Data.write(to: clientURL) + Logger.tak.debug("Created \(userClientCertFileName) (custom: \(TAKCertificateManager.shared.hasCustomClientP12()))") + } else { + Logger.tak.warning("No client certificate data available") + } + } + + // Generate manifest.xml at root level (not in subdirectory) + let manifest = generateManifest( + description: description, + useTLS: useTLS, + prefFileName: prefFileName, + userClientCertFileName: userClientCertFileName + ) + let manifestURL = tempDir.appendingPathComponent("manifest.xml") + try manifest.write(to: manifestURL, atomically: true, encoding: .utf8) + Logger.tak.debug("Created manifest.xml") + + // Create the zip file in Documents directory for better share sheet compatibility + let zipFileName = "\(packageName).zip" + guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + Logger.tak.error("Could not get Documents directory") + return nil + } + let zipURL = documentsDir.appendingPathComponent(zipFileName) + + // Remove existing zip if present + if fileManager.fileExists(atPath: zipURL.path) { + try fileManager.removeItem(at: zipURL) + } + + // Create zip archive + try createZipArchive(from: tempDir, to: zipURL) + + // Verify zip was created + guard fileManager.fileExists(atPath: zipURL.path) else { + Logger.tak.error("ZIP file was not created") + return nil + } + + // Cleanup temp directory + try? fileManager.removeItem(at: tempDir) + + Logger.tak.info("Generated TAK data package: \(zipURL.path)") + return zipURL + + } catch { + Logger.tak.error("Failed to generate TAK data package: \(error.localizedDescription)") + try? fileManager.removeItem(at: tempDir) + return nil + } + } + + // MARK: - Pref File Generation (matches working TAK data package format) + + private func generateConfigPref( + serverHost: String, + port: Int, + useTLS: Bool, + description: String, + userClientCertFileName: String + ) -> String { + let protocolType = useTLS ? "ssl" : "tcp" + // Use active certificate passwords (custom if available, otherwise bundled) + let serverPassword = TAKCertificateManager.shared.getActiveServerCertificatePassword() + let clientPassword = TAKCertificateManager.shared.getActiveClientCertificatePassword() + + if useTLS { + // TLS mode with mTLS (mutual TLS with client certificate) + return """ + + + + 1 + \(escapeXML(description)) + true + \(serverHost):\(port):\(protocolType) + + + true + cert/truststore.p12 + \(serverPassword) + cert/\(userClientCertFileName) + \(clientPassword) + + + """ + } else { + // TCP mode - no certificates needed + return """ + + + + 1 + \(escapeXML(description)) + true + \(serverHost):\(port):\(protocolType) + + + true + + + """ + } + } + + // MARK: - Manifest Generation (matches working TAK data package format) + + private func generateManifest( + description: String, + useTLS: Bool, + prefFileName: String, + userClientCertFileName: String + ) -> String { + let uid = UUID().uuidString + + if useTLS { + // TLS mode with mTLS - includes truststore and user client certificate + return """ + + + + + + + + + + + + + """ + } else { + // TCP mode - just the pref file + return """ + + + + + + + + + + + """ + } + } + + // MARK: - Helper Methods + + private func escapeXML(_ string: String) -> String { + return string + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + + // MARK: - ZIP Archive Creation + + /// Create a ZIP archive from a directory + private func createZipArchive(from sourceDir: URL, to destinationURL: URL) throws { + let fileManager = FileManager.default + var copyError: Error? + + // Use NSFileCoordinator to create zip - this is the built-in approach on iOS + var coordinatorError: NSError? + let coordinator = NSFileCoordinator() + + Logger.tak.debug("Creating ZIP from: \(sourceDir.path)") + + coordinator.coordinate( + readingItemAt: sourceDir, + options: .forUploading, + error: &coordinatorError + ) { zipURL in + Logger.tak.debug("Coordinator provided ZIP at: \(zipURL.path)") + do { + // The coordinator creates a temporary zip, copy it to our destination + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.copyItem(at: zipURL, to: destinationURL) + Logger.tak.debug("Copied ZIP to: \(destinationURL.path)") + } catch { + Logger.tak.error("Failed to copy ZIP: \(error.localizedDescription)") + copyError = error + } + } + + if let coordinatorError = coordinatorError { + Logger.tak.error("Coordinator error: \(coordinatorError.localizedDescription)") + throw coordinatorError + } + if let copyError = copyError { + throw copyError + } + } +} diff --git a/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift new file mode 100644 index 00000000..9ed42c90 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift @@ -0,0 +1,516 @@ +// +// TAKMeshtasticBridge.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import MeshtasticProtobufs +import OSLog +import CoreData + +/// Bridges CoT messages between TAK clients and the Meshtastic mesh network +/// Handles bidirectional conversion and message routing +@MainActor +final class TAKMeshtasticBridge { + + weak var accessoryManager: AccessoryManager? + weak var takServerManager: TAKServerManager? + + /// Core Data context for node lookups + var context: NSManagedObjectContext? + + /// Lookup table mapping callsigns to device UIDs + /// Populated when receiving PLI packets from other TAK users + /// Key: callsign (e.g., "OLD SALT"), Value: device UID (e.g., "ANDROID-abc123-def456") + private var callsignToDeviceUID: [String: String] = [:] + + init(accessoryManager: AccessoryManager?, takServerManager: TAKServerManager?) { + self.accessoryManager = accessoryManager + self.takServerManager = takServerManager + } + + // MARK: - Callsign to Device UID Mapping + + /// Register a callsign → device UID mapping (called when receiving PLI from other users) + func registerContact(callsign: String, deviceUID: String) { + guard !callsign.isEmpty, !deviceUID.isEmpty else { return } + // Extract actual device UID in case it has a smuggled messageId + let (actualDeviceUID, _) = Self.parseDeviceCallsign(deviceUID) + guard !actualDeviceUID.isEmpty else { return } + let previousUID = callsignToDeviceUID[callsign] + callsignToDeviceUID[callsign] = actualDeviceUID + if previousUID != actualDeviceUID { + Logger.tak.debug("Registered contact: \(callsign) → \(actualDeviceUID)") + } + } + + // MARK: - Read Receipt Handling + + /// Receipt type for GeoChat read receipts + enum ReceiptType { + case delivered // ACK:D - Message delivered to device + case read // ACK:R - Message read by user + } + + /// Parsed read receipt from a GeoChat message + struct ParsedReceipt { + let type: ReceiptType + let messageId: String + } + + /// Check if a GeoChat message is a read receipt + /// Receipt format: "ACK:D:" or "ACK:R:" + /// - Parameter message: The GeoChat message content + /// - Returns: Parsed receipt if this is a receipt, nil otherwise + nonisolated static func parseReceipt(from message: String) -> ParsedReceipt? { + guard message.hasPrefix("ACK:") else { return nil } + + let parts = message.split(separator: ":", maxSplits: 2) + guard parts.count == 3 else { + return nil + } + + let receiptTypeString = String(parts[1]) + let messageId = String(parts[2]) + + guard !messageId.isEmpty else { return nil } + + let receiptType: ReceiptType + switch receiptTypeString { + case "D": + receiptType = .delivered + case "R": + receiptType = .read + default: + return nil + } + + return ParsedReceipt(type: receiptType, messageId: messageId) + } + + /// Check if a TAKPacket GeoChat is a read receipt + nonisolated static func isReceipt(_ takPacket: TAKPacket) -> Bool { + guard case .chat(let geoChat) = takPacket.payloadVariant else { + return false + } + return geoChat.message.hasPrefix("ACK:") + } + + // MARK: - MessageId Smuggling in device_callsign + + /// Parse a device_callsign that may contain a smuggled messageId + /// Format: "|" or just "" + /// - Parameter combined: The device_callsign field value + /// - Returns: Tuple of (actualDeviceCallsign, messageId) where messageId is nil if not present + nonisolated static func parseDeviceCallsign(_ combined: String?) -> (deviceCallsign: String, messageId: String?) { + guard let combined = combined, !combined.isEmpty else { + return ("", nil) + } + + if let separatorIndex = combined.firstIndex(of: "|") { + let deviceCallsign = String(combined[..|" + /// - Parameters: + /// - deviceCallsign: The actual device UID + /// - messageId: The message ID to smuggle + /// - Returns: Combined string with messageId appended + nonisolated static func createSmuggledDeviceCallsign(deviceCallsign: String, messageId: String) -> String { + return "\(deviceCallsign)|\(messageId)" + } + + /// Look up a device UID from a callsign + func lookupDeviceUID(forCallsign callsign: String) -> String? { + return callsignToDeviceUID[callsign] + } + + // MARK: - TAK → Meshtastic (CoT to TAKPacket) + + /// Send a CoT message received from TAK to the Meshtastic mesh + func sendToMesh(_ cotMessage: CoTMessage) async { + guard let accessoryManager else { + Logger.tak.warning("Cannot send to mesh: AccessoryManager not available") + return + } + + guard accessoryManager.isConnected else { + Logger.tak.warning("Cannot send to mesh: Not connected to Meshtastic device") + return + } + + // Determine send method based on CoT type + let sendMethod = GenericCoTHandler.shared.classifySendMethod(for: cotMessage) + + switch sendMethod { + case .takPacketPLI, .takPacketChat: + // Use TAKPacket protobuf on ATAK_PLUGIN port (72) + guard let takPacket = convertToTAKPacket(cot: cotMessage) else { + Logger.tak.warning("Failed to convert CoT to TAKPacket: \(cotMessage.type)") + return + } + + do { + try await accessoryManager.sendTAKPacket(takPacket) + Logger.tak.info("Sent TAKPacket to mesh: \(cotMessage.type)") + } catch { + Logger.tak.error("Failed to send TAKPacket to mesh: \(error.localizedDescription)") + } + + case .exiDirect, .exiFountain: + // Use EXI compression on ATAK_FORWARDER port (257) + GenericCoTHandler.shared.accessoryManager = accessoryManager + do { + try await GenericCoTHandler.shared.sendGenericCoT(cotMessage) + Logger.tak.info("Sent generic CoT to mesh via ATAK_FORWARDER: \(cotMessage.type)") + } catch { + Logger.tak.error("Failed to send generic CoT to mesh: \(error.localizedDescription)") + } + } + } + + /// Convert CoT message to Meshtastic TAKPacket protobuf + func convertToTAKPacket(cot: CoTMessage) -> TAKPacket? { + Logger.tak.debug("=== CoT → TAKPacket Conversion ===") + Logger.tak.debug("CoT Input:") + Logger.tak.debug(" uid: \(cot.uid)") + Logger.tak.debug(" type: \(cot.type)") + Logger.tak.debug(" lat: \(cot.latitude), lon: \(cot.longitude), hae: \(cot.hae)") + Logger.tak.debug(" contact: \(cot.contact?.callsign ?? "nil")") + Logger.tak.debug(" group: \(cot.group?.name ?? "nil") / \(cot.group?.role ?? "nil")") + Logger.tak.debug(" status.battery: \(cot.status?.battery ?? -1)") + Logger.tak.debug(" track: speed=\(cot.track?.speed ?? -1), course=\(cot.track?.course ?? -1)") + Logger.tak.debug(" chat: \(cot.chat?.message ?? "nil")") + Logger.tak.debug(" remarks: \(cot.remarks ?? "nil")") + + var takPacket = TAKPacket() + + // Contact information + if let contact = cot.contact { + var cotContact = Contact() + cotContact.callsign = contact.callsign + cotContact.deviceCallsign = cot.uid + takPacket.contact = cotContact + Logger.tak.debug("TAKPacket.contact: callsign=\(cotContact.callsign), deviceCallsign=\(cotContact.deviceCallsign)") + } + + // Group/Team information + if let group = cot.group { + var cotGroup = Group() + cotGroup.team = Team.fromColorName(group.name) + cotGroup.role = MemberRole.fromRoleName(group.role) + takPacket.group = cotGroup + Logger.tak.debug("TAKPacket.group: team=\(cotGroup.team.rawValue), role=\(cotGroup.role.rawValue)") + } + + // Status (battery) + if let status = cot.status { + var cotStatus = Status() + cotStatus.battery = UInt32(max(0, status.battery)) + takPacket.status = cotStatus + Logger.tak.debug("TAKPacket.status: battery=\(cotStatus.battery)") + } + + // Determine payload type based on CoT type + // Accept any friendly ground unit type (a-f-G...) for PLI + if cot.type.hasPrefix("a-f-G") || cot.type.hasPrefix("a-f-g") { + // Register this TAK client's contact info for future DM lookups + if let contact = cot.contact, !contact.callsign.isEmpty, !cot.uid.isEmpty { + registerContact(callsign: contact.callsign, deviceUID: cot.uid) + } + + // Atom type (position) - create PLI + var pli = PLI() + + // Convert lat/lon to integer format (degrees * 1e7) + let latI = Int32(cot.latitude * 1e7) + let lonI = Int32(cot.longitude * 1e7) + + // Handle altitude - clamp to valid Int32 range, use 0 for unknown (9999999) + let altitudeValue: Int32 + if cot.hae >= 9999999.0 || cot.hae.isNaN || cot.hae.isInfinite { + altitudeValue = 0 // Unknown altitude + } else { + altitudeValue = Int32(clamping: Int(cot.hae)) + } + + pli.latitudeI = latI + pli.longitudeI = lonI + pli.altitude = altitudeValue + + if let track = cot.track { + pli.speed = UInt32(max(0, track.speed)) + pli.course = UInt32(max(0, track.course)) + } + + takPacket.pli = pli + + Logger.tak.debug("TAKPacket.pli created:") + Logger.tak.debug(" latitudeI: \(pli.latitudeI) (from \(cot.latitude))") + Logger.tak.debug(" longitudeI: \(pli.longitudeI) (from \(cot.longitude))") + Logger.tak.debug(" altitude: \(pli.altitude) (from \(cot.hae))") + Logger.tak.debug(" speed: \(pli.speed), course: \(pli.course)") + + } else if cot.type == "b-t-f" { + // Chat message - MUST include contact field for sender identification + var geoChat = GeoChat() + + // Extract messageId from CoT uid if present + // CoT uid format: "GeoChat.{senderUid}.{chatroom}.{messageId}" + var messageId: String? + var actualDeviceUid = cot.uid + let uidComponents = cot.uid.components(separatedBy: ".") + if uidComponents.count >= 4 && uidComponents[0] == "GeoChat" { + // Extract the actual device UID (second component) + actualDeviceUid = uidComponents[1] + // Extract messageId (last component) + messageId = uidComponents.last + Logger.tak.debug("GeoChat: Extracted messageId=\(messageId ?? "nil") from uid") + } + + // If no messageId found, generate one + if messageId == nil || messageId?.isEmpty == true { + messageId = UUID().uuidString + Logger.tak.debug("GeoChat: Generated new messageId=\(messageId!)") + } + + // Ensure contact (sender info) is always set for chat messages + // This is REQUIRED for Android ATAK to process the message correctly + if !takPacket.hasContact { + var senderContact = Contact() + // Get sender callsign from chat.senderCallsign or cot.contact + if let senderCallsign = cot.chat?.senderCallsign, !senderCallsign.isEmpty { + senderContact.callsign = senderCallsign + } else if let contactCallsign = cot.contact?.callsign, !contactCallsign.isEmpty { + senderContact.callsign = contactCallsign + } else { + senderContact.callsign = "Unknown" + } + // Smuggle messageId into device_callsign for proper threading on Android + // Format: "|" + senderContact.deviceCallsign = Self.createSmuggledDeviceCallsign( + deviceCallsign: actualDeviceUid, + messageId: messageId! + ) + takPacket.contact = senderContact + Logger.tak.debug("GeoChat: Added sender contact - callsign=\(senderContact.callsign), smuggled deviceCallsign=\(senderContact.deviceCallsign)") + } else { + // Contact already set, but we still need to smuggle the messageId + var updatedContact = takPacket.contact + let existingDeviceCallsign = updatedContact.deviceCallsign.isEmpty ? actualDeviceUid : updatedContact.deviceCallsign + updatedContact.deviceCallsign = Self.createSmuggledDeviceCallsign( + deviceCallsign: existingDeviceCallsign, + messageId: messageId! + ) + takPacket.contact = updatedContact + Logger.tak.debug("GeoChat: Updated contact with smuggled messageId - deviceCallsign=\(updatedContact.deviceCallsign)") + } + + if let chat = cot.chat { + geoChat.message = chat.message + + // Handle recipient addressing + // chat.chatroom contains either "All Chat Rooms" or the recipient's callsign + if chat.chatroom == "All Chat Rooms" { + // Broadcast message - set to literal "All Chat Rooms" + geoChat.to = "All Chat Rooms" + Logger.tak.debug("GeoChat: Broadcast to All Chat Rooms") + } else { + // Direct message - need to look up recipient's device UID from their callsign + let recipientCallsign = chat.chatroom + if let recipientDeviceUID = lookupDeviceUID(forCallsign: recipientCallsign) { + // Found the recipient's device UID + geoChat.to = recipientDeviceUID + geoChat.toCallsign = recipientCallsign + Logger.tak.debug("GeoChat DM: to=\(recipientDeviceUID), toCallsign=\(recipientCallsign)") + } else { + // Recipient device UID not found - use callsign as fallback + // This may not work on Android but is better than nothing + geoChat.to = recipientCallsign + geoChat.toCallsign = recipientCallsign + Logger.tak.warning("GeoChat DM: Unknown device UID for '\(recipientCallsign)', using callsign as fallback") + } + } + } else if let remarks = cot.remarks { + geoChat.message = remarks + geoChat.to = "All Chat Rooms" + } + + takPacket.chat = geoChat + + Logger.tak.debug("TAKPacket.chat created:") + Logger.tak.debug(" message: \(geoChat.message)") + Logger.tak.debug(" to: \(geoChat.to)") + Logger.tak.debug(" toCallsign: \(geoChat.toCallsign)") + Logger.tak.debug(" sender.callsign: \(takPacket.contact.callsign)") + Logger.tak.debug(" sender.deviceCallsign: \(takPacket.contact.deviceCallsign)") + + } else { + // Unknown type, skip + Logger.tak.debug("Skipping CoT type not mapped to TAKPacket: \(cot.type)") + return nil + } + + // Log the final TAKPacket structure + Logger.tak.debug("TAKPacket output:") + Logger.tak.debug(" hasContact: \(takPacket.hasContact)") + Logger.tak.debug(" hasGroup: \(takPacket.hasGroup)") + Logger.tak.debug(" hasStatus: \(takPacket.hasStatus)") + Logger.tak.debug(" payloadVariant: \(String(describing: takPacket.payloadVariant))") + + // Log serialized size for debugging + do { + let serialized = try takPacket.serializedData() + Logger.tak.debug(" serializedSize: \(serialized.count) bytes") + Logger.tak.debug(" serializedHex: \(serialized.prefix(64).map { String(format: "%02x", $0) }.joined(separator: " "))\(serialized.count > 64 ? "..." : "")") + } catch { + Logger.tak.error(" Failed to serialize TAKPacket: \(error.localizedDescription)") + } + + Logger.tak.debug("=== End Conversion ===") + return takPacket + } + + // MARK: - Meshtastic → TAK (TAKPacket to CoT) + + /// Broadcast a Meshtastic TAKPacket to all connected TAK clients + func broadcastToTAKClients(_ takPacket: TAKPacket, from nodeNum: UInt32) async { + // Register contact info from incoming TAKPackets (for callsign → deviceUID lookup) + if takPacket.hasContact { + let callsign = takPacket.contact.callsign + let deviceUID = takPacket.contact.deviceCallsign + if !callsign.isEmpty && !deviceUID.isEmpty { + registerContact(callsign: callsign, deviceUID: deviceUID) + } + } + + // Check if this is a read receipt - don't forward to TAK clients as chat message + if case .chat(let geoChat) = takPacket.payloadVariant { + if let receipt = Self.parseReceipt(from: geoChat.message) { + // This is a read receipt, handle it internally + let typeString = receipt.type == .delivered ? "Delivered" : "Read" + Logger.tak.info("Received \(typeString) receipt for messageId: \(receipt.messageId) from node \(nodeNum)") + // TODO: Update message status in Core Data if we track sent messages + // For now, just log and don't forward to TAK clients + return + } + } + + guard let takServerManager else { + Logger.tak.debug("Cannot broadcast to TAK: TAKServerManager not available") + return + } + + guard takServerManager.isRunning else { + Logger.tak.debug("Cannot broadcast to TAK: Server not running") + return + } + + guard !takServerManager.connectedClients.isEmpty else { + Logger.tak.debug("No TAK clients connected, skipping broadcast") + return + } + + // Look up node info for additional context + let nodeInfo = lookupNodeInfo(nodeNum: nodeNum) + + // Convert to CoT + guard let cotMessage = convertToCoT(from: takPacket, nodeNum: nodeNum, nodeInfo: nodeInfo) else { + Logger.tak.warning("Failed to convert TAKPacket to CoT from node \(nodeNum)") + return + } + + // Broadcast to all TAK clients + await takServerManager.broadcast(cotMessage) + Logger.tak.info("Broadcast CoT to TAK clients: \(cotMessage.type) from node \(nodeNum)") + } + + /// Convert Meshtastic TAKPacket to CoT message + func convertToCoT(from takPacket: TAKPacket, nodeNum: UInt32, nodeInfo: NodeInfoEntity?) -> CoTMessage? { + // Use the factory method from CoTMessage which handles the conversion + let deviceUid = "MESHTASTIC-\(String(format: "%08X", nodeNum))" + return CoTMessage.fromTAKPacket(takPacket, deviceUid: deviceUid) + } + + /// Create a CoT PLI message from a Meshtastic node's position + func createCoTFromNode(_ node: NodeInfoEntity) -> CoTMessage? { + guard let position = node.latestPosition, + let latitude = position.latitude, + let longitude = position.longitude, + latitude != 0 || longitude != 0 else { + return nil + } + + let uid = "MESHTASTIC-\(String(format: "%08X", node.num))" + let callsign = node.user?.shortName ?? node.user?.longName ?? "MESH-\(node.num)" + + // Get battery level from device metrics + let battery = Int(node.latestDeviceMetrics?.batteryLevel ?? 100) + + return CoTMessage.pli( + uid: uid, + callsign: callsign, + latitude: latitude, + longitude: longitude, + altitude: Double(position.altitude), + speed: Double(position.speed), + course: Double(position.heading), + team: "Green", // Meshtastic nodes shown as green by default + role: "Team Member", + battery: battery, + staleMinutes: 15 // Meshtastic positions can be older + ) + } + + // MARK: - Broadcast All Mesh Nodes to TAK + + /// Send all known mesh node positions to TAK clients + /// Useful when a new TAK client connects + func broadcastAllNodesToTAK() async { + guard let takServerManager, takServerManager.isRunning else { return } + guard let context else { return } + + let fetchRequest: NSFetchRequest = NodeInfoEntity.fetchRequest() + // Only nodes with valid positions + fetchRequest.predicate = NSPredicate(format: "latestPosition != nil") + + do { + let nodes = try context.fetch(fetchRequest) + + for node in nodes { + if let cotMessage = createCoTFromNode(node) { + await takServerManager.broadcast(cotMessage) + } + } + + Logger.tak.info("Broadcast \(nodes.count) mesh node positions to TAK clients") + } catch { + Logger.tak.error("Failed to fetch nodes for TAK broadcast: \(error.localizedDescription)") + } + } + + // MARK: - Helper Methods + + private func lookupNodeInfo(nodeNum: UInt32) -> NodeInfoEntity? { + guard let context else { return nil } + + let fetchRequest: NSFetchRequest = NodeInfoEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "num == %d", Int64(nodeNum)) + fetchRequest.fetchLimit = 1 + + do { + return try context.fetch(fetchRequest).first + } catch { + Logger.tak.warning("Failed to lookup node info for \(nodeNum): \(error.localizedDescription)") + return nil + } + } +} diff --git a/Meshtastic/Helpers/TAK/TAKServerManager.swift b/Meshtastic/Helpers/TAK/TAKServerManager.swift new file mode 100644 index 00000000..e3e3caa7 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKServerManager.swift @@ -0,0 +1,442 @@ +// +// TAKServerManager.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import Network +import OSLog +import Combine +import SwiftUI + +/// Manages the TAK Server lifecycle, TLS connections, and client management +/// Runs on MainActor for thread safety, following the AccessoryManager pattern +@MainActor +final class TAKServerManager: ObservableObject { + + static let shared = TAKServerManager() + + // MARK: - Published State + + @Published private(set) var isRunning = false + @Published private(set) var connectedClients: [TAKClientInfo] = [] + @Published var lastError: String? + + // MARK: - Configuration (persisted via AppStorage) + + @AppStorage("takServerEnabled") var enabled = false { + didSet { + Task { + if enabled && !isRunning { + try? await start() + } else if !enabled && isRunning { + stop() + } + } + } + } + + /// Fixed port - always use TLS port 8089 + static let defaultTLSPort = 8089 + static let defaultTCPPort = 8087 // Legacy, not used + + /// Port is fixed to 8089 (mTLS) + var port: Int { Self.defaultTLSPort } + + /// Always use TLS/mTLS + var useTLS: Bool { true } + + // MARK: - Bridge + + /// Bridge for converting between CoT and Meshtastic formats + var bridge: TAKMeshtasticBridge? + + // MARK: - Private Properties + + private var listener: NWListener? + private var connections: [ObjectIdentifier: TAKConnection] = [:] + private var connectionTasks: [ObjectIdentifier: Task] = [:] + private let queue = DispatchQueue(label: "tak.server", qos: .userInitiated) + + private init() {} + + // MARK: - Initialization + + /// Initialize the TAK server on app startup + /// Call this from app initialization to restore server state + func initializeOnStartup() { + guard enabled else { + Logger.tak.debug("TAK Server not enabled, skipping startup") + return + } + + guard !isRunning else { + Logger.tak.debug("TAK Server already running") + return + } + + Logger.tak.info("TAK Server enabled, starting on app launch") + Task { + do { + try await start() + } catch { + Logger.tak.error("Failed to start TAK Server on startup: \(error.localizedDescription)") + } + } + } + + // MARK: - Server Lifecycle + + /// Start the TAK server (TLS or TCP based on configuration) + func start() async throws { + guard !isRunning else { + Logger.tak.info("TAK Server already running") + return + } + + let mode = useTLS ? "TLS" : "TCP" + Logger.tak.info("Starting TAK Server on port \(self.port) (\(mode) mode)") + + let parameters: NWParameters + + if useTLS { + // Validate we have a server certificate for TLS mode + guard let identity = TAKCertificateManager.shared.getServerIdentity() else { + let error = TAKServerError.noServerCertificate + lastError = error.localizedDescription + enabled = false + throw error + } + + // Create TLS options + let tlsOptions = NWProtocolTLS.Options() + + // Set server identity (certificate + private key) + guard let secIdentity = sec_identity_create(identity) else { + let error = TAKServerError.tlsConfigurationFailed + Logger.tak.error("Failed to create sec_identity from server identity") + lastError = error.localizedDescription + enabled = false + throw error + } + sec_protocol_options_set_local_identity( + tlsOptions.securityProtocolOptions, + secIdentity + ) + + // Set minimum TLS version to 1.2 (TAK standard) + sec_protocol_options_set_min_tls_protocol_version( + tlsOptions.securityProtocolOptions, + .TLSv12 + ) + + // Configure mTLS - always require client certificate for TLS mode + sec_protocol_options_set_peer_authentication_required( + tlsOptions.securityProtocolOptions, + true + ) + + // Set up client certificate validation + let clientCAs = TAKCertificateManager.shared.getClientCACertificates() + Logger.tak.info("Loaded \(clientCAs.count) CA certificate(s) for client validation") + if !clientCAs.isEmpty { + for (index, ca) in clientCAs.enumerated() { + if let summary = SecCertificateCopySubjectSummary(ca) as String? { + Logger.tak.info("CA[\(index)]: \(summary)") + } + } + let trustRoots = clientCAs as CFArray + sec_protocol_options_set_verify_block( + tlsOptions.securityProtocolOptions, + { _, secTrust, completion in + // Convert sec_trust_t to SecTrust + let trust = sec_trust_copy_ref(secTrust).takeRetainedValue() + + // Set policy for client certificate validation + // Use SSL policy with server=false to validate client certificates + // This properly accepts clientAuth ExtendedKeyUsage + let clientPolicy = SecPolicyCreateSSL(false, nil) + SecTrustSetPolicies(trust, clientPolicy) + + SecTrustSetAnchorCertificates(trust, trustRoots) + SecTrustSetAnchorCertificatesOnly(trust, true) + var error: CFError? + let isValid = SecTrustEvaluateWithError(trust, &error) + if let error = error { + Logger.tak.error("Client cert validation error: \(error.localizedDescription)") + } + Logger.tak.info("Client certificate validation: \(isValid ? "passed" : "failed")") + completion(isValid) + }, + queue + ) + } else { + // No client CAs configured: keep mTLS enabled but reject all client certificates + Logger.tak.warning("mTLS enabled but no CA certificates configured for client validation; all client connections will be rejected") + sec_protocol_options_set_verify_block( + tlsOptions.securityProtocolOptions, + { _, _, completion in + Logger.tak.error("Rejecting client connection because no client CA certificates are configured") + completion(false) + }, + queue + ) + } + + // TCP options + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.enableKeepalive = true + tcpOptions.keepaliveIdle = 60 + + parameters = NWParameters(tls: tlsOptions, tcp: tcpOptions) + } else { + // Plain TCP mode (no TLS) + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.enableKeepalive = true + tcpOptions.keepaliveIdle = 60 + + parameters = NWParameters(tls: nil, tcp: tcpOptions) + } + + parameters.allowLocalEndpointReuse = true + + // Bind to localhost only - only allow TAK clients on the same device + parameters.requiredLocalEndpoint = NWEndpoint.hostPort( + host: NWEndpoint.Host("127.0.0.1"), + port: NWEndpoint.Port(integerLiteral: UInt16(port)) + ) + + // Create and configure listener + do { + listener = try NWListener(using: parameters) + } catch { + lastError = "Failed to create listener: \(error.localizedDescription)" + Logger.tak.error("Failed to create TAK listener: \(error.localizedDescription)") + enabled = false + throw error + } + + // Set up state handler + listener?.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + self?.handleListenerState(state) + } + } + + // Set up new connection handler + listener?.newConnectionHandler = { [weak self] connection in + Task { @MainActor in + await self?.handleNewConnection(connection) + } + } + + // Start listening + listener?.start(queue: queue) + } + + /// Stop the TAK server + func stop() { + Logger.tak.info("Stopping TAK Server") + + listener?.cancel() + listener = nil + + // Cancel all connection tasks + for (_, task) in connectionTasks { + task.cancel() + } + connectionTasks.removeAll() + + // Disconnect all clients + for (_, connection) in connections { + Task { + await connection.disconnect() + } + } + connections.removeAll() + connectedClients.removeAll() + + isRunning = false + lastError = nil + + Logger.tak.info("TAK Server stopped") + } + + /// Restart the server (useful after configuration changes) + func restart() async throws { + stop() + try await Task.sleep(nanoseconds: 500_000_000) // 0.5s delay + try await start() + } + + // MARK: - State Handling + + private func handleListenerState(_ state: NWListener.State) { + switch state { + case .ready: + isRunning = true + lastError = nil + Logger.tak.info("TAK Server listening on port \(self.port)") + + case .failed(let error): + isRunning = false + lastError = error.localizedDescription + enabled = false + Logger.tak.error("TAK Server failed: \(error.localizedDescription)") + + case .cancelled: + isRunning = false + Logger.tak.info("TAK Server cancelled") + + case .waiting(let error): + Logger.tak.warning("TAK Server waiting: \(error.localizedDescription)") + + case .setup: + Logger.tak.debug("TAK Server setup") + + @unknown default: + break + } + } + + // MARK: - Connection Management + + private func handleNewConnection(_ nwConnection: NWConnection) async { + let connectionId = ObjectIdentifier(nwConnection) + let connection = TAKConnection(connection: nwConnection) + + connections[connectionId] = connection + + Logger.tak.info("New TAK client connecting: \(nwConnection.endpoint.debugDescription)") + + // Start handling the connection + let eventStream = await connection.start() + + // Create task to handle connection events + let task = Task { + for await event in eventStream { + await handleConnectionEvent(event, connectionId: connectionId) + } + // Connection ended + await removeConnection(connectionId) + } + + connectionTasks[connectionId] = task + } + + private func handleConnectionEvent(_ event: TAKConnectionEvent, connectionId: ObjectIdentifier) async { + switch event { + case .connected(let clientInfo): + connectedClients.append(clientInfo) + Logger.tak.info("TAK client connected: \(clientInfo.displayName)") + + case .clientInfoUpdated(let clientInfo): + // Update the client info in our list + if let index = connectedClients.firstIndex(where: { $0.id == clientInfo.id }) { + connectedClients[index] = clientInfo + } + + case .message(let cotMessage): + Logger.tak.info("Received CoT from TAK client: \(cotMessage.type)") + // Forward to Meshtastic mesh via bridge + await bridge?.sendToMesh(cotMessage) + + case .disconnected: + await removeConnection(connectionId) + + case .error(let error): + Logger.tak.error("TAK client error: \(error.localizedDescription)") + } + } + + private func removeConnection(_ connectionId: ObjectIdentifier) async { + connectionTasks[connectionId]?.cancel() + connectionTasks.removeValue(forKey: connectionId) + + if let connection = connections.removeValue(forKey: connectionId) { + let endpoint = await connection.endpoint + connectedClients.removeAll { $0.endpoint.debugDescription == endpoint.debugDescription } + Logger.tak.info("TAK client disconnected") + } + } + + // MARK: - Message Distribution + + /// Broadcast a CoT message to all connected TAK clients + func broadcast(_ cotMessage: CoTMessage) async { + guard !connections.isEmpty else { return } + + Logger.tak.info("Broadcasting CoT to \(self.connections.count) TAK client(s): \(cotMessage.type)") + + for (connectionId, connection) in connections { + do { + try await connection.send(cotMessage) + } catch { + Logger.tak.error("Failed to send to TAK client: \(error.localizedDescription)") + // Remove failed connection + await removeConnection(connectionId) + } + } + } + + /// Send a CoT message to a specific client + func send(_ cotMessage: CoTMessage, to clientId: UUID) async throws { + guard let clientInfo = connectedClients.first(where: { $0.id == clientId }) else { + throw TAKServerError.clientNotFound + } + + for (_, connection) in connections { + let endpoint = await connection.endpoint + if endpoint.debugDescription == clientInfo.endpoint.debugDescription { + try await connection.send(cotMessage) + return + } + } + + throw TAKServerError.clientNotFound + } + + // MARK: - Status + + /// Get server status description + var statusDescription: String { + if isRunning { + let mode = useTLS ? "TLS" : "TCP" + return "Running on port \(port) (\(mode))" + } else if let error = lastError { + return "Error: \(error)" + } else { + return "Stopped" + } + } +} + +// MARK: - Server Errors + +enum TAKServerError: LocalizedError { + case noServerCertificate + case noClientCACertificate + case tlsConfigurationFailed + case listenerFailed(String) + case clientNotFound + case notRunning + + var errorDescription: String? { + switch self { + case .noServerCertificate: + return "No server certificate configured. Import a .p12 file with the server certificate and private key." + case .noClientCACertificate: + return "No client CA certificate configured. Import the CA certificate (.pem) used to sign client certificates." + case .tlsConfigurationFailed: + return "Failed to configure TLS settings." + case .listenerFailed(let reason): + return "Failed to start listener: \(reason)" + case .clientNotFound: + return "Client not found" + case .notRunning: + return "TAK Server is not running" + } + } +} diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index 4dbdb836..e8c10bea 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -25,6 +25,8 @@ com.apple.security.network.client + com.apple.security.network.server + com.apple.security.personal-information.location keychain-access-groups diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index d60ed940..9d9f6789 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -193,9 +193,13 @@ struct MeshtasticAppleApp: App { } } .onChange(of: scenePhase) { (_, newScenePhase) in + accessoryManager.isInBackground = (newScenePhase == .background) switch newScenePhase { case .background: Logger.services.info("🎬 [App] Scene is in the background") + // Stop Session Replay when app goes to background to prevent crashes + // from accessing SwiftUI view hierarchy while backgrounded + SessionReplay.stopRecording() accessoryManager.appDidEnterBackground() do { try persistenceController.container.viewContext.save() @@ -209,6 +213,8 @@ struct MeshtasticAppleApp: App { Logger.services.info("🎬 [App] Scene is inactive") case .active: Logger.services.info("🎬 [App] Scene is active") + // Resume Session Replay when app becomes active + SessionReplay.startRecording() accessoryManager.appDidBecomeActive() @unknown default: Logger.services.error("🍎 [App] Apple must have changed something") diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index 19521601..2658a4bf 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -25,6 +25,10 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat if locationsHandler.backgroundActivity { locationsHandler.backgroundActivity = true } + // Initialize TAK Server if enabled + Task { @MainActor in + TAKServerManager.shared.initializeOnStartup() + } return true } // Lets us show the notification in the app in the foreground diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index c56644a5..ed5b2af2 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -8,263 +8,398 @@ import CoreData import MeshtasticProtobufs import OSLog -public func clearStaleNodes(nodeExpireDays: Int, context: NSManagedObjectContext) -> Bool { - var nodeExpireTime: TimeInterval { - return TimeInterval(-nodeExpireDays * 86400) +extension MeshPackets { + public func clearStaleNodes(nodeExpireDays: Int) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearStaleNodes(nodeExpireDays: nodeExpireDays, context: context) + } } - var nodePKIExpireTime: TimeInterval { - return TimeInterval((nodeExpireDays < 7 ? -7 : -nodeExpireDays) * 86400) - } - - if nodeExpireDays == 0 { - // Purge Disabled - Logger.data.info("💾 [NodeInfoEntity] Skip clearing stale nodes") + + nonisolated public func clearStaleNodes(nodeExpireDays: Int, context: NSManagedObjectContext) -> Bool { + var nodeExpireTime: TimeInterval { + return TimeInterval(-nodeExpireDays * 86400) + } + var nodePKIExpireTime: TimeInterval { + return TimeInterval((nodeExpireDays < 7 ? -7 : -nodeExpireDays) * 86400) + } + + if nodeExpireDays == 0 { + // Purge Disabled + Logger.data.info("💾 [NodeInfoEntity] Skip clearing stale nodes") + return false + } + let fetchRequest = NSFetchRequest(entityName: "NodeInfoEntity") + fetchRequest.predicate = NSPredicate(format: "favorite == false AND ignored == false AND ((user.pkiEncrypted == NO AND lastHeard < %@) OR (user.pkiEncrypted == YES AND lastHeard < %@))", + NSDate(timeIntervalSinceNow: nodeExpireTime), NSDate(timeIntervalSinceNow: nodePKIExpireTime)) + let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + batchDeleteRequest.resultType = .resultTypeCount + + do { + Logger.data.info("💾 [NodeInfoEntity] Clearing nodes older than \(nodeExpireDays) days") + if let batchDeleteResult = try context.execute(batchDeleteRequest) as? NSBatchDeleteResult { + try context.save() + let deletedNodes = batchDeleteResult.result as? Int ?? 0 + Logger.data.info("💾 [NodeInfoEntity] Cleared \(deletedNodes) stale nodes") + if deletedNodes > 0 { + return true + } + } else { + Logger.data.error("💥 [NodeInfoEntity] bad delete results") + } + } catch { + context.rollback() + Logger.data.error("💥 [NodeInfoEntity] Error deleting stale nodes") + } return false } - let fetchRequest = NSFetchRequest(entityName: "NodeInfoEntity") - fetchRequest.predicate = NSPredicate(format: "favorite == false AND ignored == false AND ((user.pkiEncrypted == NO AND lastHeard < %@) OR (user.pkiEncrypted == YES AND lastHeard < %@))", - NSDate(timeIntervalSinceNow: nodeExpireTime), NSDate(timeIntervalSinceNow: nodePKIExpireTime)) - let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) - batchDeleteRequest.resultType = .resultTypeCount - - do { - Logger.data.info("💾 [NodeInfoEntity] Clearing nodes older than \(nodeExpireDays) days") - if let batchDeleteResult = try context.execute(batchDeleteRequest) as? NSBatchDeleteResult { - try context.save() - let deletedNodes = batchDeleteResult.result as? Int ?? 0 - Logger.data.info("💾 [NodeInfoEntity] Cleared \(deletedNodes) stale nodes") - if deletedNodes > 0 { + + func clearPax(destNum: Int64) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearPax(destNum: destNum, context: context) + } + } + + nonisolated public func clearPax(destNum: Int64, context: NSManagedObjectContext) -> Bool { + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + let newPax = [PaxCounterLog]() + fetchedNode[0].pax? = NSOrderedSet(array: newPax) + do { + try context.save() return true + + } catch { + context.rollback() + return false } - } else { - Logger.data.error("💥 [NodeInfoEntity] bad delete results") - } - } catch { - context.rollback() - Logger.data.error("💥 [NodeInfoEntity] Error deleting stale nodes") - } - return false -} - -public func clearPax(destNum: Int64, context: NSManagedObjectContext) -> Bool { - - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) - - do { - let fetchedNode = try context.fetch(fetchNodeInfoRequest) - let newPax = [PaxCounterLog]() - fetchedNode[0].pax? = NSOrderedSet(array: newPax) - do { - try context.save() - return true - } catch { - context.rollback() + Logger.data.error("💥 [NodeInfoEntity] fetch data error") return false } - } catch { - Logger.data.error("💥 [NodeInfoEntity] fetch data error") - return false } -} - -public func clearPositions(destNum: Int64, context: NSManagedObjectContext) -> Bool { - - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) - - do { - let fetchedNode = try context.fetch(fetchNodeInfoRequest) - let newPostions = [PositionEntity]() - fetchedNode[0].positions? = NSOrderedSet(array: newPostions) + + public func clearPositions(destNum: Int64) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearPositions(destNum: destNum, context: context) + } + } + + nonisolated public func clearPositions(destNum: Int64, context: NSManagedObjectContext) -> Bool { + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) + do { - try context.save() - return true - + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + let newPostions = [PositionEntity]() + fetchedNode[0].positions? = NSOrderedSet(array: newPostions) + do { + try context.save() + return true + + } catch { + context.rollback() + return false + } } catch { - context.rollback() + Logger.data.error("💥 [NodeInfoEntity] fetch data error") return false } - } catch { - Logger.data.error("💥 [NodeInfoEntity] fetch data error") - return false } -} - -public func clearTelemetry(destNum: Int64, metricsType: Int32, context: NSManagedObjectContext) -> Bool { - - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) - - do { - let fetchedNode = try context.fetch(fetchNodeInfoRequest) - let emptyTelemetry = [TelemetryEntity]() - fetchedNode[0].telemetries? = NSOrderedSet(array: emptyTelemetry) + + public func clearTelemetry(destNum: Int64, metricsType: Int32) async -> Bool { + let context = self.backgroundContext + return await context.perform { + return self.clearTelemetry(destNum: destNum, metricsType: metricsType, context: context) + } + } + + nonisolated public func clearTelemetry(destNum: Int64, metricsType: Int32, context: NSManagedObjectContext) -> Bool { + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(destNum)) + do { - try context.save() - return true - + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + let emptyTelemetry = [TelemetryEntity]() + fetchedNode[0].telemetries? = NSOrderedSet(array: emptyTelemetry) + do { + try context.save() + return true + + } catch { + context.rollback() + return false + } } catch { - context.rollback() + Logger.data.error("💥 [NodeInfoEntity] fetch data error") return false } - } catch { - Logger.data.error("💥 [NodeInfoEntity] fetch data error") - return false } -} - -public func deleteChannelMessages(channel: ChannelEntity, context: NSManagedObjectContext) { - do { - let objects = channel.allPrivateMessages - for object in objects { - context.delete(object) - } - try context.save() - } catch let error as NSError { - Logger.data.error("\(error.localizedDescription, privacy: .public)") - } -} - -public func deleteUserMessages(user: UserEntity, context: NSManagedObjectContext) { - - do { - let objects = user.messageList - for object in objects { - context.delete(object) - } - try context.save() - } catch let error as NSError { - Logger.data.error("\(error.localizedDescription, privacy: .public)") - } -} - -public func clearCoreDataDatabase(context: NSManagedObjectContext, includeRoutes: Bool) { - - let persistenceController = PersistenceController.shared.container - for i in 0...persistenceController.managedObjectModel.entities.count-1 { - - let entity = persistenceController.managedObjectModel.entities[i] - let query = NSFetchRequest(entityName: entity.name!) - var deleteRequest = NSBatchDeleteRequest(fetchRequest: query) - let entityName = entity.name ?? "UNK" - - if includeRoutes { - deleteRequest = NSBatchDeleteRequest(fetchRequest: query) - } else if !includeRoutes { - if !(entityName.contains("RouteEntity") || entityName.contains("LocationEntity")) { - deleteRequest = NSBatchDeleteRequest(fetchRequest: query) + + public func deleteChannelMessages(channel: ChannelEntity) async { + let context = self.backgroundContext + let objectId = channel.objectID + await context.perform { + if let channelObject = context.object(with: objectId) as? ChannelEntity { + self.deleteChannelMessages(channel: channelObject, context: context) } } + } + + nonisolated public func deleteChannelMessages(channel: ChannelEntity, context: NSManagedObjectContext) { do { - try context.executeAndMergeChanges(using: deleteRequest) - } catch { + let objects = channel.allPrivateMessages + for object in objects { + context.delete(object) + } + try context.save() + } catch let error as NSError { Logger.data.error("\(error.localizedDescription, privacy: .public)") } } -} - -func updateAnyPacketFrom (packet: MeshPacket, activeDeviceNum: Int64, context: NSManagedObjectContext) { - // Update NodeInfoEntity for any packet received. This mirrors the firmware's NodeDB::updateFrom, which sniffs ALL received packets and updates the radio's nodeDB with packet.from's: - // - last_heard (from rxTime) - // - snr - // - via_mqtt - // - hops_away - - // However, unlike the firmware, this function will NOT create a new NodeInfoEntity if we don't have it already. We'll leave that to the existing code paths. - - // We do NOT update fetchedNode[0].channel, because we may hear a node over multiple channels, and only some packet types should update what we consider the node's channel to be. (Example: primary private channel, secondary public channel. A text message on the secondary public channel should NOT change fetchedNode[0].channel.) - - guard packet.from > 0 else { return } - guard packet.from != activeDeviceNum else { return } // Ignore if packet is from our own node - - let fetchNodeInfoAppRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoAppRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) - - do { - let fetchedNode = try context.fetch(fetchNodeInfoAppRequest) - if fetchedNode.count >= 1 { - fetchedNode[0].id = Int64(packet.from) - fetchedNode[0].num = Int64(packet.from) - - if packet.rxTime > 0 { - fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) - Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) lastHeard from rxTime=\(packet.rxTime)") - } else { - fetchedNode[0].lastHeard = Date() - Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) lastHeard to now (rxTime==0)") - } - - fetchedNode[0].snr = packet.rxSnr - fetchedNode[0].rssi = packet.rxRssi - fetchedNode[0].viaMqtt = packet.viaMqtt - - if packet.hopStart != 0 && packet.hopLimit <= packet.hopStart { - fetchedNode[0].hopsAway = Int32(packet.hopStart - packet.hopLimit) - Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) hopsAway=\(fetchedNode[0].hopsAway)") - } - - do { - try context.save() - Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(fetchedNode[0].num.toHex(), privacy: .public) snr=\(fetchedNode[0].snr), rssi=\(fetchedNode[0].rssi) from packet \(packet.id.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [updateAnyPacketFrom] Error Saving node \(fetchedNode[0].num.toHex(), privacy: .public) from packet \(packet.id.toHex(), privacy: .public) \(nsError, privacy: .public)") + + public func deleteUserMessages(user: UserEntity) async { + let context = self.backgroundContext + let objectId = user.objectID + await context.perform { + if let userObject = context.object(with: objectId) as? UserEntity { + self.deleteUserMessages(user: userObject, context: context) } } - } catch { - Logger.data.error("💥 [updateAnyPacketFrom] fetch data error") } -} - -func upsertNodeInfoPacket (packet: MeshPacket, favorite: Bool = false, context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, packet.from.toHex()) - Logger.mesh.info("📟 \(logString, privacy: .public)") - - guard packet.from > 0 else { return } - - let fetchNodeInfoAppRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoAppRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) - - do { - - let fetchedNode = try context.fetch(fetchNodeInfoAppRequest) - if fetchedNode.count == 0 { - // Not Found Insert - let newNode = NodeInfoEntity(context: context) - newNode.id = Int64(packet.from) - newNode.num = Int64(packet.from) - newNode.favorite = favorite - if packet.rxTime > 0 { - newNode.firstHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) - newNode.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) - } else { - newNode.firstHeard = Date() - newNode.lastHeard = Date() + + nonisolated public func deleteUserMessages(user: UserEntity, context: NSManagedObjectContext) { + do { + let objects = user.messageList + for object in objects { + context.delete(object) } - newNode.snr = packet.rxSnr - newNode.rssi = packet.rxRssi - newNode.viaMqtt = packet.viaMqtt - - if packet.to == Constants.maximumNodeNum || packet.to == UserDefaults.preferredPeripheralNum { - newNode.channel = Int32(packet.channel) - } - if let nodeInfoMessage = try? NodeInfo(serializedBytes: packet.decoded.payload) { - if nodeInfoMessage.hasHopsAway { - newNode.hopsAway = Int32(nodeInfoMessage.hopsAway) + try context.save() + } catch let error as NSError { + Logger.data.error("\(error.localizedDescription, privacy: .public)") + } + } + + public func clearCoreDataDatabase(includeRoutes: Bool) async { + let context = self.backgroundContext + await context.perform { + self.clearCoreDataDatabase(context: context, includeRoutes: includeRoutes) + } + } + + nonisolated public func clearCoreDataDatabase(context: NSManagedObjectContext, includeRoutes: Bool) { + let persistenceController = PersistenceController.shared.container + for i in 0...persistenceController.managedObjectModel.entities.count-1 { + + let entity = persistenceController.managedObjectModel.entities[i] + let query = NSFetchRequest(entityName: entity.name!) + var deleteRequest = NSBatchDeleteRequest(fetchRequest: query) + let entityName = entity.name ?? "UNK" + + if includeRoutes { + deleteRequest = NSBatchDeleteRequest(fetchRequest: query) + } else if !includeRoutes { + if !(entityName.contains("RouteEntity") || entityName.contains("LocationEntity")) { + deleteRequest = NSBatchDeleteRequest(fetchRequest: query) } - newNode.favorite = nodeInfoMessage.isFavorite } - - if let newUserMessage = try? User(serializedBytes: packet.decoded.payload) { - - if newUserMessage.id.isEmpty { + do { + try context.executeAndMergeChanges(using: deleteRequest) + } catch { + Logger.data.error("\(error.localizedDescription, privacy: .public)") + } + } + } + + func updateAnyPacketFrom (packet: MeshPacket, activeDeviceNum: Int64) async { + let context = self.backgroundContext + await context.perform { + self.updateAnyPacketFrom(packet: packet, activeDeviceNum: activeDeviceNum, context: context) + } + } + + nonisolated func updateAnyPacketFrom (packet: MeshPacket, activeDeviceNum: Int64, context: NSManagedObjectContext) { + // Update NodeInfoEntity for any packet received. This mirrors the firmware's NodeDB::updateFrom, which sniffs ALL received packets and updates the radio's nodeDB with packet.from's: + // - last_heard (from rxTime) + // - snr + // - via_mqtt + // - hops_away + + // However, unlike the firmware, this function will NOT create a new NodeInfoEntity if we don't have it already. We'll leave that to the existing code paths. + + // We do NOT update fetchedNode[0].channel, because we may hear a node over multiple channels, and only some packet types should update what we consider the node's channel to be. (Example: primary private channel, secondary public channel. A text message on the secondary public channel should NOT change fetchedNode[0].channel.) + + guard packet.from > 0 else { return } + guard packet.from != activeDeviceNum else { return } // Ignore if packet is from our own node + + let fetchNodeInfoAppRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoAppRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoAppRequest) + if fetchedNode.count >= 1 { + fetchedNode[0].id = Int64(packet.from) + fetchedNode[0].num = Int64(packet.from) + + if packet.rxTime > 0 { + fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) lastHeard from rxTime=\(packet.rxTime)") + } else { + fetchedNode[0].lastHeard = Date() + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) lastHeard to now (rxTime==0)") + } + + fetchedNode[0].snr = packet.rxSnr + fetchedNode[0].rssi = packet.rxRssi + fetchedNode[0].viaMqtt = packet.viaMqtt + + if packet.hopStart != 0 && packet.hopLimit <= packet.hopStart { + fetchedNode[0].hopsAway = Int32(packet.hopStart - packet.hopLimit) + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(packet.from.toHex(), privacy: .public) hopsAway=\(fetchedNode[0].hopsAway)") + } + + do { + try context.save() + Logger.data.info("💾 [updateAnyPacketFrom] Updating node \(fetchedNode[0].num.toHex(), privacy: .public) snr=\(fetchedNode[0].snr), rssi=\(fetchedNode[0].rssi) from packet \(packet.id.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [updateAnyPacketFrom] Error Saving node \(fetchedNode[0].num.toHex(), privacy: .public) from packet \(packet.id.toHex(), privacy: .public) \(nsError, privacy: .public)") + } + } + } catch { + Logger.data.error("💥 [updateAnyPacketFrom] fetch data error") + } + } + + func upsertNodeInfoPacket (packet: MeshPacket, favorite: Bool = false) async { + let context = self.backgroundContext + await context.perform { + self.upsertNodeInfoPacket(packet: packet, favorite: favorite, context: context) + } + } + + nonisolated func upsertNodeInfoPacket (packet: MeshPacket, favorite: Bool = false, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, packet.from.toHex()) + Logger.mesh.info("📟 \(logString, privacy: .public)") + + guard packet.from > 0 else { return } + + let fetchNodeInfoAppRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoAppRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + + let fetchedNode = try context.fetch(fetchNodeInfoAppRequest) + if fetchedNode.count == 0 { + // Not Found Insert + let newNode = NodeInfoEntity(context: context) + newNode.id = Int64(packet.from) + newNode.num = Int64(packet.from) + newNode.favorite = favorite + if packet.rxTime > 0 { + newNode.firstHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + newNode.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + } else { + newNode.firstHeard = Date() + newNode.lastHeard = Date() + } + newNode.snr = packet.rxSnr + newNode.rssi = packet.rxRssi + newNode.viaMqtt = packet.viaMqtt + + if packet.to == Constants.maximumNodeNum || packet.to == UserDefaults.preferredPeripheralNum { + newNode.channel = Int32(packet.channel) + } + if let nodeInfoMessage = try? NodeInfo(serializedBytes: packet.decoded.payload) { + if nodeInfoMessage.hasHopsAway { + newNode.hopsAway = Int32(nodeInfoMessage.hopsAway) + } + newNode.favorite = nodeInfoMessage.isFavorite + } + + if let newUserMessage = try? User(serializedBytes: packet.decoded.payload) { + + if newUserMessage.id.isEmpty { + if packet.from > Constants.minimumNodeNum { + do { + let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) + newNode.user = 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)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + } else { + + let newUser = UserEntity(context: context) + newUser.userId = newNode.num.toHex() + newUser.num = Int64(packet.from) + newUser.longName = newUserMessage.longName + newUser.shortName = newUserMessage.shortName + newUser.role = Int32(newUserMessage.role.rawValue) + newUser.hwModel = String(describing: newUserMessage.hwModel).uppercased() + newUser.hwModelId = Int32(newUserMessage.hwModel.rawValue) + /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default + if newUserMessage.hasIsUnmessagable { + newUser.unmessagable = newUserMessage.isUnmessagable + } else { + let roles = [2, 4, 5, 6, 7, 10, 11] + let containsRole = roles.contains(Int(newUser.role)) + if containsRole { + newUser.unmessagable = true + } else { + newUser.unmessagable = false + } + } + if !newUserMessage.publicKey.isEmpty { + newUser.pkiEncrypted = true + newUser.publicKey = newUserMessage.publicKey + } + + Task { + Api().loadDeviceHardwareData { (hw) in + let dh = hw.first(where: { $0.hwModel == newUser.hwModelId }) + newUser.hwDisplayName = dh?.displayName + } + } + newNode.user = newUser + + if UserDefaults.newNodeNotifications { + Task { @MainActor in + let manager = LocalNotificationManager() + manager.notifications = [ + Notification( + id: (UUID().uuidString), + title: "New Node".localized, + subtitle: "\(newUser.longName ?? "Unknown".localized)", + content: "New Node has been discovered".localized, + target: "nodes", + path: "meshtastic:///nodes?nodenum=\(newUser.num)" + ) + ] + manager.schedule() + } + } + } + } else { if packet.from > Constants.minimumNodeNum { do { let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) + if !packet.publicKey.isEmpty { + newNode.user?.pkiEncrypted = true + newNode.user?.publicKey = packet.publicKey + } newNode.user = 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)") @@ -272,1306 +407,1382 @@ func upsertNodeInfoPacket (packet: MeshPacket, favorite: Bool = false, context: Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") } } - } else { - - let newUser = UserEntity(context: context) - newUser.userId = newNode.num.toHex() - newUser.num = Int64(packet.from) - newUser.longName = newUserMessage.longName - newUser.shortName = newUserMessage.shortName - newUser.role = Int32(newUserMessage.role.rawValue) - newUser.hwModel = String(describing: newUserMessage.hwModel).uppercased() - newUser.hwModelId = Int32(newUserMessage.hwModel.rawValue) - /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default - if newUserMessage.hasIsUnmessagable { - newUser.unmessagable = newUserMessage.isUnmessagable - } else { - let roles = [2, 4, 5, 6, 7, 10, 11] - let containsRole = roles.contains(Int(newUser.role)) - if containsRole { - newUser.unmessagable = true - } else { - newUser.unmessagable = false - } - } - if !newUserMessage.publicKey.isEmpty { - newUser.pkiEncrypted = true - newUser.publicKey = newUserMessage.publicKey - } - - Task { - Api().loadDeviceHardwareData { (hw) in - let dh = hw.first(where: { $0.hwModel == newUser.hwModelId }) - newUser.hwDisplayName = dh?.displayName - } - } - newNode.user = newUser - - if UserDefaults.newNodeNotifications { - let manager = LocalNotificationManager() - manager.notifications = [ - Notification( - id: (UUID().uuidString), - title: "New Node".localized, - subtitle: "\(newUser.longName ?? "Unknown".localized)", - content: "New Node has been discovered".localized, - target: "nodes", - path: "meshtastic:///nodes?nodenum=\(newUser.num)" - ) - ] - manager.schedule() - } } - } else { - if packet.from > Constants.minimumNodeNum { + // User is messed up and has failed to create at least once, if this fails bail out + if newNode.user == nil && packet.from > Constants.minimumNodeNum { do { - let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) - if !packet.publicKey.isEmpty { - newNode.user?.pkiEncrypted = true - newNode.user?.publicKey = packet.publicKey - } + let newUser = try createUser(num: Int64(packet.from), context: context) newNode.user = 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)") + context.rollback() + return } catch { Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") - } - } - } - // User is messed up and has failed to create at least once, if this fails bail out - if newNode.user == nil && packet.from > Constants.minimumNodeNum { - do { - let newUser = try createUser(num: Int64(packet.from), context: context) - newNode.user = 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)") - context.rollback() - return - } catch { - Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") - context.rollback() - return - } - } - - 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)") - } - - } else { - // Update an existing node - if packet.to == Constants.maximumNodeNum || packet.to == UserDefaults.preferredPeripheralNum { - fetchedNode[0].channel = Int32(packet.channel) - } - - if let nodeInfoMessage = try? NodeInfo(serializedBytes: packet.decoded.payload) { - - fetchedNode[0].hopsAway = Int32(nodeInfoMessage.hopsAway) - fetchedNode[0].favorite = nodeInfoMessage.isFavorite - if nodeInfoMessage.hasDeviceMetrics { - let telemetry = TelemetryEntity(context: context) - telemetry.batteryLevel = Int32(nodeInfoMessage.deviceMetrics.batteryLevel) - telemetry.voltage = nodeInfoMessage.deviceMetrics.voltage - telemetry.channelUtilization = nodeInfoMessage.deviceMetrics.channelUtilization - telemetry.airUtilTx = nodeInfoMessage.deviceMetrics.airUtilTx - var newTelemetries = [TelemetryEntity]() - newTelemetries.append(telemetry) - fetchedNode[0].telemetries? = NSOrderedSet(array: newTelemetries) - } - if nodeInfoMessage.hasUser { - fetchedNode[0].user?.userId = nodeInfoMessage.num.toHex() - fetchedNode[0].user?.num = Int64(nodeInfoMessage.num) - fetchedNode[0].user?.longName = nodeInfoMessage.user.longName - fetchedNode[0].user?.shortName = nodeInfoMessage.user.shortName - fetchedNode[0].user?.role = Int32(nodeInfoMessage.user.role.rawValue) - fetchedNode[0].user?.hwModel = String(describing: nodeInfoMessage.user.hwModel).uppercased() - fetchedNode[0].user?.hwModelId = Int32(nodeInfoMessage.user.hwModel.rawValue) - /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default - if nodeInfoMessage.user.hasIsUnmessagable { - fetchedNode[0].user?.unmessagable = nodeInfoMessage.user.isUnmessagable - } else { - let roles = [-1, 2, 4, 5, 6, 7, 10, 11] - let containsRole = roles.contains(Int(fetchedNode[0].user?.role ?? -1)) - if containsRole { - fetchedNode[0].user?.unmessagable = true - } else { - fetchedNode[0].user?.unmessagable = false - } - } - if !nodeInfoMessage.user.publicKey.isEmpty { - fetchedNode[0].user?.pkiEncrypted = true - fetchedNode[0].user?.publicKey = nodeInfoMessage.user.publicKey - } - Task { - Api().loadDeviceHardwareData { (hw) in - let dh = hw.first(where: { $0.hwModel == fetchedNode[0].user?.hwModelId ?? 0 }) - fetchedNode[0].user?.hwDisplayName = dh?.displayName - } - } - } - } else if packet.hopStart != 0 && packet.hopLimit <= packet.hopStart { - fetchedNode[0].hopsAway = Int32(packet.hopStart - packet.hopLimit) - } - if fetchedNode[0].user == nil { - do { - let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) - fetchedNode[0].user = newUser - } catch CoreDataError.invalidInput(let message) { - Logger.data.error("Error Creating a new Core Data UserEntity on an existing node (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") - } catch { - Logger.data.error("Error Creating a new Core Data UserEntity on an existing node from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") - } - } - do { - try context.save() - Logger.data.info("💾 [NodeInfoEntity] Updated from Node Info App Packet For: \(fetchedNode[0].num.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [NodeInfoEntity] Error Saving from NODEINFO_APP \(nsError, privacy: .public)") - } - } - } catch { - Logger.data.error("💥 [NodeInfoEntity] fetch data error for NODEINFO_APP") - } -} - -func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("[Position] received from node: %@".localized, String(packet.from)) - Logger.mesh.info("📍 \(logString, privacy: .public)") - - let fetchNodePositionRequest = NodeInfoEntity.fetchRequest() - fetchNodePositionRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) - - do { - - if let positionMessage = try? Position(serializedBytes: packet.decoded.payload) { - - /// Don't save empty position packets from null island or apple park - if (positionMessage.longitudeI != 0 && positionMessage.latitudeI != 0) && (positionMessage.latitudeI != 373346000 && positionMessage.longitudeI != -1220090000) { - let fetchedNode = try context.fetch(fetchNodePositionRequest) - if fetchedNode.count == 1 { - - // Unset the current latest position for this node - let fetchCurrentLatestPositionsRequest = PositionEntity.fetchRequest() - fetchCurrentLatestPositionsRequest.predicate = NSPredicate(format: "nodePosition.num == %lld && latest = true", Int64(packet.from)) - - let fetchedPositions = try context.fetch(fetchCurrentLatestPositionsRequest) - if fetchedPositions.count > 0 { - for position in fetchedPositions { - position.latest = false - } - } - let position = PositionEntity(context: context) - position.latest = true - position.snr = packet.rxSnr - position.rssi = packet.rxRssi - position.seqNo = Int32(positionMessage.seqNumber) - position.latitudeI = positionMessage.latitudeI - position.longitudeI = positionMessage.longitudeI - position.altitude = positionMessage.altitude - position.satsInView = Int32(positionMessage.satsInView) - position.speed = Int32(positionMessage.groundSpeed) - let heading = Int32(positionMessage.groundTrack) - // Throw out bad haeadings from the device - if heading >= 0 && heading <= 360 { - position.heading = Int32(positionMessage.groundTrack) - } - position.precisionBits = Int32(positionMessage.precisionBits) - if positionMessage.timestamp != 0 { - position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.timestamp))) - } else { - position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) - } - guard let mutablePositions = fetchedNode[0].positions?.mutableCopy() as? NSMutableOrderedSet else { + context.rollback() return } - /// Don't save nearly the same position over and over. If the next position is less than 10 meters from the new position, delete the previous position and save the new one. - if mutablePositions.count > 0 && (position.precisionBits == 32 || position.precisionBits == 0) { - if let mostRecent = mutablePositions.lastObject as? PositionEntity, mostRecent.coordinate.distance(from: position.coordinate) < 9.0 { - mutablePositions.remove(mostRecent) - } - } else if mutablePositions.count > 0 { - /// Don't store any history for reduced accuracy positions, we will just show a circle - mutablePositions.removeAllObjects() - } - mutablePositions.add(position) - + } + + 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)") + } + + } else { + // Update an existing node + if packet.to == Constants.maximumNodeNum || packet.to == UserDefaults.preferredPeripheralNum { fetchedNode[0].channel = Int32(packet.channel) - fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet - + } + + if let nodeInfoMessage = try? NodeInfo(serializedBytes: packet.decoded.payload) { + + fetchedNode[0].hopsAway = Int32(nodeInfoMessage.hopsAway) + fetchedNode[0].favorite = nodeInfoMessage.isFavorite + if nodeInfoMessage.hasDeviceMetrics { + let telemetry = TelemetryEntity(context: context) + telemetry.batteryLevel = Int32(nodeInfoMessage.deviceMetrics.batteryLevel) + telemetry.voltage = nodeInfoMessage.deviceMetrics.voltage + telemetry.channelUtilization = nodeInfoMessage.deviceMetrics.channelUtilization + telemetry.airUtilTx = nodeInfoMessage.deviceMetrics.airUtilTx + var newTelemetries = [TelemetryEntity]() + newTelemetries.append(telemetry) + fetchedNode[0].telemetries? = NSOrderedSet(array: newTelemetries) + } + if nodeInfoMessage.hasUser { + fetchedNode[0].user?.userId = nodeInfoMessage.num.toHex() + fetchedNode[0].user?.num = Int64(nodeInfoMessage.num) + fetchedNode[0].user?.longName = nodeInfoMessage.user.longName + fetchedNode[0].user?.shortName = nodeInfoMessage.user.shortName + fetchedNode[0].user?.role = Int32(nodeInfoMessage.user.role.rawValue) + fetchedNode[0].user?.hwModel = String(describing: nodeInfoMessage.user.hwModel).uppercased() + fetchedNode[0].user?.hwModelId = Int32(nodeInfoMessage.user.hwModel.rawValue) + /// For nodes that have the optional isUnmessagable boolean use that, otherwise excluded roles that are unmessagable by default + if nodeInfoMessage.user.hasIsUnmessagable { + fetchedNode[0].user?.unmessagable = nodeInfoMessage.user.isUnmessagable + } else { + let roles = [-1, 2, 4, 5, 6, 7, 10, 11] + let containsRole = roles.contains(Int(fetchedNode[0].user?.role ?? -1)) + if containsRole { + fetchedNode[0].user?.unmessagable = true + } else { + fetchedNode[0].user?.unmessagable = false + } + } + if !nodeInfoMessage.user.publicKey.isEmpty { + fetchedNode[0].user?.pkiEncrypted = true + fetchedNode[0].user?.publicKey = nodeInfoMessage.user.publicKey + } + Task { + Api().loadDeviceHardwareData { (hw) in + let dh = hw.first(where: { $0.hwModel == fetchedNode[0].user?.hwModelId ?? 0 }) + fetchedNode[0].user?.hwDisplayName = dh?.displayName + } + } + } + } else if packet.hopStart != 0 && packet.hopLimit <= packet.hopStart { + fetchedNode[0].hopsAway = Int32(packet.hopStart - packet.hopLimit) + } + if fetchedNode[0].user == nil { do { - try context.save() - Logger.data.info("💾 [Position] Saved from Position App Packet For: \(fetchedNode[0].num.toHex(), privacy: .public)") + let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) + fetchedNode[0].user = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity on an existing node (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 Error Saving NodeInfoEntity from POSITION_APP \(nsError, privacy: .public)") + Logger.data.error("Error Creating a new Core Data UserEntity on an existing node from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") } } - } else { - Logger.data.error("💥 Empty POSITION_APP Packet: \((try? packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") - } - } - } catch { - Logger.data.error("💥 Error Deserializing POSITION_APP packet.") - } -} - -func upsertBluetoothConfigPacket(config: Config.BluetoothConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Bluetooth config received: %@".localized, String(nodeNum)) - Logger.mesh.info("📶 \(logString, privacy: .public)") - - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) - - do { - let fetchedNode = try context.fetch(fetchNodeInfoRequest) - // Found a node, save Device Config - if !fetchedNode.isEmpty { - if fetchedNode[0].bluetoothConfig == nil { - let newBluetoothConfig = BluetoothConfigEntity(context: context) - newBluetoothConfig.enabled = config.enabled - newBluetoothConfig.mode = Int32(config.mode.rawValue) - newBluetoothConfig.fixedPin = Int32(config.fixedPin) - fetchedNode[0].bluetoothConfig = newBluetoothConfig - } else { - fetchedNode[0].bluetoothConfig?.enabled = config.enabled - fetchedNode[0].bluetoothConfig?.mode = Int32(config.mode.rawValue) - fetchedNode[0].bluetoothConfig?.fixedPin = Int32(config.fixedPin) - } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [BluetoothConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [BluetoothConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [BluetoothConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Bluetooth Config") - } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [BluetoothConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") - } -} - -func upsertDeviceConfigPacket(config: Config.DeviceConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Device config received: %@".localized, String(nodeNum)) - Logger.mesh.info("📟 \(logString, privacy: .public)") - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) - - do { - let fetchedNode = try context.fetch(fetchNodeInfoRequest) - // Found a node, save Device Config - if !fetchedNode.isEmpty { - if fetchedNode[0].deviceConfig == nil { - let newDeviceConfig = DeviceConfigEntity(context: context) - newDeviceConfig.role = Int32(config.role.rawValue) - newDeviceConfig.buttonGpio = Int32(config.buttonGpio) - newDeviceConfig.buzzerGpio = Int32(config.buzzerGpio) - newDeviceConfig.rebroadcastMode = Int32(config.rebroadcastMode.rawValue) - newDeviceConfig.nodeInfoBroadcastSecs = Int32(truncating: config.nodeInfoBroadcastSecs as NSNumber) - newDeviceConfig.doubleTapAsButtonPress = config.doubleTapAsButtonPress - newDeviceConfig.tripleClickAsAdHocPing = !config.disableTripleClick - newDeviceConfig.ledHeartbeatEnabled = !config.ledHeartbeatDisabled - newDeviceConfig.isManaged = config.isManaged - newDeviceConfig.tzdef = config.tzdef - fetchedNode[0].deviceConfig = newDeviceConfig - } else { - fetchedNode[0].deviceConfig?.role = Int32(config.role.rawValue) - fetchedNode[0].deviceConfig?.buttonGpio = Int32(config.buttonGpio) - fetchedNode[0].deviceConfig?.buzzerGpio = Int32(config.buzzerGpio) - fetchedNode[0].deviceConfig?.rebroadcastMode = Int32(config.rebroadcastMode.rawValue) - fetchedNode[0].deviceConfig?.nodeInfoBroadcastSecs = Int32(truncating: config.nodeInfoBroadcastSecs as NSNumber) - fetchedNode[0].deviceConfig?.doubleTapAsButtonPress = config.doubleTapAsButtonPress - fetchedNode[0].deviceConfig?.tripleClickAsAdHocPing = !config.disableTripleClick - fetchedNode[0].deviceConfig?.ledHeartbeatEnabled = !config.ledHeartbeatDisabled - fetchedNode[0].deviceConfig?.isManaged = config.isManaged - fetchedNode[0].deviceConfig?.tzdef = config.tzdef - } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [DeviceConfigEntity] Updated Device Config for node number: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [DeviceConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [DeviceConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") - } -} - -func upsertDisplayConfigPacket(config: Config.DisplayConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Display config received: %@".localized, nodeNum.toHex()) - 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) - // Found a node, save Device Config - if !fetchedNode.isEmpty { - - if fetchedNode[0].displayConfig == nil { - - let newDisplayConfig = DisplayConfigEntity(context: context) - newDisplayConfig.screenOnSeconds = Int32(truncatingIfNeeded: config.screenOnSecs) - newDisplayConfig.screenCarouselInterval = Int32(truncatingIfNeeded: config.autoScreenCarouselSecs) - newDisplayConfig.compassNorthTop = config.compassNorthTop - newDisplayConfig.flipScreen = config.flipScreen - newDisplayConfig.oledType = Int32(config.oled.rawValue) - newDisplayConfig.displayMode = Int32(config.displaymode.rawValue) - newDisplayConfig.units = Int32(config.units.rawValue) - newDisplayConfig.headingBold = config.headingBold - newDisplayConfig.use12HClock = config.use12HClock - fetchedNode[0].displayConfig = newDisplayConfig - } else { - fetchedNode[0].displayConfig?.screenOnSeconds = Int32(truncatingIfNeeded: config.screenOnSecs) - fetchedNode[0].displayConfig?.screenCarouselInterval = Int32(truncatingIfNeeded: config.autoScreenCarouselSecs) - fetchedNode[0].displayConfig?.compassNorthTop = config.compassNorthTop - fetchedNode[0].displayConfig?.flipScreen = config.flipScreen - fetchedNode[0].displayConfig?.oledType = Int32(config.oled.rawValue) - fetchedNode[0].displayConfig?.displayMode = Int32(config.displaymode.rawValue) - fetchedNode[0].displayConfig?.units = Int32(config.units.rawValue) - fetchedNode[0].displayConfig?.headingBold = config.headingBold - fetchedNode[0].displayConfig?.use12HClock = config.use12HClock - } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - - try context.save() - Logger.data.info("💾 [DisplayConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") - - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [DisplayConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [DisplayConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Display Config") - } - - } catch { - let nsError = error as NSError - Logger.data.error("💥 [DisplayConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") - } -} - -func upsertLoRaConfigPacket(config: Config.LoRaConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("LoRa config received: %@".localized, nodeNum.toHex()) - Logger.data.info("📻 \(logString, privacy: .public)") - - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", nodeNum) - do { - let fetchedNode = try context.fetch(fetchNodeInfoRequest) - // Found a node, save LoRa Config - if fetchedNode.count > 0 { - if fetchedNode[0].loRaConfig == nil { - // No lora config for node, save a new lora config - let newLoRaConfig = LoRaConfigEntity(context: context) - newLoRaConfig.regionCode = Int32(config.region.rawValue) - newLoRaConfig.usePreset = config.usePreset - newLoRaConfig.modemPreset = Int32(config.modemPreset.rawValue) - newLoRaConfig.bandwidth = Int32(config.bandwidth) - newLoRaConfig.spreadFactor = Int32(config.spreadFactor) - newLoRaConfig.codingRate = Int32(config.codingRate) - newLoRaConfig.frequencyOffset = config.frequencyOffset - newLoRaConfig.overrideFrequency = config.overrideFrequency - newLoRaConfig.overrideDutyCycle = config.overrideDutyCycle - newLoRaConfig.hopLimit = Int32(config.hopLimit) - newLoRaConfig.txPower = Int32(config.txPower) - newLoRaConfig.txEnabled = config.txEnabled - newLoRaConfig.channelNum = Int32(config.channelNum) - newLoRaConfig.sx126xRxBoostedGain = config.sx126XRxBoostedGain - newLoRaConfig.ignoreMqtt = config.ignoreMqtt - newLoRaConfig.okToMqtt = config.configOkToMqtt - fetchedNode[0].loRaConfig = newLoRaConfig - } else { - fetchedNode[0].loRaConfig?.regionCode = Int32(config.region.rawValue) - fetchedNode[0].loRaConfig?.usePreset = config.usePreset - fetchedNode[0].loRaConfig?.modemPreset = Int32(config.modemPreset.rawValue) - fetchedNode[0].loRaConfig?.bandwidth = Int32(config.bandwidth) - fetchedNode[0].loRaConfig?.spreadFactor = Int32(config.spreadFactor) - fetchedNode[0].loRaConfig?.codingRate = Int32(config.codingRate) - fetchedNode[0].loRaConfig?.frequencyOffset = config.frequencyOffset - fetchedNode[0].loRaConfig?.overrideFrequency = config.overrideFrequency - fetchedNode[0].loRaConfig?.overrideDutyCycle = config.overrideDutyCycle - fetchedNode[0].loRaConfig?.hopLimit = Int32(config.hopLimit) - fetchedNode[0].loRaConfig?.txPower = Int32(config.txPower) - fetchedNode[0].loRaConfig?.txEnabled = config.txEnabled - fetchedNode[0].loRaConfig?.channelNum = Int32(config.channelNum) - fetchedNode[0].loRaConfig?.sx126xRxBoostedGain = config.sx126XRxBoostedGain - fetchedNode[0].loRaConfig?.ignoreMqtt = config.ignoreMqtt - fetchedNode[0].loRaConfig?.okToMqtt = config.configOkToMqtt - fetchedNode[0].loRaConfig?.sx126xRxBoostedGain = config.sx126XRxBoostedGain - } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [LoRaConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [LoRaConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [LoRaConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Lora Config") - } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [LoRaConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") - } -} - -func upsertNetworkConfigPacket(config: Config.NetworkConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Network 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) - // Found a node, save WiFi Config - if !fetchedNode.isEmpty { - if fetchedNode[0].networkConfig == nil { - let newNetworkConfig = NetworkConfigEntity(context: context) - newNetworkConfig.wifiEnabled = config.wifiEnabled - newNetworkConfig.wifiSsid = config.wifiSsid - newNetworkConfig.wifiPsk = config.wifiPsk - newNetworkConfig.ethEnabled = config.ethEnabled - newNetworkConfig.enabledProtocols = Int32(config.enabledProtocols) - fetchedNode[0].networkConfig = newNetworkConfig - } else { - fetchedNode[0].networkConfig?.ethEnabled = config.ethEnabled - fetchedNode[0].networkConfig?.wifiEnabled = config.wifiEnabled - fetchedNode[0].networkConfig?.wifiSsid = config.wifiSsid - fetchedNode[0].networkConfig?.wifiPsk = config.wifiPsk - fetchedNode[0].networkConfig?.enabledProtocols = Int32(config.enabledProtocols) - } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [NetworkConfigEntity] Updated Network Config for node: \(nodeNum.toHex(), privacy: .public)") - - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [NetworkConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [NetworkConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Network Config") - } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [NetworkConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") - } -} - -func upsertPositionConfigPacket(config: Config.PositionConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Position 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) - // Found a node, save LoRa Config - if !fetchedNode.isEmpty { - if fetchedNode[0].positionConfig == nil { - let newPositionConfig = PositionConfigEntity(context: context) - newPositionConfig.smartPositionEnabled = config.positionBroadcastSmartEnabled - newPositionConfig.deviceGpsEnabled = config.gpsEnabled - newPositionConfig.gpsMode = Int32(truncatingIfNeeded: config.gpsMode.rawValue) - newPositionConfig.rxGpio = Int32(truncatingIfNeeded: config.rxGpio) - newPositionConfig.txGpio = Int32(truncatingIfNeeded: config.txGpio) - newPositionConfig.gpsEnGpio = Int32(truncatingIfNeeded: config.gpsEnGpio) - newPositionConfig.fixedPosition = config.fixedPosition - newPositionConfig.positionBroadcastSeconds = Int32(truncatingIfNeeded: config.positionBroadcastSecs) - newPositionConfig.broadcastSmartMinimumIntervalSecs = Int32(truncatingIfNeeded: config.broadcastSmartMinimumIntervalSecs) - newPositionConfig.broadcastSmartMinimumDistance = Int32(truncatingIfNeeded: config.broadcastSmartMinimumDistance) - newPositionConfig.positionFlags = Int32(truncatingIfNeeded: config.positionFlags) - newPositionConfig.gpsAttemptTime = 900 - newPositionConfig.gpsUpdateInterval = Int32(truncatingIfNeeded: config.gpsUpdateInterval) - fetchedNode[0].positionConfig = newPositionConfig - } else { - fetchedNode[0].positionConfig?.smartPositionEnabled = config.positionBroadcastSmartEnabled - fetchedNode[0].positionConfig?.deviceGpsEnabled = config.gpsEnabled - fetchedNode[0].positionConfig?.gpsMode = Int32(truncatingIfNeeded: config.gpsMode.rawValue) - fetchedNode[0].positionConfig?.rxGpio = Int32(truncatingIfNeeded: config.rxGpio) - fetchedNode[0].positionConfig?.txGpio = Int32(truncatingIfNeeded: config.txGpio) - fetchedNode[0].positionConfig?.gpsEnGpio = Int32(truncatingIfNeeded: config.gpsEnGpio) - fetchedNode[0].positionConfig?.fixedPosition = config.fixedPosition - fetchedNode[0].positionConfig?.positionBroadcastSeconds = Int32(truncatingIfNeeded: config.positionBroadcastSecs) - fetchedNode[0].positionConfig?.broadcastSmartMinimumIntervalSecs = Int32(truncatingIfNeeded: config.broadcastSmartMinimumIntervalSecs) - fetchedNode[0].positionConfig?.broadcastSmartMinimumDistance = Int32(truncatingIfNeeded: config.broadcastSmartMinimumDistance) - fetchedNode[0].positionConfig?.gpsAttemptTime = 900 - fetchedNode[0].positionConfig?.gpsUpdateInterval = Int32(truncatingIfNeeded: config.gpsUpdateInterval) - fetchedNode[0].positionConfig?.positionFlags = Int32(truncatingIfNeeded: config.positionFlags) - } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [PositionConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [PositionConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [PositionConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Position Config") - } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [PositionConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") - } -} - -func upsertPowerConfigPacket(config: Config.PowerConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - let logString = String.localizedStringWithFormat("Power 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) - // Found a node, save Power Config - if !fetchedNode.isEmpty { - if fetchedNode[0].powerConfig == nil { - let newPowerConfig = PowerConfigEntity(context: context) - newPowerConfig.adcMultiplierOverride = config.adcMultiplierOverride - newPowerConfig.deviceBatteryInaAddress = Int32(config.deviceBatteryInaAddress) - newPowerConfig.isPowerSaving = config.isPowerSaving - newPowerConfig.lsSecs = Int32(truncatingIfNeeded: config.lsSecs) - newPowerConfig.minWakeSecs = Int32(truncatingIfNeeded: config.minWakeSecs) - newPowerConfig.onBatteryShutdownAfterSecs = Int32(truncatingIfNeeded: config.onBatteryShutdownAfterSecs) - newPowerConfig.waitBluetoothSecs = Int32(truncatingIfNeeded: config.waitBluetoothSecs) - fetchedNode[0].powerConfig = newPowerConfig - } else { - fetchedNode[0].powerConfig?.adcMultiplierOverride = config.adcMultiplierOverride - fetchedNode[0].powerConfig?.deviceBatteryInaAddress = Int32(config.deviceBatteryInaAddress) - fetchedNode[0].powerConfig?.isPowerSaving = config.isPowerSaving - fetchedNode[0].powerConfig?.lsSecs = Int32(truncatingIfNeeded: config.lsSecs) - fetchedNode[0].powerConfig?.minWakeSecs = Int32(truncatingIfNeeded: config.minWakeSecs) - fetchedNode[0].powerConfig?.onBatteryShutdownAfterSecs = Int32(truncatingIfNeeded: config.onBatteryShutdownAfterSecs) - fetchedNode[0].powerConfig?.waitBluetoothSecs = Int32(truncatingIfNeeded: config.waitBluetoothSecs) - } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [PowerConfigEntity] Updated Power Config for node: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [PowerConfigEntity] Error Updating Core Data PowerConfigEntity: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [PowerConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Power Config") - } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [PowerConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") - } -} - -func upsertSecurityConfigPacket(config: Config.SecurityConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("mesh.log.security.config %@".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) - // Found a node, save Security Config - if !fetchedNode.isEmpty { - if fetchedNode[0].securityConfig == nil { - let newSecurityConfig = SecurityConfigEntity(context: context) - newSecurityConfig.publicKey = config.publicKey - newSecurityConfig.privateKey = config.privateKey - if config.adminKey.count > 0 { - newSecurityConfig.adminKey = config.adminKey[0] + do { + try context.save() + Logger.data.info("💾 [NodeInfoEntity] Updated from Node Info App Packet For: \(fetchedNode[0].num.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [NodeInfoEntity] Error Saving from NODEINFO_APP \(nsError, privacy: .public)") } - newSecurityConfig.isManaged = config.isManaged - newSecurityConfig.serialEnabled = config.serialEnabled - newSecurityConfig.debugLogApiEnabled = config.debugLogApiEnabled - newSecurityConfig.adminChannelEnabled = config.adminChannelEnabled - fetchedNode[0].securityConfig = newSecurityConfig - } else { - fetchedNode[0].securityConfig?.publicKey = config.publicKey - fetchedNode[0].securityConfig?.privateKey = config.privateKey - if config.adminKey.count > 0 { - fetchedNode[0].securityConfig?.adminKey = config.adminKey[0] - if config.adminKey.count > 1 { - fetchedNode[0].securityConfig?.adminKey2 = config.adminKey[1] + } + } catch { + Logger.data.error("💥 [NodeInfoEntity] fetch data error for NODEINFO_APP") + } + } + + func upsertPositionPacket (packet: MeshPacket) async { + let context = self.backgroundContext + await context.perform { + self.upsertPositionPacket(packet: packet, context: context) + } + } + + nonisolated func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("[Position] received from node: %@".localized, String(packet.from)) + Logger.mesh.info("📍 \(logString, privacy: .public)") + + let fetchNodePositionRequest = NodeInfoEntity.fetchRequest() + fetchNodePositionRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + + do { + + if let positionMessage = try? Position(serializedBytes: packet.decoded.payload) { + + /// Don't save empty position packets from null island or apple park + if (positionMessage.longitudeI != 0 && positionMessage.latitudeI != 0) && (positionMessage.latitudeI != 373346000 && positionMessage.longitudeI != -1220090000) { + let fetchedNode = try context.fetch(fetchNodePositionRequest) + if fetchedNode.count == 1 { + + // Unset the current latest position for this node + let fetchCurrentLatestPositionsRequest = PositionEntity.fetchRequest() + fetchCurrentLatestPositionsRequest.predicate = NSPredicate(format: "nodePosition.num == %lld && latest = true", Int64(packet.from)) + + let fetchedPositions = try context.fetch(fetchCurrentLatestPositionsRequest) + if fetchedPositions.count > 0 { + for position in fetchedPositions { + position.latest = false + } + } + let position = PositionEntity(context: context) + position.latest = true + position.snr = packet.rxSnr + position.rssi = packet.rxRssi + position.seqNo = Int32(positionMessage.seqNumber) + position.latitudeI = positionMessage.latitudeI + position.longitudeI = positionMessage.longitudeI + position.altitude = positionMessage.altitude + position.satsInView = Int32(positionMessage.satsInView) + position.speed = Int32(positionMessage.groundSpeed) + let heading = Int32(positionMessage.groundTrack) + // Throw out bad haeadings from the device + if heading >= 0 && heading <= 360 { + position.heading = Int32(positionMessage.groundTrack) + } + position.precisionBits = Int32(positionMessage.precisionBits) + if positionMessage.timestamp != 0 { + position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.timestamp))) + } else { + position.time = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) + } + guard let mutablePositions = fetchedNode[0].positions?.mutableCopy() as? NSMutableOrderedSet else { + return + } + /// Don't save nearly the same position over and over. If the next position is less than 10 meters from the new position, delete the previous position and save the new one. + if mutablePositions.count > 0 && (position.precisionBits == 32 || position.precisionBits == 0) { + if let mostRecent = mutablePositions.lastObject as? PositionEntity, mostRecent.coordinate.distance(from: position.coordinate) < 9.0 { + mutablePositions.remove(mostRecent) + } + } else if mutablePositions.count > 0 { + /// Don't store any history for reduced accuracy positions, we will just show a circle + mutablePositions.removeAllObjects() + } + mutablePositions.add(position) + + fetchedNode[0].channel = Int32(packet.channel) + fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet + + do { + try context.save() + Logger.data.info("💾 [Position] Saved from Position App Packet For: \(fetchedNode[0].num.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 Error Saving NodeInfoEntity from POSITION_APP \(nsError, privacy: .public)") + } } - if config.adminKey.count > 2 { - fetchedNode[0].securityConfig?.adminKey3 = config.adminKey[2] + } else { + Logger.data.error("💥 Empty POSITION_APP Packet: \((try? packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") + } + } + } catch { + Logger.data.error("💥 Error Deserializing POSITION_APP packet.") + } + } + + func upsertBluetoothConfigPacket(config: Config.BluetoothConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertBluetoothConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertBluetoothConfigPacket(config: Config.BluetoothConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Bluetooth config received: %@".localized, String(nodeNum)) + Logger.mesh.info("📶 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].bluetoothConfig == nil { + let newBluetoothConfig = BluetoothConfigEntity(context: context) + newBluetoothConfig.enabled = config.enabled + newBluetoothConfig.mode = Int32(config.mode.rawValue) + newBluetoothConfig.fixedPin = Int32(config.fixedPin) + fetchedNode[0].bluetoothConfig = newBluetoothConfig + } else { + fetchedNode[0].bluetoothConfig?.enabled = config.enabled + fetchedNode[0].bluetoothConfig?.mode = Int32(config.mode.rawValue) + fetchedNode[0].bluetoothConfig?.fixedPin = Int32(config.fixedPin) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [BluetoothConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [BluetoothConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [BluetoothConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Bluetooth Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [BluetoothConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertDeviceConfigPacket(config: Config.DeviceConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertDeviceConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertDeviceConfigPacket(config: Config.DeviceConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Device config received: %@".localized, String(nodeNum)) + Logger.mesh.info("📟 \(logString, privacy: .public)") + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) + + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].deviceConfig == nil { + let newDeviceConfig = DeviceConfigEntity(context: context) + newDeviceConfig.role = Int32(config.role.rawValue) + newDeviceConfig.buttonGpio = Int32(config.buttonGpio) + newDeviceConfig.buzzerGpio = Int32(config.buzzerGpio) + newDeviceConfig.rebroadcastMode = Int32(config.rebroadcastMode.rawValue) + newDeviceConfig.nodeInfoBroadcastSecs = Int32(truncating: config.nodeInfoBroadcastSecs as NSNumber) + newDeviceConfig.doubleTapAsButtonPress = config.doubleTapAsButtonPress + newDeviceConfig.tripleClickAsAdHocPing = !config.disableTripleClick + newDeviceConfig.ledHeartbeatEnabled = !config.ledHeartbeatDisabled + newDeviceConfig.isManaged = config.isManaged + newDeviceConfig.tzdef = config.tzdef + fetchedNode[0].deviceConfig = newDeviceConfig + } else { + fetchedNode[0].deviceConfig?.role = Int32(config.role.rawValue) + fetchedNode[0].deviceConfig?.buttonGpio = Int32(config.buttonGpio) + fetchedNode[0].deviceConfig?.buzzerGpio = Int32(config.buzzerGpio) + fetchedNode[0].deviceConfig?.rebroadcastMode = Int32(config.rebroadcastMode.rawValue) + fetchedNode[0].deviceConfig?.nodeInfoBroadcastSecs = Int32(truncating: config.nodeInfoBroadcastSecs as NSNumber) + fetchedNode[0].deviceConfig?.doubleTapAsButtonPress = config.doubleTapAsButtonPress + fetchedNode[0].deviceConfig?.tripleClickAsAdHocPing = !config.disableTripleClick + fetchedNode[0].deviceConfig?.ledHeartbeatEnabled = !config.ledHeartbeatDisabled + fetchedNode[0].deviceConfig?.isManaged = config.isManaged + fetchedNode[0].deviceConfig?.tzdef = config.tzdef + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [DeviceConfigEntity] Updated Device Config for node number: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [DeviceConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [DeviceConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertDisplayConfigPacket(config: Config.DisplayConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertDisplayConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertDisplayConfigPacket(config: Config.DisplayConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Display config received: %@".localized, nodeNum.toHex()) + 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) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].displayConfig == nil { + + let newDisplayConfig = DisplayConfigEntity(context: context) + newDisplayConfig.screenOnSeconds = Int32(truncatingIfNeeded: config.screenOnSecs) + newDisplayConfig.screenCarouselInterval = Int32(truncatingIfNeeded: config.autoScreenCarouselSecs) + newDisplayConfig.compassNorthTop = config.compassNorthTop + newDisplayConfig.flipScreen = config.flipScreen + newDisplayConfig.oledType = Int32(config.oled.rawValue) + newDisplayConfig.displayMode = Int32(config.displaymode.rawValue) + newDisplayConfig.units = Int32(config.units.rawValue) + newDisplayConfig.headingBold = config.headingBold + newDisplayConfig.use12HClock = config.use12HClock + fetchedNode[0].displayConfig = newDisplayConfig + } else { + fetchedNode[0].displayConfig?.screenOnSeconds = Int32(truncatingIfNeeded: config.screenOnSecs) + fetchedNode[0].displayConfig?.screenCarouselInterval = Int32(truncatingIfNeeded: config.autoScreenCarouselSecs) + fetchedNode[0].displayConfig?.compassNorthTop = config.compassNorthTop + fetchedNode[0].displayConfig?.flipScreen = config.flipScreen + fetchedNode[0].displayConfig?.oledType = Int32(config.oled.rawValue) + fetchedNode[0].displayConfig?.displayMode = Int32(config.displaymode.rawValue) + fetchedNode[0].displayConfig?.units = Int32(config.units.rawValue) + fetchedNode[0].displayConfig?.headingBold = config.headingBold + fetchedNode[0].displayConfig?.use12HClock = config.use12HClock + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + + try context.save() + Logger.data.info("💾 [DisplayConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [DisplayConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [DisplayConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Display Config") + } + + } catch { + let nsError = error as NSError + Logger.data.error("💥 [DisplayConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertLoRaConfigPacket(config: Config.LoRaConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertLoRaConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertLoRaConfigPacket(config: Config.LoRaConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("LoRa config received: %@".localized, nodeNum.toHex()) + Logger.data.info("📻 \(logString, privacy: .public)") + + let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() + fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", nodeNum) + do { + let fetchedNode = try context.fetch(fetchNodeInfoRequest) + // Found a node, save LoRa Config + if fetchedNode.count > 0 { + if fetchedNode[0].loRaConfig == nil { + // No lora config for node, save a new lora config + let newLoRaConfig = LoRaConfigEntity(context: context) + newLoRaConfig.regionCode = Int32(config.region.rawValue) + newLoRaConfig.usePreset = config.usePreset + newLoRaConfig.modemPreset = Int32(config.modemPreset.rawValue) + newLoRaConfig.bandwidth = Int32(config.bandwidth) + newLoRaConfig.spreadFactor = Int32(config.spreadFactor) + newLoRaConfig.codingRate = Int32(config.codingRate) + newLoRaConfig.frequencyOffset = config.frequencyOffset + newLoRaConfig.overrideFrequency = config.overrideFrequency + newLoRaConfig.overrideDutyCycle = config.overrideDutyCycle + newLoRaConfig.hopLimit = Int32(config.hopLimit) + newLoRaConfig.txPower = Int32(config.txPower) + newLoRaConfig.txEnabled = config.txEnabled + newLoRaConfig.channelNum = Int32(config.channelNum) + newLoRaConfig.sx126xRxBoostedGain = config.sx126XRxBoostedGain + newLoRaConfig.ignoreMqtt = config.ignoreMqtt + newLoRaConfig.okToMqtt = config.configOkToMqtt + fetchedNode[0].loRaConfig = newLoRaConfig + } else { + fetchedNode[0].loRaConfig?.regionCode = Int32(config.region.rawValue) + fetchedNode[0].loRaConfig?.usePreset = config.usePreset + fetchedNode[0].loRaConfig?.modemPreset = Int32(config.modemPreset.rawValue) + fetchedNode[0].loRaConfig?.bandwidth = Int32(config.bandwidth) + fetchedNode[0].loRaConfig?.spreadFactor = Int32(config.spreadFactor) + fetchedNode[0].loRaConfig?.codingRate = Int32(config.codingRate) + fetchedNode[0].loRaConfig?.frequencyOffset = config.frequencyOffset + fetchedNode[0].loRaConfig?.overrideFrequency = config.overrideFrequency + fetchedNode[0].loRaConfig?.overrideDutyCycle = config.overrideDutyCycle + fetchedNode[0].loRaConfig?.hopLimit = Int32(config.hopLimit) + fetchedNode[0].loRaConfig?.txPower = Int32(config.txPower) + fetchedNode[0].loRaConfig?.txEnabled = config.txEnabled + fetchedNode[0].loRaConfig?.channelNum = Int32(config.channelNum) + fetchedNode[0].loRaConfig?.sx126xRxBoostedGain = config.sx126XRxBoostedGain + fetchedNode[0].loRaConfig?.ignoreMqtt = config.ignoreMqtt + fetchedNode[0].loRaConfig?.okToMqtt = config.configOkToMqtt + fetchedNode[0].loRaConfig?.sx126xRxBoostedGain = config.sx126XRxBoostedGain + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [LoRaConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [LoRaConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [LoRaConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Lora Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [LoRaConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertNetworkConfigPacket(config: Config.NetworkConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertNetworkConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertNetworkConfigPacket(config: Config.NetworkConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Network 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) + // Found a node, save WiFi Config + if !fetchedNode.isEmpty { + if fetchedNode[0].networkConfig == nil { + let newNetworkConfig = NetworkConfigEntity(context: context) + newNetworkConfig.wifiEnabled = config.wifiEnabled + newNetworkConfig.wifiSsid = config.wifiSsid + newNetworkConfig.wifiPsk = config.wifiPsk + newNetworkConfig.ethEnabled = config.ethEnabled + newNetworkConfig.enabledProtocols = Int32(config.enabledProtocols) + fetchedNode[0].networkConfig = newNetworkConfig + } else { + fetchedNode[0].networkConfig?.ethEnabled = config.ethEnabled + fetchedNode[0].networkConfig?.wifiEnabled = config.wifiEnabled + fetchedNode[0].networkConfig?.wifiSsid = config.wifiSsid + fetchedNode[0].networkConfig?.wifiPsk = config.wifiPsk + fetchedNode[0].networkConfig?.enabledProtocols = Int32(config.enabledProtocols) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [NetworkConfigEntity] Updated Network Config for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [NetworkConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [NetworkConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Network Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [NetworkConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertPositionConfigPacket(config: Config.PositionConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertPositionConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertPositionConfigPacket(config: Config.PositionConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Position 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) + // Found a node, save LoRa Config + if !fetchedNode.isEmpty { + if fetchedNode[0].positionConfig == nil { + let newPositionConfig = PositionConfigEntity(context: context) + newPositionConfig.smartPositionEnabled = config.positionBroadcastSmartEnabled + newPositionConfig.deviceGpsEnabled = config.gpsEnabled + newPositionConfig.gpsMode = Int32(truncatingIfNeeded: config.gpsMode.rawValue) + newPositionConfig.rxGpio = Int32(truncatingIfNeeded: config.rxGpio) + newPositionConfig.txGpio = Int32(truncatingIfNeeded: config.txGpio) + newPositionConfig.gpsEnGpio = Int32(truncatingIfNeeded: config.gpsEnGpio) + newPositionConfig.fixedPosition = config.fixedPosition + newPositionConfig.positionBroadcastSeconds = Int32(truncatingIfNeeded: config.positionBroadcastSecs) + newPositionConfig.broadcastSmartMinimumIntervalSecs = Int32(truncatingIfNeeded: config.broadcastSmartMinimumIntervalSecs) + newPositionConfig.broadcastSmartMinimumDistance = Int32(truncatingIfNeeded: config.broadcastSmartMinimumDistance) + newPositionConfig.positionFlags = Int32(truncatingIfNeeded: config.positionFlags) + newPositionConfig.gpsAttemptTime = 900 + newPositionConfig.gpsUpdateInterval = Int32(truncatingIfNeeded: config.gpsUpdateInterval) + fetchedNode[0].positionConfig = newPositionConfig + } else { + fetchedNode[0].positionConfig?.smartPositionEnabled = config.positionBroadcastSmartEnabled + fetchedNode[0].positionConfig?.deviceGpsEnabled = config.gpsEnabled + fetchedNode[0].positionConfig?.gpsMode = Int32(truncatingIfNeeded: config.gpsMode.rawValue) + fetchedNode[0].positionConfig?.rxGpio = Int32(truncatingIfNeeded: config.rxGpio) + fetchedNode[0].positionConfig?.txGpio = Int32(truncatingIfNeeded: config.txGpio) + fetchedNode[0].positionConfig?.gpsEnGpio = Int32(truncatingIfNeeded: config.gpsEnGpio) + fetchedNode[0].positionConfig?.fixedPosition = config.fixedPosition + fetchedNode[0].positionConfig?.positionBroadcastSeconds = Int32(truncatingIfNeeded: config.positionBroadcastSecs) + fetchedNode[0].positionConfig?.broadcastSmartMinimumIntervalSecs = Int32(truncatingIfNeeded: config.broadcastSmartMinimumIntervalSecs) + fetchedNode[0].positionConfig?.broadcastSmartMinimumDistance = Int32(truncatingIfNeeded: config.broadcastSmartMinimumDistance) + fetchedNode[0].positionConfig?.gpsAttemptTime = 900 + fetchedNode[0].positionConfig?.gpsUpdateInterval = Int32(truncatingIfNeeded: config.gpsUpdateInterval) + fetchedNode[0].positionConfig?.positionFlags = Int32(truncatingIfNeeded: config.positionFlags) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [PositionConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [PositionConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [PositionConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Position Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [PositionConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertPowerConfigPacket(config: Config.PowerConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertPowerConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertPowerConfigPacket(config: Config.PowerConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + let logString = String.localizedStringWithFormat("Power 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) + // Found a node, save Power Config + if !fetchedNode.isEmpty { + if fetchedNode[0].powerConfig == nil { + let newPowerConfig = PowerConfigEntity(context: context) + newPowerConfig.adcMultiplierOverride = config.adcMultiplierOverride + newPowerConfig.deviceBatteryInaAddress = Int32(config.deviceBatteryInaAddress) + newPowerConfig.isPowerSaving = config.isPowerSaving + newPowerConfig.lsSecs = Int32(truncatingIfNeeded: config.lsSecs) + newPowerConfig.minWakeSecs = Int32(truncatingIfNeeded: config.minWakeSecs) + newPowerConfig.onBatteryShutdownAfterSecs = Int32(truncatingIfNeeded: config.onBatteryShutdownAfterSecs) + newPowerConfig.waitBluetoothSecs = Int32(truncatingIfNeeded: config.waitBluetoothSecs) + fetchedNode[0].powerConfig = newPowerConfig + } else { + fetchedNode[0].powerConfig?.adcMultiplierOverride = config.adcMultiplierOverride + fetchedNode[0].powerConfig?.deviceBatteryInaAddress = Int32(config.deviceBatteryInaAddress) + fetchedNode[0].powerConfig?.isPowerSaving = config.isPowerSaving + fetchedNode[0].powerConfig?.lsSecs = Int32(truncatingIfNeeded: config.lsSecs) + fetchedNode[0].powerConfig?.minWakeSecs = Int32(truncatingIfNeeded: config.minWakeSecs) + fetchedNode[0].powerConfig?.onBatteryShutdownAfterSecs = Int32(truncatingIfNeeded: config.onBatteryShutdownAfterSecs) + fetchedNode[0].powerConfig?.waitBluetoothSecs = Int32(truncatingIfNeeded: config.waitBluetoothSecs) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [PowerConfigEntity] Updated Power Config for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [PowerConfigEntity] Error Updating Core Data PowerConfigEntity: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [PowerConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Power Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [PowerConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertSecurityConfigPacket(config: Config.SecurityConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertSecurityConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertSecurityConfigPacket(config: Config.SecurityConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("mesh.log.security.config %@".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) + // Found a node, save Security Config + if !fetchedNode.isEmpty { + if fetchedNode[0].securityConfig == nil { + let newSecurityConfig = SecurityConfigEntity(context: context) + newSecurityConfig.publicKey = config.publicKey + newSecurityConfig.privateKey = config.privateKey + if config.adminKey.count > 0 { + newSecurityConfig.adminKey = config.adminKey[0] } + newSecurityConfig.isManaged = config.isManaged + newSecurityConfig.serialEnabled = config.serialEnabled + newSecurityConfig.debugLogApiEnabled = config.debugLogApiEnabled + newSecurityConfig.adminChannelEnabled = config.adminChannelEnabled + fetchedNode[0].securityConfig = newSecurityConfig + } else { + fetchedNode[0].securityConfig?.publicKey = config.publicKey + fetchedNode[0].securityConfig?.privateKey = config.privateKey + if config.adminKey.count > 0 { + fetchedNode[0].securityConfig?.adminKey = config.adminKey[0] + if config.adminKey.count > 1 { + fetchedNode[0].securityConfig?.adminKey2 = config.adminKey[1] + } + if config.adminKey.count > 2 { + fetchedNode[0].securityConfig?.adminKey3 = config.adminKey[2] + } + } + fetchedNode[0].securityConfig?.isManaged = config.isManaged + fetchedNode[0].securityConfig?.serialEnabled = config.serialEnabled + fetchedNode[0].securityConfig?.debugLogApiEnabled = config.debugLogApiEnabled + fetchedNode[0].securityConfig?.adminChannelEnabled = config.adminChannelEnabled } - fetchedNode[0].securityConfig?.isManaged = config.isManaged - fetchedNode[0].securityConfig?.serialEnabled = config.serialEnabled - fetchedNode[0].securityConfig?.debugLogApiEnabled = config.debugLogApiEnabled - fetchedNode[0].securityConfig?.adminChannelEnabled = config.adminChannelEnabled - } - if sessionPasskey?.count != 0 { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [SecurityConfigEntity] Updated Security Config for node: \(nodeNum.toHex(), privacy: .public)") - - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [SecurityConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [SecurityConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Security Config") - } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [SecurityConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") - } -} - -func upsertAmbientLightingModuleConfigPacket(config: ModuleConfig.AmbientLightingConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Ambient Lighting 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) - // Found a node, save Ambient Lighting Config - if !fetchedNode.isEmpty { - - if fetchedNode[0].cannedMessageConfig == nil { - let newAmbientLightingConfig = AmbientLightingConfigEntity(context: context) - newAmbientLightingConfig.ledState = config.ledState - newAmbientLightingConfig.current = Int32(config.current) - newAmbientLightingConfig.red = Int32(config.red) - newAmbientLightingConfig.green = Int32(config.green) - newAmbientLightingConfig.blue = Int32(config.blue) - fetchedNode[0].ambientLightingConfig = newAmbientLightingConfig - } else { - - if fetchedNode[0].ambientLightingConfig == nil { - fetchedNode[0].ambientLightingConfig = AmbientLightingConfigEntity(context: context) + if sessionPasskey?.count != 0 { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [SecurityConfigEntity] Updated Security Config for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [SecurityConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") } - fetchedNode[0].ambientLightingConfig?.ledState = config.ledState - fetchedNode[0].ambientLightingConfig?.current = Int32(config.current) - fetchedNode[0].ambientLightingConfig?.red = Int32(config.red) - fetchedNode[0].ambientLightingConfig?.green = Int32(config.green) - fetchedNode[0].ambientLightingConfig?.blue = Int32(config.blue) - } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [AmbientLightingConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [AmbientLightingConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [AmbientLightingConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Ambient Lighting Module Config") - } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [AmbientLightingConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") - } -} - -func upsertCannedMessagesModuleConfigPacket(config: ModuleConfig.CannedMessageConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Canned Message 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) - // Found a node, save Canned Message Config - if !fetchedNode.isEmpty { - - if fetchedNode[0].cannedMessageConfig == nil { - let newCannedMessageConfig = CannedMessageConfigEntity(context: context) - newCannedMessageConfig.enabled = config.enabled - newCannedMessageConfig.sendBell = config.sendBell - newCannedMessageConfig.rotary1Enabled = config.rotary1Enabled - newCannedMessageConfig.updown1Enabled = config.updown1Enabled - newCannedMessageConfig.inputbrokerPinA = Int32(config.inputbrokerPinA) - newCannedMessageConfig.inputbrokerPinB = Int32(config.inputbrokerPinB) - newCannedMessageConfig.inputbrokerPinPress = Int32(config.inputbrokerPinPress) - newCannedMessageConfig.inputbrokerEventCw = Int32(config.inputbrokerEventCw.rawValue) - newCannedMessageConfig.inputbrokerEventCcw = Int32(config.inputbrokerEventCcw.rawValue) - newCannedMessageConfig.inputbrokerEventPress = Int32(config.inputbrokerEventPress.rawValue) - fetchedNode[0].cannedMessageConfig = newCannedMessageConfig } else { - fetchedNode[0].cannedMessageConfig?.enabled = config.enabled - fetchedNode[0].cannedMessageConfig?.sendBell = config.sendBell - fetchedNode[0].cannedMessageConfig?.rotary1Enabled = config.rotary1Enabled - fetchedNode[0].cannedMessageConfig?.updown1Enabled = config.updown1Enabled - fetchedNode[0].cannedMessageConfig?.inputbrokerPinA = Int32(config.inputbrokerPinA) - fetchedNode[0].cannedMessageConfig?.inputbrokerPinB = Int32(config.inputbrokerPinB) - fetchedNode[0].cannedMessageConfig?.inputbrokerPinPress = Int32(config.inputbrokerPinPress) - fetchedNode[0].cannedMessageConfig?.inputbrokerEventCw = Int32(config.inputbrokerEventCw.rawValue) - fetchedNode[0].cannedMessageConfig?.inputbrokerEventCcw = Int32(config.inputbrokerEventCcw.rawValue) - fetchedNode[0].cannedMessageConfig?.inputbrokerEventPress = Int32(config.inputbrokerEventPress.rawValue) + Logger.data.error("💥 [SecurityConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Security Config") } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [CannedMessageConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [CannedMessageConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [CannedMessageConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Canned Message Module Config") + } catch { + let nsError = error as NSError + Logger.data.error("💥 [SecurityConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [CannedMessageConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } -} - -func upsertDetectionSensorModuleConfigPacket(config: ModuleConfig.DetectionSensorConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Detection Sensor 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) - // Found a node, save Detection Sensor Config - if !fetchedNode.isEmpty { - if fetchedNode[0].detectionSensorConfig == nil { - let newConfig = DetectionSensorConfigEntity(context: context) - newConfig.enabled = config.enabled - newConfig.sendBell = config.sendBell - newConfig.name = config.name - newConfig.monitorPin = Int32(config.monitorPin) - newConfig.triggerType = Int32(config.detectionTriggerType.rawValue) - newConfig.usePullup = config.usePullup - newConfig.minimumBroadcastSecs = Int32(truncatingIfNeeded: config.minimumBroadcastSecs) - newConfig.stateBroadcastSecs = Int32(truncatingIfNeeded: config.stateBroadcastSecs) - fetchedNode[0].detectionSensorConfig = newConfig + + func upsertAmbientLightingModuleConfigPacket(config: ModuleConfig.AmbientLightingConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertAmbientLightingModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertAmbientLightingModuleConfigPacket(config: ModuleConfig.AmbientLightingConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Ambient Lighting 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) + // Found a node, save Ambient Lighting Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].cannedMessageConfig == nil { + let newAmbientLightingConfig = AmbientLightingConfigEntity(context: context) + newAmbientLightingConfig.ledState = config.ledState + newAmbientLightingConfig.current = Int32(config.current) + newAmbientLightingConfig.red = Int32(config.red) + newAmbientLightingConfig.green = Int32(config.green) + newAmbientLightingConfig.blue = Int32(config.blue) + fetchedNode[0].ambientLightingConfig = newAmbientLightingConfig + } else { + + if fetchedNode[0].ambientLightingConfig == nil { + fetchedNode[0].ambientLightingConfig = AmbientLightingConfigEntity(context: context) + } + fetchedNode[0].ambientLightingConfig?.ledState = config.ledState + fetchedNode[0].ambientLightingConfig?.current = Int32(config.current) + fetchedNode[0].ambientLightingConfig?.red = Int32(config.red) + fetchedNode[0].ambientLightingConfig?.green = Int32(config.green) + fetchedNode[0].ambientLightingConfig?.blue = Int32(config.blue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [AmbientLightingConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [AmbientLightingConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } } else { - fetchedNode[0].detectionSensorConfig?.enabled = config.enabled - fetchedNode[0].detectionSensorConfig?.sendBell = config.sendBell - fetchedNode[0].detectionSensorConfig?.name = config.name - fetchedNode[0].detectionSensorConfig?.monitorPin = Int32(config.monitorPin) - fetchedNode[0].detectionSensorConfig?.usePullup = config.usePullup - fetchedNode[0].detectionSensorConfig?.triggerType = Int32(config.detectionTriggerType.rawValue) - fetchedNode[0].detectionSensorConfig?.minimumBroadcastSecs = Int32(truncatingIfNeeded: config.minimumBroadcastSecs) - fetchedNode[0].detectionSensorConfig?.stateBroadcastSecs = Int32(truncatingIfNeeded: config.stateBroadcastSecs) + Logger.data.error("💥 [AmbientLightingConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Ambient Lighting Module Config") } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [DetectionSensorConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") - - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [DetectionSensorConfigEntity] Error Updating Core Data : \(nsError, privacy: .public)") - } - - } else { - Logger.data.error("💥 [DetectionSensorConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Detection Sensor Module Config") + } catch { + let nsError = error as NSError + Logger.data.error("💥 [AmbientLightingConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } - - } catch { - let nsError = error as NSError - Logger.data.error("💥 [DetectionSensorConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } -} - -func upsertExternalNotificationModuleConfigPacket(config: ModuleConfig.ExternalNotificationConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("External Notification 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) - // Found a node, save External Notificaitone Config - if !fetchedNode.isEmpty { - - if fetchedNode[0].externalNotificationConfig == nil { - let newExternalNotificationConfig = ExternalNotificationConfigEntity(context: context) - newExternalNotificationConfig.enabled = config.enabled - newExternalNotificationConfig.usePWM = config.usePwm - newExternalNotificationConfig.alertBell = config.alertBell - newExternalNotificationConfig.alertBellBuzzer = config.alertBellBuzzer - newExternalNotificationConfig.alertBellVibra = config.alertBellVibra - newExternalNotificationConfig.alertMessage = config.alertMessage - newExternalNotificationConfig.alertMessageBuzzer = config.alertMessageBuzzer - newExternalNotificationConfig.alertMessageVibra = config.alertMessageVibra - newExternalNotificationConfig.active = config.active - newExternalNotificationConfig.output = Int32(config.output) - newExternalNotificationConfig.outputBuzzer = Int32(config.outputBuzzer) - newExternalNotificationConfig.outputVibra = Int32(config.outputVibra) - newExternalNotificationConfig.outputMilliseconds = Int32(config.outputMs) - newExternalNotificationConfig.nagTimeout = Int32(config.nagTimeout) - newExternalNotificationConfig.useI2SAsBuzzer = config.useI2SAsBuzzer - fetchedNode[0].externalNotificationConfig = newExternalNotificationConfig + + func upsertCannedMessagesModuleConfigPacket(config: ModuleConfig.CannedMessageConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertCannedMessagesModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertCannedMessagesModuleConfigPacket(config: ModuleConfig.CannedMessageConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Canned Message 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) + // Found a node, save Canned Message Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].cannedMessageConfig == nil { + let newCannedMessageConfig = CannedMessageConfigEntity(context: context) + newCannedMessageConfig.enabled = config.enabled + newCannedMessageConfig.sendBell = config.sendBell + newCannedMessageConfig.rotary1Enabled = config.rotary1Enabled + newCannedMessageConfig.updown1Enabled = config.updown1Enabled + newCannedMessageConfig.inputbrokerPinA = Int32(config.inputbrokerPinA) + newCannedMessageConfig.inputbrokerPinB = Int32(config.inputbrokerPinB) + newCannedMessageConfig.inputbrokerPinPress = Int32(config.inputbrokerPinPress) + newCannedMessageConfig.inputbrokerEventCw = Int32(config.inputbrokerEventCw.rawValue) + newCannedMessageConfig.inputbrokerEventCcw = Int32(config.inputbrokerEventCcw.rawValue) + newCannedMessageConfig.inputbrokerEventPress = Int32(config.inputbrokerEventPress.rawValue) + fetchedNode[0].cannedMessageConfig = newCannedMessageConfig + } else { + fetchedNode[0].cannedMessageConfig?.enabled = config.enabled + fetchedNode[0].cannedMessageConfig?.sendBell = config.sendBell + fetchedNode[0].cannedMessageConfig?.rotary1Enabled = config.rotary1Enabled + fetchedNode[0].cannedMessageConfig?.updown1Enabled = config.updown1Enabled + fetchedNode[0].cannedMessageConfig?.inputbrokerPinA = Int32(config.inputbrokerPinA) + fetchedNode[0].cannedMessageConfig?.inputbrokerPinB = Int32(config.inputbrokerPinB) + fetchedNode[0].cannedMessageConfig?.inputbrokerPinPress = Int32(config.inputbrokerPinPress) + fetchedNode[0].cannedMessageConfig?.inputbrokerEventCw = Int32(config.inputbrokerEventCw.rawValue) + fetchedNode[0].cannedMessageConfig?.inputbrokerEventCcw = Int32(config.inputbrokerEventCcw.rawValue) + fetchedNode[0].cannedMessageConfig?.inputbrokerEventPress = Int32(config.inputbrokerEventPress.rawValue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [CannedMessageConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [CannedMessageConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } } else { - fetchedNode[0].externalNotificationConfig?.enabled = config.enabled - fetchedNode[0].externalNotificationConfig?.usePWM = config.usePwm - fetchedNode[0].externalNotificationConfig?.alertBell = config.alertBell - fetchedNode[0].externalNotificationConfig?.alertBellBuzzer = config.alertBellBuzzer - fetchedNode[0].externalNotificationConfig?.alertBellVibra = config.alertBellVibra - fetchedNode[0].externalNotificationConfig?.alertMessage = config.alertMessage - fetchedNode[0].externalNotificationConfig?.alertMessageBuzzer = config.alertMessageBuzzer - fetchedNode[0].externalNotificationConfig?.alertMessageVibra = config.alertMessageVibra - fetchedNode[0].externalNotificationConfig?.active = config.active - fetchedNode[0].externalNotificationConfig?.output = Int32(config.output) - fetchedNode[0].externalNotificationConfig?.outputBuzzer = Int32(config.outputBuzzer) - fetchedNode[0].externalNotificationConfig?.outputVibra = Int32(config.outputVibra) - fetchedNode[0].externalNotificationConfig?.outputMilliseconds = Int32(config.outputMs) - fetchedNode[0].externalNotificationConfig?.nagTimeout = Int32(config.nagTimeout) - fetchedNode[0].externalNotificationConfig?.useI2SAsBuzzer = config.useI2SAsBuzzer + Logger.data.error("💥 [CannedMessageConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Canned Message Module Config") } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [ExternalNotificationConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [ExternalNotificationConfigEntity] Error Updating Core Data : \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [ExternalNotificationConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save External Notification Module Config") + } catch { + let nsError = error as NSError + Logger.data.error("💥 [CannedMessageConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [ExternalNotificationConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } -} -func upsertPaxCounterModuleConfigPacket(config: ModuleConfig.PaxcounterConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + func upsertDetectionSensorModuleConfigPacket(config: ModuleConfig.DetectionSensorConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertDetectionSensorModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } - let logString = String.localizedStringWithFormat("PAX Counter 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) - // Found a node, save PAX Counter Config - if !fetchedNode.isEmpty { - if fetchedNode[0].paxCounterConfig == nil { - let newPaxCounterConfig = PaxCounterConfigEntity(context: context) - newPaxCounterConfig.enabled = config.enabled - newPaxCounterConfig.updateInterval = Int32(config.paxcounterUpdateInterval) - fetchedNode[0].paxCounterConfig = newPaxCounterConfig + nonisolated func upsertDetectionSensorModuleConfigPacket(config: ModuleConfig.DetectionSensorConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Detection Sensor 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) + // Found a node, save Detection Sensor Config + if !fetchedNode.isEmpty { + if fetchedNode[0].detectionSensorConfig == nil { + let newConfig = DetectionSensorConfigEntity(context: context) + newConfig.enabled = config.enabled + newConfig.sendBell = config.sendBell + newConfig.name = config.name + newConfig.monitorPin = Int32(config.monitorPin) + newConfig.triggerType = Int32(config.detectionTriggerType.rawValue) + newConfig.usePullup = config.usePullup + newConfig.minimumBroadcastSecs = Int32(truncatingIfNeeded: config.minimumBroadcastSecs) + newConfig.stateBroadcastSecs = Int32(truncatingIfNeeded: config.stateBroadcastSecs) + fetchedNode[0].detectionSensorConfig = newConfig + } else { + fetchedNode[0].detectionSensorConfig?.enabled = config.enabled + fetchedNode[0].detectionSensorConfig?.sendBell = config.sendBell + fetchedNode[0].detectionSensorConfig?.name = config.name + fetchedNode[0].detectionSensorConfig?.monitorPin = Int32(config.monitorPin) + fetchedNode[0].detectionSensorConfig?.usePullup = config.usePullup + fetchedNode[0].detectionSensorConfig?.triggerType = Int32(config.detectionTriggerType.rawValue) + fetchedNode[0].detectionSensorConfig?.minimumBroadcastSecs = Int32(truncatingIfNeeded: config.minimumBroadcastSecs) + fetchedNode[0].detectionSensorConfig?.stateBroadcastSecs = Int32(truncatingIfNeeded: config.stateBroadcastSecs) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [DetectionSensorConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [DetectionSensorConfigEntity] Error Updating Core Data : \(nsError, privacy: .public)") + } + } else { - fetchedNode[0].paxCounterConfig?.enabled = config.enabled - fetchedNode[0].paxCounterConfig?.updateInterval = Int32(config.paxcounterUpdateInterval) + Logger.data.error("💥 [DetectionSensorConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Detection Sensor Module Config") } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [PaxCounterConfigEntity] Updated for node number: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [PaxCounterConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [PaxCounterConfigEntity] No Nodes found in local database matching node number \(nodeNum.toHex(), privacy: .public) unable to save PAX Counter Module Config") + + } catch { + let nsError = error as NSError + Logger.data.error("💥 [DetectionSensorConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [PaxCounterConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } -} -func upsertRtttlConfigPacket(ringtone: String, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + func upsertExternalNotificationModuleConfigPacket(config: ModuleConfig.ExternalNotificationConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertExternalNotificationModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } - let logString = String.localizedStringWithFormat("RTTTL Ringtone 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) - // Found a node, save RTTTL Config - if !fetchedNode.isEmpty { - if fetchedNode[0].rtttlConfig == nil { - let newRtttlConfig = RTTTLConfigEntity(context: context) - newRtttlConfig.ringtone = ringtone - fetchedNode[0].rtttlConfig = newRtttlConfig + nonisolated func upsertExternalNotificationModuleConfigPacket(config: ModuleConfig.ExternalNotificationConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("External Notification 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) + // Found a node, save External Notificaitone Config + if !fetchedNode.isEmpty { + + if fetchedNode[0].externalNotificationConfig == nil { + let newExternalNotificationConfig = ExternalNotificationConfigEntity(context: context) + newExternalNotificationConfig.enabled = config.enabled + newExternalNotificationConfig.usePWM = config.usePwm + newExternalNotificationConfig.alertBell = config.alertBell + newExternalNotificationConfig.alertBellBuzzer = config.alertBellBuzzer + newExternalNotificationConfig.alertBellVibra = config.alertBellVibra + newExternalNotificationConfig.alertMessage = config.alertMessage + newExternalNotificationConfig.alertMessageBuzzer = config.alertMessageBuzzer + newExternalNotificationConfig.alertMessageVibra = config.alertMessageVibra + newExternalNotificationConfig.active = config.active + newExternalNotificationConfig.output = Int32(config.output) + newExternalNotificationConfig.outputBuzzer = Int32(config.outputBuzzer) + newExternalNotificationConfig.outputVibra = Int32(config.outputVibra) + newExternalNotificationConfig.outputMilliseconds = Int32(config.outputMs) + newExternalNotificationConfig.nagTimeout = Int32(config.nagTimeout) + newExternalNotificationConfig.useI2SAsBuzzer = config.useI2SAsBuzzer + fetchedNode[0].externalNotificationConfig = newExternalNotificationConfig + } else { + fetchedNode[0].externalNotificationConfig?.enabled = config.enabled + fetchedNode[0].externalNotificationConfig?.usePWM = config.usePwm + fetchedNode[0].externalNotificationConfig?.alertBell = config.alertBell + fetchedNode[0].externalNotificationConfig?.alertBellBuzzer = config.alertBellBuzzer + fetchedNode[0].externalNotificationConfig?.alertBellVibra = config.alertBellVibra + fetchedNode[0].externalNotificationConfig?.alertMessage = config.alertMessage + fetchedNode[0].externalNotificationConfig?.alertMessageBuzzer = config.alertMessageBuzzer + fetchedNode[0].externalNotificationConfig?.alertMessageVibra = config.alertMessageVibra + fetchedNode[0].externalNotificationConfig?.active = config.active + fetchedNode[0].externalNotificationConfig?.output = Int32(config.output) + fetchedNode[0].externalNotificationConfig?.outputBuzzer = Int32(config.outputBuzzer) + fetchedNode[0].externalNotificationConfig?.outputVibra = Int32(config.outputVibra) + fetchedNode[0].externalNotificationConfig?.outputMilliseconds = Int32(config.outputMs) + fetchedNode[0].externalNotificationConfig?.nagTimeout = Int32(config.nagTimeout) + fetchedNode[0].externalNotificationConfig?.useI2SAsBuzzer = config.useI2SAsBuzzer + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [ExternalNotificationConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [ExternalNotificationConfigEntity] Error Updating Core Data : \(nsError, privacy: .public)") + } } else { - fetchedNode[0].rtttlConfig?.ringtone = ringtone + Logger.data.error("💥 [ExternalNotificationConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save External Notification Module Config") } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [RtttlConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [RtttlConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [RtttlConfigEntity] No nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save RTTTL Ringtone Config") + } catch { + let nsError = error as NSError + Logger.data.error("💥 [ExternalNotificationConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [RtttlConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } -} - -func upsertMqttModuleConfigPacket(config: ModuleConfig.MQTTConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("MQTT 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) - // Found a node, save MQTT Config - if !fetchedNode.isEmpty { - if fetchedNode[0].mqttConfig == nil { - let newMQTTConfig = MQTTConfigEntity(context: context) - newMQTTConfig.enabled = config.enabled - newMQTTConfig.proxyToClientEnabled = config.proxyToClientEnabled - newMQTTConfig.address = config.address - newMQTTConfig.username = config.username - newMQTTConfig.password = config.password - newMQTTConfig.root = config.root - newMQTTConfig.encryptionEnabled = config.encryptionEnabled - newMQTTConfig.jsonEnabled = config.jsonEnabled - newMQTTConfig.tlsEnabled = config.tlsEnabled - newMQTTConfig.mapReportingEnabled = config.mapReportingEnabled - newMQTTConfig.mapReportingShouldReportLocation = config.mapReportSettings.shouldReportLocation - newMQTTConfig.mapPositionPrecision = Int32(config.mapReportSettings.positionPrecision) - newMQTTConfig.mapPublishIntervalSecs = Int32(config.mapReportSettings.publishIntervalSecs) - fetchedNode[0].mqttConfig = newMQTTConfig + + func upsertPaxCounterModuleConfigPacket(config: ModuleConfig.PaxcounterConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertPaxCounterModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertPaxCounterModuleConfigPacket(config: ModuleConfig.PaxcounterConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("PAX Counter 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) + // Found a node, save PAX Counter Config + if !fetchedNode.isEmpty { + if fetchedNode[0].paxCounterConfig == nil { + let newPaxCounterConfig = PaxCounterConfigEntity(context: context) + newPaxCounterConfig.enabled = config.enabled + newPaxCounterConfig.updateInterval = Int32(config.paxcounterUpdateInterval) + fetchedNode[0].paxCounterConfig = newPaxCounterConfig + } else { + fetchedNode[0].paxCounterConfig?.enabled = config.enabled + fetchedNode[0].paxCounterConfig?.updateInterval = Int32(config.paxcounterUpdateInterval) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [PaxCounterConfigEntity] Updated for node number: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [PaxCounterConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } } else { - fetchedNode[0].mqttConfig?.enabled = config.enabled - fetchedNode[0].mqttConfig?.proxyToClientEnabled = config.proxyToClientEnabled - fetchedNode[0].mqttConfig?.address = config.address - fetchedNode[0].mqttConfig?.username = config.username - fetchedNode[0].mqttConfig?.password = config.password - fetchedNode[0].mqttConfig?.root = config.root - fetchedNode[0].mqttConfig?.encryptionEnabled = config.encryptionEnabled - fetchedNode[0].mqttConfig?.jsonEnabled = config.jsonEnabled - fetchedNode[0].mqttConfig?.tlsEnabled = config.tlsEnabled - fetchedNode[0].mqttConfig?.mapReportingEnabled = config.mapReportingEnabled - fetchedNode[0].mqttConfig?.mapPositionPrecision = Int32(config.mapReportSettings.positionPrecision) - fetchedNode[0].mqttConfig?.mapPublishIntervalSecs = Int32(config.mapReportSettings.publishIntervalSecs) + Logger.data.error("💥 [PaxCounterConfigEntity] No Nodes found in local database matching node number \(nodeNum.toHex(), privacy: .public) unable to save PAX Counter Module Config") } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [MQTTConfigEntity] Updated for node number: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [MQTTConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [MQTTConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save MQTT Module Config") + } catch { + let nsError = error as NSError + Logger.data.error("💥 [PaxCounterConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [MQTTConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } -} -func upsertRangeTestModuleConfigPacket(config: ModuleConfig.RangeTestConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Range Test 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) - // Found a node, save Device Config - if !fetchedNode.isEmpty { - if fetchedNode[0].rangeTestConfig == nil { - let newRangeTestConfig = RangeTestConfigEntity(context: context) - newRangeTestConfig.sender = Int32(config.sender) - newRangeTestConfig.enabled = config.enabled - newRangeTestConfig.save = config.save - fetchedNode[0].rangeTestConfig = newRangeTestConfig + func upsertRtttlConfigPacket(ringtone: String, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertRtttlConfigPacket(ringtone: ringtone, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertRtttlConfigPacket(ringtone: String, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("RTTTL Ringtone 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) + // Found a node, save RTTTL Config + if !fetchedNode.isEmpty { + if fetchedNode[0].rtttlConfig == nil { + let newRtttlConfig = RTTTLConfigEntity(context: context) + newRtttlConfig.ringtone = ringtone + fetchedNode[0].rtttlConfig = newRtttlConfig + } else { + fetchedNode[0].rtttlConfig?.ringtone = ringtone + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [RtttlConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [RtttlConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } } else { - fetchedNode[0].rangeTestConfig?.sender = Int32(config.sender) - fetchedNode[0].rangeTestConfig?.enabled = config.enabled - fetchedNode[0].rangeTestConfig?.save = config.save + Logger.data.error("💥 [RtttlConfigEntity] No nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save RTTTL Ringtone Config") } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [RangeTestConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [RangeTestConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [RangeTestConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Range Test Module Config") + } catch { + let nsError = error as NSError + Logger.data.error("💥 [RtttlConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [RangeTestConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } -} -func upsertSerialModuleConfigPacket(config: ModuleConfig.SerialConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Serial 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) - // Found a node, save Device Config - if !fetchedNode.isEmpty { - if fetchedNode[0].serialConfig == nil { - let newSerialConfig = SerialConfigEntity(context: context) - newSerialConfig.enabled = config.enabled - newSerialConfig.echo = config.echo - newSerialConfig.rxd = Int32(config.rxd) - newSerialConfig.txd = Int32(config.txd) - newSerialConfig.baudRate = Int32(config.baud.rawValue) - newSerialConfig.timeout = Int32(config.timeout) - newSerialConfig.mode = Int32(config.mode.rawValue) - fetchedNode[0].serialConfig = newSerialConfig + func upsertMqttModuleConfigPacket(config: ModuleConfig.MQTTConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertMqttModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertMqttModuleConfigPacket(config: ModuleConfig.MQTTConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("MQTT 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) + // Found a node, save MQTT Config + if !fetchedNode.isEmpty { + if fetchedNode[0].mqttConfig == nil { + let newMQTTConfig = MQTTConfigEntity(context: context) + newMQTTConfig.enabled = config.enabled + newMQTTConfig.proxyToClientEnabled = config.proxyToClientEnabled + newMQTTConfig.address = config.address + newMQTTConfig.username = config.username + newMQTTConfig.password = config.password + newMQTTConfig.root = config.root + newMQTTConfig.encryptionEnabled = config.encryptionEnabled + newMQTTConfig.jsonEnabled = config.jsonEnabled + newMQTTConfig.tlsEnabled = config.tlsEnabled + newMQTTConfig.mapReportingEnabled = config.mapReportingEnabled + newMQTTConfig.mapReportingShouldReportLocation = config.mapReportSettings.shouldReportLocation + newMQTTConfig.mapPositionPrecision = Int32(config.mapReportSettings.positionPrecision) + newMQTTConfig.mapPublishIntervalSecs = Int32(config.mapReportSettings.publishIntervalSecs) + fetchedNode[0].mqttConfig = newMQTTConfig + } else { + fetchedNode[0].mqttConfig?.enabled = config.enabled + fetchedNode[0].mqttConfig?.proxyToClientEnabled = config.proxyToClientEnabled + fetchedNode[0].mqttConfig?.address = config.address + fetchedNode[0].mqttConfig?.username = config.username + fetchedNode[0].mqttConfig?.password = config.password + fetchedNode[0].mqttConfig?.root = config.root + fetchedNode[0].mqttConfig?.encryptionEnabled = config.encryptionEnabled + fetchedNode[0].mqttConfig?.jsonEnabled = config.jsonEnabled + fetchedNode[0].mqttConfig?.tlsEnabled = config.tlsEnabled + fetchedNode[0].mqttConfig?.mapReportingEnabled = config.mapReportingEnabled + fetchedNode[0].mqttConfig?.mapPositionPrecision = Int32(config.mapReportSettings.positionPrecision) + fetchedNode[0].mqttConfig?.mapPublishIntervalSecs = Int32(config.mapReportSettings.publishIntervalSecs) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [MQTTConfigEntity] Updated for node number: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [MQTTConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } } else { - fetchedNode[0].serialConfig?.enabled = config.enabled - fetchedNode[0].serialConfig?.echo = config.echo - fetchedNode[0].serialConfig?.rxd = Int32(config.rxd) - fetchedNode[0].serialConfig?.txd = Int32(config.txd) - fetchedNode[0].serialConfig?.baudRate = Int32(config.baud.rawValue) - fetchedNode[0].serialConfig?.timeout = Int32(config.timeout) - fetchedNode[0].serialConfig?.mode = Int32(config.mode.rawValue) + Logger.data.error("💥 [MQTTConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save MQTT Module Config") } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [SerialConfigEntity]Updated Serial Module Config for node: \(nodeNum.toHex(), privacy: .public)") - } catch { - - context.rollback() - - let nsError = error as NSError - Logger.data.error("💥 [SerialConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [SerialConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Serial Module Config") + } catch { + let nsError = error as NSError + Logger.data.error("💥 [MQTTConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } - } catch { - - let nsError = error as NSError - Logger.data.error("💥 [SerialConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } -} -func upsertStoreForwardModuleConfigPacket(config: ModuleConfig.StoreForwardConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Store & Forward 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) - // Found a node, save Store & Forward Sensor Config - if !fetchedNode.isEmpty { - if fetchedNode[0].storeForwardConfig == nil { - let newConfig = StoreForwardConfigEntity(context: context) - newConfig.enabled = config.enabled - newConfig.heartbeat = config.heartbeat - newConfig.records = Int32(config.records) - newConfig.historyReturnMax = Int32(config.historyReturnMax) - newConfig.historyReturnWindow = Int32(config.historyReturnWindow) - newConfig.isRouter = config.isServer - fetchedNode[0].storeForwardConfig = newConfig + func upsertRangeTestModuleConfigPacket(config: ModuleConfig.RangeTestConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertRangeTestModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertRangeTestModuleConfigPacket(config: ModuleConfig.RangeTestConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Range Test 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) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].rangeTestConfig == nil { + let newRangeTestConfig = RangeTestConfigEntity(context: context) + newRangeTestConfig.sender = Int32(config.sender) + newRangeTestConfig.enabled = config.enabled + newRangeTestConfig.save = config.save + fetchedNode[0].rangeTestConfig = newRangeTestConfig + } else { + fetchedNode[0].rangeTestConfig?.sender = Int32(config.sender) + fetchedNode[0].rangeTestConfig?.enabled = config.enabled + fetchedNode[0].rangeTestConfig?.save = config.save + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [RangeTestConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [RangeTestConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } } else { - fetchedNode[0].storeForwardConfig?.enabled = config.enabled - fetchedNode[0].storeForwardConfig?.heartbeat = config.heartbeat - fetchedNode[0].storeForwardConfig?.records = Int32(config.records) - fetchedNode[0].storeForwardConfig?.historyReturnMax = Int32(config.historyReturnMax) - fetchedNode[0].storeForwardConfig?.historyReturnWindow = Int32(config.historyReturnWindow) + Logger.data.error("💥 [RangeTestConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Range Test Module Config") } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [StoreForwardConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [StoreForwardConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - } else { - Logger.data.error("💥 [StoreForwardConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Store & Forward Module Config") + } catch { + let nsError = error as NSError + Logger.data.error("💥 [RangeTestConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [StoreForwardConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } -} -func upsertTelemetryModuleConfigPacket(config: ModuleConfig.TelemetryConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { - - let logString = String.localizedStringWithFormat("Telemetry 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) - // Found a node, save Telemetry Config - if !fetchedNode.isEmpty { - if fetchedNode[0].telemetryConfig == nil { - let newTelemetryConfig = TelemetryConfigEntity(context: context) - newTelemetryConfig.deviceUpdateInterval = Int32(truncatingIfNeeded: config.deviceUpdateInterval) - newTelemetryConfig.deviceTelemetryEnabled = config.deviceTelemetryEnabled - newTelemetryConfig.environmentUpdateInterval = Int32(truncatingIfNeeded: config.environmentUpdateInterval) - newTelemetryConfig.environmentMeasurementEnabled = config.environmentMeasurementEnabled - newTelemetryConfig.environmentScreenEnabled = config.environmentScreenEnabled - newTelemetryConfig.environmentDisplayFahrenheit = config.environmentDisplayFahrenheit - newTelemetryConfig.powerMeasurementEnabled = config.powerMeasurementEnabled - newTelemetryConfig.powerUpdateInterval = Int32(truncatingIfNeeded: config.powerUpdateInterval) - newTelemetryConfig.powerScreenEnabled = config.powerScreenEnabled - fetchedNode[0].telemetryConfig = newTelemetryConfig + func upsertSerialModuleConfigPacket(config: ModuleConfig.SerialConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertSerialModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertSerialModuleConfigPacket(config: ModuleConfig.SerialConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Serial 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) + // Found a node, save Device Config + if !fetchedNode.isEmpty { + if fetchedNode[0].serialConfig == nil { + let newSerialConfig = SerialConfigEntity(context: context) + newSerialConfig.enabled = config.enabled + newSerialConfig.echo = config.echo + newSerialConfig.rxd = Int32(config.rxd) + newSerialConfig.txd = Int32(config.txd) + newSerialConfig.baudRate = Int32(config.baud.rawValue) + newSerialConfig.timeout = Int32(config.timeout) + newSerialConfig.mode = Int32(config.mode.rawValue) + fetchedNode[0].serialConfig = newSerialConfig + } else { + fetchedNode[0].serialConfig?.enabled = config.enabled + fetchedNode[0].serialConfig?.echo = config.echo + fetchedNode[0].serialConfig?.rxd = Int32(config.rxd) + fetchedNode[0].serialConfig?.txd = Int32(config.txd) + fetchedNode[0].serialConfig?.baudRate = Int32(config.baud.rawValue) + fetchedNode[0].serialConfig?.timeout = Int32(config.timeout) + fetchedNode[0].serialConfig?.mode = Int32(config.mode.rawValue) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [SerialConfigEntity]Updated Serial Module Config for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + + context.rollback() + + let nsError = error as NSError + Logger.data.error("💥 [SerialConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } } else { - fetchedNode[0].telemetryConfig?.deviceUpdateInterval = Int32(truncatingIfNeeded: config.deviceUpdateInterval) - fetchedNode[0].telemetryConfig?.deviceTelemetryEnabled = config.deviceTelemetryEnabled - fetchedNode[0].telemetryConfig?.environmentUpdateInterval = Int32(truncatingIfNeeded: config.environmentUpdateInterval) - fetchedNode[0].telemetryConfig?.environmentMeasurementEnabled = config.environmentMeasurementEnabled - fetchedNode[0].telemetryConfig?.environmentScreenEnabled = config.environmentScreenEnabled - fetchedNode[0].telemetryConfig?.environmentDisplayFahrenheit = config.environmentDisplayFahrenheit - fetchedNode[0].telemetryConfig?.powerMeasurementEnabled = config.powerMeasurementEnabled - fetchedNode[0].telemetryConfig?.powerUpdateInterval = Int32(truncatingIfNeeded: config.powerUpdateInterval) - fetchedNode[0].telemetryConfig?.powerScreenEnabled = config.powerScreenEnabled + Logger.data.error("💥 [SerialConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Serial Module Config") } - if sessionPasskey != nil { - fetchedNode[0].sessionPasskey = sessionPasskey - fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) - } - do { - try context.save() - Logger.data.info("💾 [TelemetryConfigEntity] Updated Telemetry Module Config for node: \(nodeNum.toHex(), privacy: .public)") - - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("💥 [TelemetryConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") - } - - } else { - Logger.data.error("💥 [TelemetryConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Telemetry Module Config") + } catch { + + let nsError = error as NSError + Logger.data.error("💥 [SerialConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") } + } - } catch { - let nsError = error as NSError - Logger.data.error("💥 [TelemetryConfigEntity] Fetching node for core data TelemetryConfigEntity failed: \(nsError, privacy: .public)") + func upsertStoreForwardModuleConfigPacket(config: ModuleConfig.StoreForwardConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertStoreForwardModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertStoreForwardModuleConfigPacket(config: ModuleConfig.StoreForwardConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Store & Forward 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) + // Found a node, save Store & Forward Sensor Config + if !fetchedNode.isEmpty { + if fetchedNode[0].storeForwardConfig == nil { + let newConfig = StoreForwardConfigEntity(context: context) + newConfig.enabled = config.enabled + newConfig.heartbeat = config.heartbeat + newConfig.records = Int32(config.records) + newConfig.historyReturnMax = Int32(config.historyReturnMax) + newConfig.historyReturnWindow = Int32(config.historyReturnWindow) + newConfig.isRouter = config.isServer + fetchedNode[0].storeForwardConfig = newConfig + } else { + fetchedNode[0].storeForwardConfig?.enabled = config.enabled + fetchedNode[0].storeForwardConfig?.heartbeat = config.heartbeat + fetchedNode[0].storeForwardConfig?.records = Int32(config.records) + fetchedNode[0].storeForwardConfig?.historyReturnMax = Int32(config.historyReturnMax) + fetchedNode[0].storeForwardConfig?.historyReturnWindow = Int32(config.historyReturnWindow) + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [StoreForwardConfigEntity] Updated for node: \(nodeNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [StoreForwardConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + } else { + Logger.data.error("💥 [StoreForwardConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Store & Forward Module Config") + } + } catch { + let nsError = error as NSError + Logger.data.error("💥 [StoreForwardConfigEntity] Fetching node for core data failed: \(nsError, privacy: .public)") + } + } + + func upsertTelemetryModuleConfigPacket(config: ModuleConfig.TelemetryConfig, nodeNum: Int64, sessionPasskey: Data? = Data()) async { + let context = self.backgroundContext + await context.perform { + self.upsertTelemetryModuleConfigPacket(config: config, nodeNum: nodeNum, sessionPasskey: sessionPasskey, context: context) + } + } + + nonisolated func upsertTelemetryModuleConfigPacket(config: ModuleConfig.TelemetryConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { + + let logString = String.localizedStringWithFormat("Telemetry 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) + // Found a node, save Telemetry Config + if !fetchedNode.isEmpty { + if fetchedNode[0].telemetryConfig == nil { + let newTelemetryConfig = TelemetryConfigEntity(context: context) + newTelemetryConfig.deviceUpdateInterval = Int32(truncatingIfNeeded: config.deviceUpdateInterval) + newTelemetryConfig.deviceTelemetryEnabled = config.deviceTelemetryEnabled + newTelemetryConfig.environmentUpdateInterval = Int32(truncatingIfNeeded: config.environmentUpdateInterval) + newTelemetryConfig.environmentMeasurementEnabled = config.environmentMeasurementEnabled + newTelemetryConfig.environmentScreenEnabled = config.environmentScreenEnabled + newTelemetryConfig.environmentDisplayFahrenheit = config.environmentDisplayFahrenheit + newTelemetryConfig.powerMeasurementEnabled = config.powerMeasurementEnabled + newTelemetryConfig.powerUpdateInterval = Int32(truncatingIfNeeded: config.powerUpdateInterval) + newTelemetryConfig.powerScreenEnabled = config.powerScreenEnabled + fetchedNode[0].telemetryConfig = newTelemetryConfig + } else { + fetchedNode[0].telemetryConfig?.deviceUpdateInterval = Int32(truncatingIfNeeded: config.deviceUpdateInterval) + fetchedNode[0].telemetryConfig?.deviceTelemetryEnabled = config.deviceTelemetryEnabled + fetchedNode[0].telemetryConfig?.environmentUpdateInterval = Int32(truncatingIfNeeded: config.environmentUpdateInterval) + fetchedNode[0].telemetryConfig?.environmentMeasurementEnabled = config.environmentMeasurementEnabled + fetchedNode[0].telemetryConfig?.environmentScreenEnabled = config.environmentScreenEnabled + fetchedNode[0].telemetryConfig?.environmentDisplayFahrenheit = config.environmentDisplayFahrenheit + fetchedNode[0].telemetryConfig?.powerMeasurementEnabled = config.powerMeasurementEnabled + fetchedNode[0].telemetryConfig?.powerUpdateInterval = Int32(truncatingIfNeeded: config.powerUpdateInterval) + fetchedNode[0].telemetryConfig?.powerScreenEnabled = config.powerScreenEnabled + } + if sessionPasskey != nil { + fetchedNode[0].sessionPasskey = sessionPasskey + fetchedNode[0].sessionExpiration = Date().addingTimeInterval(300) + } + do { + try context.save() + Logger.data.info("💾 [TelemetryConfigEntity] Updated Telemetry Module Config for node: \(nodeNum.toHex(), privacy: .public)") + + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("💥 [TelemetryConfigEntity] Error Updating Core Data: \(nsError, privacy: .public)") + } + + } else { + Logger.data.error("💥 [TelemetryConfigEntity] No Nodes found in local database matching node \(nodeNum.toHex(), privacy: .public) unable to save Telemetry Module Config") + } + + } catch { + let nsError = error as NSError + Logger.data.error("💥 [TelemetryConfigEntity] Fetching node for core data TelemetryConfigEntity failed: \(nsError, privacy: .public)") + } } } diff --git a/Meshtastic/Resources/Certificates/ca.pem b/Meshtastic/Resources/Certificates/ca.pem new file mode 100644 index 00000000..1dc6e36f --- /dev/null +++ b/Meshtastic/Resources/Certificates/ca.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID4zCCAsugAwIBAgIUeM9XhqZCtta+QorYNjZSdAk3gkMwDQYJKoZIhvcNAQEL +BQAwgYAxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQH +DA1TYW4gRnJhbmNpc2NvMRMwEQYDVQQKDApNZXNodGFzdGljMRMwEQYDVQQLDApU +QUsgU2VydmVyMRowGAYDVQQDDBFNZXNodGFzdGljIFRBSyBDQTAeFw0yNTEyMzEx +OTQwMDJaFw0yODA0MDQxOTQwMDJaMIGAMQswCQYDVQQGEwJVUzETMBEGA1UECAwK +Q2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzETMBEGA1UECgwKTWVz +aHRhc3RpYzETMBEGA1UECwwKVEFLIFNlcnZlcjEaMBgGA1UEAwwRTWVzaHRhc3Rp +YyBUQUsgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2F6/n1CI2 +4dGtLt0irkfiU+PRmqkkuE7m49i7/FeH+38SEn9+0B4egW0kYRoRXmYdPzRsVttu +23LZ3RLjwB6fFI3tiA27mxD58AuEMfwVR7J29oHqFwuVhuqDyjkNpUPFUomKwzvK +SPJvoiHGkbQwWTMNP6T06tCg9llSE7SIgJWjzikQ+JsI37SqVGZ8K2evs7LTuyQh +ssJfYVB7aE1kNNyi8YFHLoCWQMB7h8qJ3hRd7QGFG9gfWuNrWtim61iiHgBAPTRw +gMn+YSIZiV9/iOytBKxFppNTxffEowF/iKBvgXwd9KHxYkk1Nvtcz5NJynSL75PT +8B7XiHCGhcgzAgMBAAGjUzBRMB0GA1UdDgQWBBRRe/o9Raj93Fq22ArNSNrpsye3 +AzAfBgNVHSMEGDAWgBRRe/o9Raj93Fq22ArNSNrpsye3AzAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAsuSQ+j/1Bm7HbZWzN5qChH554vucWoqI0 +sVRHThvCASC6+wSosWZlx/Ag5KnRmBVsYA6CX5ztoF5keiSRy5G7qyRQVjITOq1o +4XUAHBtGxKdRCEzS84GnsW9qeWX7t/xxf2fFr9gPZ7Z4nuyNg7QyX5FM01BtAlZC +HbBhXvJyHRqJkMe7keYU7GmiAs1RZa+7593uEQ8DQ/kRvCzU0XswFSguJrd4Fnpi +PGesGOk0NHFQY9pIu9oshgPgMA9dEWnhhvAF3PZ3sLRn9sSuslj5oumFsTYboByE +aOKQshFe5xEX/4O7DI+wsD1Pt5gdT75nAuG7GEAIFKKGjQtUUYfH +-----END CERTIFICATE----- diff --git a/Meshtastic/Resources/Certificates/client.p12 b/Meshtastic/Resources/Certificates/client.p12 new file mode 100644 index 0000000000000000000000000000000000000000..2f27bff2d6a5e102bb7f6c343c6a1bacd530a01f GIT binary patch literal 3827 zcmai%Ra6uV*M(siy1OKXZf59~lvYv(kZ$RghCv!6hejG1X(T13q)S2=QV9`|9vVKs z_5bUAFTabk&f0rloV&fA1BMYD0njnPFrsoST%KsP=u1L$Z1f@+Q6>;Zl=jzt2ZrI? z{2O5v!EpBfTDt&rw7+ZP-vk|O@sGeH1nYqz|4ITd8<6B>YXf4p8|-+3jMm>|GPjeHRQ?95lw%h$R&(@2y#h=y^xPoE3(OBv+Y2 zD0|sQwbe&|WtF?V*63pki3&NW6zs6_NyalKY_BSDTLK}&&Cd=uJ?qJED9On0zr-;Z zSL=#op>@>1l3EJ&)F{HD%Lv>0Gr7#R{ARcX^dt3xmh%{^xOdXJ03p#)ik;&mbmM0w zw_RX=O@dRWT_w)X6vyUColxL6;`puz)f_Q5QB?yUj2+a>Rmt?QPZ@ll-%zgpv-J?o z0sE<=u|O*p(6Z@$&)ze1xrdATQ^SLlvoi_G=Gu&}_V=GDPbIMjys!Ij{Do z(@yPqYxj7N^m7+@@*g#|O;YA{L|kEyUWGvcMp5foof+3`O^U8iDF4CBI#9*FjSpTK z(kV;RlA;rXRbWFF7|Eljyx(zSoj7{sAI7MnGDMujI9(0p>zjZxu>EdPcO}n?VaC5x z-9iNPB9BygVFRC;Zjs!L5KsBSmCbd>v=)U4nR%Sa$sIb)qS{R4Hz=zOYolC}#)wij zd;{U&2&sXWs_pL5V~EOH|9Yl5takQmjiqebepv;l__d^Hq9y%h)=mOV zzm$_k9ZSD#Lh^N$AVxV(IsiH*sTaNXkE7X+-~krY8iq52t{_#I`JS3fYi∈>}Zi zLl@B(^{PI`XFin|sqOv0KkCnahq`pn*v9No{XhlMSeUrrr?5BoniGrI&7hT+Byg#l z4sl*{e2$%9?J;$eo=HtCc+O{N`g`2?RS)gMCwty`t|0C)^**gjv)e!R1U`4`Q+bIW z(|tJGqgWJ~HK_O#1fc_W7Q$^eW>-!50o7HZ>lf>>60e3ejaoLbe9BlxRMSh_Kq})m z*l3^zzpBrRMY#RkkRD}@Z8a`lp@;2e8QjtEW2pybo;33 zzg-yn0ubF8S}KeE2Y;8z(o2BLl0}V$x)T;5!7pVeb<`h#KE)ZDWHtbwG+cb(@1xZ6 zUR8GZuFe4~`MUlzG0kZR8PEkqN@E2zVMs|wjjEPvD@ZNxj>}Y;;iffrP~#4sOrHtE z3HxdbOvNlsUD4Epo$V7TYOM z^l0(f+I&$(Dk`5GFt`n$l|C+9StshwnikA6cg1^JO0yak8L~nD@w-&@J_Z{@S-I#; zSu6T7g{CwVKC>LNiviHw^C5<%QJEMGoew~I>YAeYX6Zo$Qvr(HwI&R z$FQ}7o=0d^5Mm;iawERung4Gy*4$3b(p*YALWIoZQ7S?;kpE|pxf$#CeR-h|9+}0M z@=rGoiz}%u!kVz4)siJTy4|U&N&qOTUx@@@=PyE%ag8g)gTQvWJ;BC4&&dguH3z7H5F1Z*n_t!5<6ke)W*xO_VY4{S7EG#8w zq^gyl7T^DNlsaJ>O9-cf6xc;1r41wlx^k3hR>HO`^sxzT+!#`H1a_Q<%u`6CH*7pS zXwnl}9r+iwI)z+$ssk(ib-k`TSbyYjrW{O^DFIRGJ5U5p!6x5lmiGYJJc$AfuskPh}AO1aWmN0>dy9|KXVbf)Qp25QZ7>*Y^E8o>=(*&rAe( z=zzbWs7j$G2zwp$(7x{d6;ttlf>2Jy7RrjzA&)`BT&zB8-vbB(P7^QH zzbme~8!0Z9v9FL?a%%sz?m&t*UZnbgGgQOqY&%zF^JsXfK7xb+#zBan!k>2dR688RnpJve9i=BYac`LveawsI8j2}jEKs?L>Aepo`B)nd|%)Hl}pa#oP8jT9*y?S9|L>J19L9BO@4xQ06poFjCVv z$p9-pNPW;VGMPvblbzw4Z~=XW(y3+Mb_}hVLG6Pvd+05~!BTGj_*^V(XX{2XyU&716)b;+*``-Vt2S6PrWyi~1D zf0GG;{VavwUizK%_zF4sQQ_*Jj*zGG_(ZyuIfhD#vM;BOkMM4l){V1J8FfgD(H*@>a{c^!8m{WO6kGk}8iMqOA>>>4#= z-myZlHMA#m?u4b^js9p>CD^ zvK}Dx0>fj1eELO^Py=ULN; z@s)X(Nw!N=PEjdy_WiSa`6e%onppf!za5M1=gP}6#q;-a;Weu0(rg+1Q?`AAJ$J)i zgm`+Yo8l2f2f-K^ouH3 z0^x$$2VhgUpSk>{lXb&!MDVe$@bSc%Rv<)omE16@pivG|0!BrN(dE@Vku4{Yh*f> zj4(qQdHGonI!0Z+~(BP>IVP2_LZC&a4 zfQZJ@q=QPPi#0I@M~>YQGW(`s(t>D70AYAN<46FF-SZskQq3D)u$N!Bu7H2pZCSE` zfdo4VhHPcqyEQN0W387oXmDp3h1Bd5tYkP#qjJlyjjk$f{iGag0+u&65;Oty<~pg| zZx;<@nC)AcnKFLO;ly@jEpDBrRhvBcHr_tbIvwka+`hgad?RONPiR-uJ=}b@v6=B? zj)JO~r-a}V-4))-K(gO2AUJi3QcOcV#JKB-(p^93C?vh}0v#)kKVAPZ8C>D~#fNNj zd*hc(Aoo@dld;^NYbV55b-y<>>RH=mx58PqEpOiS+)Oi2%Guk4yt2p*ZC3YU8DIS5 zfLC@;?R^@6xoM@M^GuhD#ZuYzRf>km@W)LA>>|(Mfw-6ut!wX;hSng50o{oL<8!F< zJNKQ7+X_d zIpX_^V}fqmx#wCDE%$n*6E=md!!PAgfp~p)9S9};CE>m!ZdziFblk_*m(J+%K)$2y zTF=9)6YtHokOVpTCdXFRQ={n3C*%ps#>-}QCY1XoNuq~E32Y&o{#g=zCB9A~eLgtl zC2}Mjk+~P;Pa1t2t5+I1MP?B21j9cY+wS+s+J>g*5%;L5_z0^~*0#$7rnp0037K0n zpVvY+2VGb(coiv%1IJ(fTc_3J1}z?Z5Z_uLOP6eukhEJjlKEHg{Jba1TrB~nN3FY5 zMk)-bj&cchJFl{M6P}gbGx;R*afz&6Z08v^mSg&vz|+nry=zvY32L%t1sJ_Ut3X3;m#9MK5D#4;&=aolH&{UcmS-Oy>p2gSbma=RIgYRN2<`0a`nD3oNiNk39 zA|Ho?Xnr(skKGt}CmEFeMvC{zF#S)HFd8>%g-8U^w@scV}F>ap|-e?YZf>dMqUJ8F{BwOJj>&w2g1 zY7tm^Uc8{M)}*=uiCyUguNc;R2)oGsj=60>I+3soO{n=pZx*tb!VW26zlzWf($^PG z!tKTrl(RH(*SuSlDhukJP@H@29fy5#Vlcm3$?*2jRmMoSerl!RsxglUK;W>We9L`4 z*?#|dBA;SmZNE>!(Pq$}0z{zt+RaFz9pWD#!+}>sWMrH0w!+8qv_$l-Z(tQ=&>SBd zsx8`0&Zc)x8@_z@T68H^<2_Gn>pA%G7T2aIBHGX^{#e)@;S=CluoAcOp@zH?ZWX@FLT*%G~? zMF%JJsqXC!{kz`jmi?UYQx3?Rr?199=-^dux2Vwb2si)!*Fdo?`kw>EG{q&tb1QC6 zLy3DvFPliF)V|GWRpFj2=)xC~N>N8>LKV>IM6mBr9KrLwyZC4s{hKYRrI}a+Hi{dc z?sqIn@bp!a&*k&{;jLr)DZHTB?2OwvXTO}Lz9e+7+&&07lhPq7Zc zx|9dc&>FI@MRbyIC4QGFeWybNO_< zb*X@B5sm^zxm2WwJ6RflYIHb8ZOWkwv&V&L3Ri22l?*GRbIFQchWSUIWyL$CL`?Uw zjPjORs>4DK1(%n_4EtgV5-qye^#Z}UY@UWJpIoc(RY5YR0~^akm1p2fDw9`f+A2o3 z%*F7Z7e?3!v(zuQ;@3%ybe6)uup^4+G=q&Fj8$oV&)v>zG>RgJ(ZARz&TdT#lV*gJ zEE);_R`!1BgtN%sn-x#@MA;~OS8Ka?Y1049(=naWq^1dk5qid0!kUo?ALc)@w=$jf3fUm`xV&t8) zeoh@QwVszRcT6{}tZkGLoPnZG3LT)9LKyLY$!(DLiBgDXp(l_mX@ zIp<7#Kl=q>!3c89nk16KWn?YC5aHB@#VDURd~HB7x$Ka&>`Q`;@75X_N254s^c3`t;N7-TX&` zW6i&pYe?6lAPKNZ7#8^CAI|wNH~}L7SRm%F9sGChgXsUCsUUQeRDVO2zvi|71xnVe zWEIou`Z6P1DMa)E=*Is9rR1_T;qnzOflOpDj zE#R$tb23Dg99g#LB(0JUmsWSg!s0jIr}uxSOP1%juGv53fEMmH)ABEET;adtPwnEC z3e_OZqx&Q&o{xr;o_7%0t_ep(wo86lXnk`h>e*dqO!5)C<+|j~yQT^2?D;i1&ZEwV z*c|RoixS}FfY+K;tZb`}5BS99n5$3FWii2n@dPPxgKb_9rT8aIIqN2G^N-z@Q=&=t zDDNAHf<*h#YDp5n z`HwrwH)2NHxeqoi*)MV-CF#bi=--2|<;)jihlBY_m%HG(=^5qTcwNyCjRp}NNcm`f zh4gBS(peNHfAlc6u^;8+ah97W=qyKKTQ&k7O{h6*+>G(v3b{UP?+@7$=SXf z@YTeOR(Lx3Ox=GQRPBc8Qp{o*zaKk+nseUp(!6Ij^-A(C6X+2>(?XEEmD(}HPiIK& zJ%r*&vQwbJRW>e|kK>C|Yje)mdg9`?ytrK0$t=E^GjHbqL}<`gMEY6X#MZ5sLHOeb z%eO1DKwO6bpb1^ToYS2>+6<#BmS0=cODYxL7o+yqm@U4l2saoVE$l=*RIMB*E*kmBHNgIXHUFY;IY`SBq#&Qjb&^;MFHb)sQ{G8(Pm6(-jr3e7OQGtN$LUKOG{ zCRQksZP7|Fb}X|URXN`=U&-C|Du1V_aYdM{S+CS3*j_8^l>D7~8uD#2Kasq|SL-fN zzY6<#ov`NvHnmMws_Yd?daSHEtULHJsfVameH*VGUQY}9+8J#MGj zux;k2ZW~)3Zt%xAI0mc%d|%QQY=1iEZ(>+kGZPT%gJi=?wEGC8d`}sJ#`3V%iA*wi z2&@-VKmA-S)va~0@1nyU4SCi6}0F^sRH@zEZW2ouc3=TqDV5LwxgVq!MXJ zg%`LY>dEulNQ|&)p@oFAw)<*3EU4xmk%J{9dx6qP7~2)c78w_$<6frlEnL5m zLg`bU@7d(X%bJLIO_?U+e&VMtzYFm6Gg2hli29Y_-1BCbLmnTFxN=A|`?;Aq zvnJjpWnvUn-#XG=3RT(nL;mGDk$;MomR*hma7k=uGr&dljJ_wwCso}64n(AUU4!Ys z( @@ -458,6 +470,7 @@ struct Settings: View { developersSection #endif firmwareSection + takSection } } .navigationDestination(for: SettingsNavigationState.self) { destination in @@ -521,6 +534,8 @@ struct Settings: View { AppData() case .firmwareUpdates: Firmware(node: node) + case .tak: + TAKServerConfig() } } .onChange(of: UserDefaults.preferredPeripheralNum ) { _, newConnectedNode in diff --git a/Meshtastic/Views/Settings/TAKServerConfig.swift b/Meshtastic/Views/Settings/TAKServerConfig.swift new file mode 100644 index 00000000..749b54fc --- /dev/null +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -0,0 +1,390 @@ +// +// TAKServerConfig.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import SwiftUI +import UniformTypeIdentifiers +import OSLog + +enum CertificateImportType { + case p12 + case pem +} + +struct TAKServerConfig: View { + @StateObject private var takServer = TAKServerManager.shared + @State private var showingFileImporter = false + @State private var importType: CertificateImportType = .p12 + @State private var p12Password = "" + @State private var showingPasswordPrompt = false + @State private var pendingP12Data: Data? + @State private var importError: String? + @State private var showingImportError = false + @State private var showingFileExporter = false + @State private var dataPackageURL: URL? + + private let certManager = TAKCertificateManager.shared + + var body: some View { + Form { + serverStatusSection + serverConfigSection + certificatesSection + dataPackageSection + } + .navigationTitle("TAK Server") + .fileImporter( + isPresented: $showingFileImporter, + allowedContentTypes: importType == .p12 ? [UTType(filenameExtension: "p12") ?? .pkcs12, .pkcs12] : [UTType(filenameExtension: "pem") ?? .plainText], + allowsMultipleSelection: false + ) { result in + switch importType { + case .p12: + handleP12Import(result) + case .pem: + handlePEMImport(result) + } + } + .alert("Enter P12 Password", isPresented: $showingPasswordPrompt) { + SecureField("Password", text: $p12Password) + Button("Import") { + importP12WithPassword() + } + Button("Cancel", role: .cancel) { + p12Password = "" + pendingP12Data = nil + } + } message: { + Text("Enter the password for the PKCS#12 file") + } + .alert("Import Error", isPresented: $showingImportError) { + Button("OK", role: .cancel) {} + } message: { + Text(importError ?? "Unknown error") + } + .fileExporter( + isPresented: $showingFileExporter, + document: dataPackageURL.map { ZipDocument(url: $0) }, + contentType: .zip, + defaultFilename: "Meshtastic_TAK_Server.zip" + ) { result in + switch result { + case .success(let url): + Logger.tak.info("Data package saved to: \(url.path)") + case .failure(let error): + importError = "Failed to save: \(error.localizedDescription)" + showingImportError = true + } + // Clean up the source file + if let sourceURL = dataPackageURL { + try? FileManager.default.removeItem(at: sourceURL) + } + dataPackageURL = nil + } + } + + // MARK: - Server Status Section + + private var serverStatusSection: some View { + Section { + HStack { + Label { + Text("Status") + } icon: { + Circle() + .fill(takServer.isRunning ? .green : .gray) + .frame(width: 10, height: 10) + } + Spacer() + Text(takServer.statusDescription) + .foregroundColor(.secondary) + } + + if let error = takServer.lastError { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text(error) + .font(.caption) + .foregroundColor(.orange) + } + } + } header: { + Text("Server Status") + } + } + + // MARK: - Server Configuration Section + + private var serverConfigSection: some View { + Section { + Toggle(isOn: $takServer.enabled) { + Label("Enable TAK Server", systemImage: "antenna.radiowaves.left.and.right") + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + HStack { + Label("Port", systemImage: "number") + Spacer() + Text("8089") + .foregroundColor(.secondary) + } + + HStack { + Label("Security", systemImage: "lock.fill") + Spacer() + Text("mTLS") + .foregroundColor(.secondary) + } + + if takServer.isRunning { + Button { + Task { + try? await takServer.restart() + } + } label: { + Label("Restart Server", systemImage: "arrow.clockwise") + } + } + } header: { + Text("Configuration") + } footer: { + Text("Secure mTLS connection on port 8089. Both server and client certificates are required.") + } + } + + // MARK: - Certificates Section + + private var certificatesSection: some View { + Section { + // Server Certificate + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Server Certificate", systemImage: "key.fill") + Spacer() + if certManager.hasServerCertificate() { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } else { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + } + + if let certInfo = certManager.getServerCertificateInfo() { + Text(certInfo) + .font(.caption) + .foregroundColor(.secondary) + } + + HStack { + Button { + importType = .p12 + showingFileImporter = true + } label: { + Text("Import Custom .p12") + } + .buttonStyle(.bordered) + + if certManager.hasCustomServerCertificate() { + Button { + certManager.resetToDefaultServerCertificate() + } label: { + Text("Reset to Default") + } + .buttonStyle(.bordered) + } + } + } + .padding(.vertical, 4) + + // Client CA Certificate + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Client CA Certificate", systemImage: "person.badge.shield.checkmark") + Spacer() + if certManager.hasClientCACertificate() { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } else { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + } + } + + let caInfo = certManager.getClientCACertificateInfo() + if !caInfo.isEmpty { + ForEach(caInfo, id: \.self) { info in + Text(info) + .font(.caption) + .foregroundColor(.secondary) + } + } + + HStack { + Button { + importType = .pem + showingFileImporter = true + } label: { + Text(certManager.hasClientCACertificate() ? "Add CA" : "Import .pem") + } + .buttonStyle(.bordered) + + if certManager.hasClientCACertificate() { + Button(role: .destructive) { + certManager.deleteClientCACertificates() + } label: { + Text("Delete All") + } + .buttonStyle(.bordered) + } + } + } + .padding(.vertical, 4) + + // Reset to bundled defaults + Button { + certManager.reloadBundledCertificates() + if takServer.isRunning { + Task { + try? await takServer.restart() + } + } + } label: { + Label("Reload Bundled Certificates", systemImage: "arrow.triangle.2.circlepath") + } + } header: { + Text("TLS Certificates") + } footer: { + Text("A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients.") + } + } + + // MARK: - Data Package Section + + private var dataPackageSection: some View { + Section { + Button { + generateAndShareDataPackage() + } label: { + Label("Download TAK Server Data Package", systemImage: "arrow.down.doc.fill") + } + } header: { + Text("Client Configuration") + } footer: { + Text("Generate a data package (.zip) to configure TAK clients to connect to this server.") + } + } + + + // MARK: - Import Handlers + + private func handleP12Import(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + + guard url.startAccessingSecurityScopedResource() else { + importError = "Cannot access file" + showingImportError = true + return + } + defer { url.stopAccessingSecurityScopedResource() } + + do { + pendingP12Data = try Data(contentsOf: url) + p12Password = "" + showingPasswordPrompt = true + } catch { + importError = "Failed to read file: \(error.localizedDescription)" + showingImportError = true + } + + case .failure(let error): + importError = error.localizedDescription + showingImportError = true + } + } + + private func importP12WithPassword() { + guard let data = pendingP12Data else { return } + + do { + _ = try certManager.importServerIdentity(from: data, password: p12Password) + Logger.tak.info("Server certificate imported successfully") + } catch { + importError = error.localizedDescription + showingImportError = true + } + + p12Password = "" + pendingP12Data = nil + } + + private func handlePEMImport(_ result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + + guard url.startAccessingSecurityScopedResource() else { + importError = "Cannot access file" + showingImportError = true + return + } + defer { url.stopAccessingSecurityScopedResource() } + + do { + let data = try Data(contentsOf: url) + _ = try certManager.importClientCACertificate(from: data) + Logger.tak.info("Client CA certificate imported successfully") + } catch { + importError = error.localizedDescription + showingImportError = true + } + + case .failure(let error): + importError = error.localizedDescription + showingImportError = true + } + } + + // MARK: - Data Package Generation + + private func generateAndShareDataPackage() { + guard let url = TAKDataPackageGenerator.shared.generateDataPackage( + port: TAKServerManager.defaultTLSPort, + useTLS: true, + description: "Meshtastic TAK Server" + ) else { + importError = "Failed to generate data package" + showingImportError = true + return + } + + dataPackageURL = url + showingFileExporter = true + } +} + +// MARK: - Zip Document for File Exporter + +struct ZipDocument: FileDocument { + static var readableContentTypes: [UTType] { [.zip] } + + let data: Data + + init(url: URL) { + self.data = (try? Data(contentsOf: url)) ?? Data() + } + + init(configuration: ReadConfiguration) throws { + self.data = configuration.file.regularFileContents ?? Data() + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} diff --git a/MeshtasticProtobufs/Sources/meshtastic/admin.pb.swift b/MeshtasticProtobufs/Sources/meshtastic/admin.pb.swift index 1bb9c2ce..5b1b5dee 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/admin.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/admin.pb.swift @@ -664,6 +664,16 @@ public struct AdminMessage: Sendable { set {payloadVariant = .otaRequest(newValue)} } + /// + /// Parameters and sensor configuration + public var sensorConfig: SensorConfig { + get { + if case .sensorConfig(let v)? = payloadVariant {return v} + return SensorConfig() + } + set {payloadVariant = .sensorConfig(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() /// @@ -852,6 +862,9 @@ public struct AdminMessage: Sendable { /// /// Tell the node to reset into the OTA Loader case otaRequest(AdminMessage.OTAEvent) + /// + /// Parameters and sensor configuration + case sensorConfig(SensorConfig) } @@ -1009,6 +1022,14 @@ public struct AdminMessage: Sendable { /// /// TODO: REPLACE case paxcounterConfig // = 12 + + /// + /// TODO: REPLACE + case statusmessageConfig // = 13 + + /// + /// Traffic management module config + case trafficmanagementConfig // = 14 case UNRECOGNIZED(Int) public init() { @@ -1030,6 +1051,8 @@ public struct AdminMessage: Sendable { case 10: self = .ambientlightingConfig case 11: self = .detectionsensorConfig case 12: self = .paxcounterConfig + case 13: self = .statusmessageConfig + case 14: self = .trafficmanagementConfig default: self = .UNRECOGNIZED(rawValue) } } @@ -1049,6 +1072,8 @@ public struct AdminMessage: Sendable { case .ambientlightingConfig: return 10 case .detectionsensorConfig: return 11 case .paxcounterConfig: return 12 + case .statusmessageConfig: return 13 + case .trafficmanagementConfig: return 14 case .UNRECOGNIZED(let i): return i } } @@ -1068,6 +1093,8 @@ public struct AdminMessage: Sendable { .ambientlightingConfig, .detectionsensorConfig, .paxcounterConfig, + .statusmessageConfig, + .trafficmanagementConfig, ] } @@ -1338,6 +1365,171 @@ public struct KeyVerificationAdmin: Sendable { fileprivate var _securityNumber: UInt32? = nil } +public struct SensorConfig: 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. + + /// + /// SCD4X CO2 Sensor configuration + public var scd4XConfig: SCD4X_config { + get {return _scd4XConfig ?? SCD4X_config()} + set {_scd4XConfig = newValue} + } + /// Returns true if `scd4XConfig` has been explicitly set. + public var hasScd4XConfig: Bool {return self._scd4XConfig != nil} + /// Clears the value of `scd4XConfig`. Subsequent reads from it will return its default value. + public mutating func clearScd4XConfig() {self._scd4XConfig = nil} + + /// + /// SEN5X PM Sensor configuration + public var sen5XConfig: SEN5X_config { + get {return _sen5XConfig ?? SEN5X_config()} + set {_sen5XConfig = newValue} + } + /// Returns true if `sen5XConfig` has been explicitly set. + public var hasSen5XConfig: Bool {return self._sen5XConfig != nil} + /// Clears the value of `sen5XConfig`. Subsequent reads from it will return its default value. + public mutating func clearSen5XConfig() {self._sen5XConfig = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _scd4XConfig: SCD4X_config? = nil + fileprivate var _sen5XConfig: SEN5X_config? = nil +} + +public struct SCD4X_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} + + /// + /// Sensor ambient pressure in Pa. 70000 - 120000 Pa (overrides altitude) + public var setAmbientPressure: UInt32 { + get {return _setAmbientPressure ?? 0} + set {_setAmbientPressure = newValue} + } + /// Returns true if `setAmbientPressure` has been explicitly set. + public var hasSetAmbientPressure: Bool {return self._setAmbientPressure != nil} + /// Clears the value of `setAmbientPressure`. Subsequent reads from it will return its default value. + public mutating func clearSetAmbientPressure() {self._setAmbientPressure = nil} + + /// + /// Perform a factory reset of the sensor + public var factoryReset: Bool { + get {return _factoryReset ?? false} + set {_factoryReset = newValue} + } + /// Returns true if `factoryReset` has been explicitly set. + public var hasFactoryReset: Bool {return self._factoryReset != nil} + /// Clears the value of `factoryReset`. Subsequent reads from it will return its default value. + public mutating func clearFactoryReset() {self._factoryReset = nil} + + /// + /// Power mode for sensor (true for low power, false for normal) + public var setPowerMode: Bool { + get {return _setPowerMode ?? false} + set {_setPowerMode = newValue} + } + /// Returns true if `setPowerMode` has been explicitly set. + public var hasSetPowerMode: Bool {return self._setPowerMode != nil} + /// Clears the value of `setPowerMode`. Subsequent reads from it will return its default value. + public mutating func clearSetPowerMode() {self._setPowerMode = 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 _setAmbientPressure: UInt32? = nil + fileprivate var _factoryReset: Bool? = nil + fileprivate var _setPowerMode: Bool? = nil +} + +public struct SEN5X_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. + + /// + /// 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} + + /// + /// One-shot mode (true for low power - one-shot mode, false for normal - continuous mode) + public var setOneShotMode: Bool { + get {return _setOneShotMode ?? false} + set {_setOneShotMode = newValue} + } + /// Returns true if `setOneShotMode` has been explicitly set. + public var hasSetOneShotMode: Bool {return self._setOneShotMode != nil} + /// Clears the value of `setOneShotMode`. Subsequent reads from it will return its default value. + public mutating func clearSetOneShotMode() {self._setOneShotMode = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _setTemperature: Float? = nil + fileprivate var _setOneShotMode: Bool? = nil +} + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "meshtastic" @@ -1348,7 +1540,7 @@ extension OTAMode: SwiftProtobuf._ProtoNameProviding { extension AdminMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { 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") + 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") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -1900,6 +2092,19 @@ extension AdminMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat 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 } } @@ -2136,9 +2341,17 @@ extension AdminMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat if !self.sessionPasskey.isEmpty { try visitor.visitSingularBytesField(value: self.sessionPasskey, fieldNumber: 101) } - try { if case .otaRequest(let v)? = self.payloadVariant { + 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 unknownFields.traverse(visitor: &visitor) } @@ -2155,7 +2368,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") + 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") } extension AdminMessage.BackupLocation: SwiftProtobuf._ProtoNameProviding { @@ -2418,3 +2631,145 @@ extension KeyVerificationAdmin: SwiftProtobuf.Message, SwiftProtobuf._MessageImp extension KeyVerificationAdmin.MessageType: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0INITIATE_VERIFICATION\0\u{1}PROVIDE_SECURITY_NUMBER\0\u{1}DO_VERIFY\0\u{1}DO_NOT_VERIFY\0") } + +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 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.decodeSingularMessageField(value: &self._scd4XConfig) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._sen5XConfig) }() + 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._scd4XConfig { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try { if let v = self._sen5XConfig { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() + 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.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension SCD4X_config: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".SCD4X_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_ambient_pressure\0\u{3}factory_reset\0\u{3}set_power_mode\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._setAmbientPressure) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self._factoryReset) }() + case 7: try { try decoder.decodeSingularBoolField(value: &self._setPowerMode) }() + 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._setAmbientPressure { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 5) + } }() + try { if let v = self._factoryReset { + try visitor.visitSingularBoolField(value: v, fieldNumber: 6) + } }() + try { if let v = self._setPowerMode { + try visitor.visitSingularBoolField(value: v, fieldNumber: 7) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: SCD4X_config, rhs: SCD4X_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._setAmbientPressure != rhs._setAmbientPressure {return false} + if lhs._factoryReset != rhs._factoryReset {return false} + if lhs._setPowerMode != rhs._setPowerMode {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension SEN5X_config: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".SEN5X_config" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}set_temperature\0\u{3}set_one_shot_mode\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.decodeSingularFloatField(value: &self._setTemperature) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self._setOneShotMode) }() + 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._setTemperature { + try visitor.visitSingularFloatField(value: v, fieldNumber: 1) + } }() + try { if let v = self._setOneShotMode { + try visitor.visitSingularBoolField(value: v, fieldNumber: 2) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: SEN5X_config, rhs: SEN5X_config) -> Bool { + if lhs._setTemperature != rhs._setTemperature {return false} + if lhs._setOneShotMode != rhs._setOneShotMode {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 5dddccd7..943c2d2c 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/config.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/config.pb.swift @@ -1032,6 +1032,10 @@ public struct Config: Sendable { /// If true, node names will show in long format public var useLongNodeName: Bool = false + /// + /// If true, the device will display message bubbles on screen. + public var enableMessageBubbles: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() /// @@ -2536,7 +2540,7 @@ extension Config.NetworkConfig.IpV4Config: SwiftProtobuf.Message, SwiftProtobuf. extension Config.DisplayConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = Config.protoMessageName + ".DisplayConfig" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}screen_on_secs\0\u{3}gps_format\0\u{3}auto_screen_carousel_secs\0\u{3}compass_north_top\0\u{3}flip_screen\0\u{1}units\0\u{1}oled\0\u{1}displaymode\0\u{3}heading_bold\0\u{3}wake_on_tap_or_motion\0\u{3}compass_orientation\0\u{3}use_12h_clock\0\u{3}use_long_node_name\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}screen_on_secs\0\u{3}gps_format\0\u{3}auto_screen_carousel_secs\0\u{3}compass_north_top\0\u{3}flip_screen\0\u{1}units\0\u{1}oled\0\u{1}displaymode\0\u{3}heading_bold\0\u{3}wake_on_tap_or_motion\0\u{3}compass_orientation\0\u{3}use_12h_clock\0\u{3}use_long_node_name\0\u{3}enable_message_bubbles\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -2557,6 +2561,7 @@ extension Config.DisplayConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImp case 11: try { try decoder.decodeSingularEnumField(value: &self.compassOrientation) }() case 12: try { try decoder.decodeSingularBoolField(value: &self.use12HClock) }() case 13: try { try decoder.decodeSingularBoolField(value: &self.useLongNodeName) }() + case 14: try { try decoder.decodeSingularBoolField(value: &self.enableMessageBubbles) }() default: break } } @@ -2602,6 +2607,9 @@ extension Config.DisplayConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImp if self.useLongNodeName != false { try visitor.visitSingularBoolField(value: self.useLongNodeName, fieldNumber: 13) } + if self.enableMessageBubbles != false { + try visitor.visitSingularBoolField(value: self.enableMessageBubbles, fieldNumber: 14) + } try unknownFields.traverse(visitor: &visitor) } @@ -2619,6 +2627,7 @@ extension Config.DisplayConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImp if lhs.compassOrientation != rhs.compassOrientation {return false} if lhs.use12HClock != rhs.use12HClock {return false} if lhs.useLongNodeName != rhs.useLongNodeName {return false} + if lhs.enableMessageBubbles != rhs.enableMessageBubbles {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/MeshtasticProtobufs/Sources/meshtastic/localonly.pb.swift b/MeshtasticProtobufs/Sources/meshtastic/localonly.pb.swift index 9ba9dd88..91874766 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/localonly.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/localonly.pb.swift @@ -277,6 +277,28 @@ public struct LocalModuleConfig: @unchecked Sendable { /// Clears the value of `paxcounter`. Subsequent reads from it will return its default value. public mutating func clearPaxcounter() {_uniqueStorage()._paxcounter = nil} + /// + /// StatusMessage Config + public var statusmessage: ModuleConfig.StatusMessageConfig { + get {return _storage._statusmessage ?? ModuleConfig.StatusMessageConfig()} + set {_uniqueStorage()._statusmessage = newValue} + } + /// Returns true if `statusmessage` has been explicitly set. + public var hasStatusmessage: Bool {return _storage._statusmessage != nil} + /// Clears the value of `statusmessage`. Subsequent reads from it will return its default value. + public mutating func clearStatusmessage() {_uniqueStorage()._statusmessage = nil} + + /// + /// The part of the config that is specific to the Traffic Management module + public var trafficManagement: ModuleConfig.TrafficManagementConfig { + get {return _storage._trafficManagement ?? ModuleConfig.TrafficManagementConfig()} + set {_uniqueStorage()._trafficManagement = newValue} + } + /// Returns true if `trafficManagement` has been explicitly set. + public var hasTrafficManagement: Bool {return _storage._trafficManagement != nil} + /// Clears the value of `trafficManagement`. Subsequent reads from it will return its default value. + public mutating func clearTrafficManagement() {_uniqueStorage()._trafficManagement = 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 @@ -425,7 +447,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") + 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") fileprivate class _StorageClass { var _mqtt: ModuleConfig.MQTTConfig? = nil @@ -441,6 +463,8 @@ extension LocalModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem var _ambientLighting: ModuleConfig.AmbientLightingConfig? = nil var _detectionSensor: ModuleConfig.DetectionSensorConfig? = nil var _paxcounter: ModuleConfig.PaxcounterConfig? = nil + var _statusmessage: ModuleConfig.StatusMessageConfig? = nil + var _trafficManagement: ModuleConfig.TrafficManagementConfig? = nil var _version: UInt32 = 0 // This property is used as the initial default value for new instances of the type. @@ -465,6 +489,8 @@ extension LocalModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem _ambientLighting = source._ambientLighting _detectionSensor = source._detectionSensor _paxcounter = source._paxcounter + _statusmessage = source._statusmessage + _trafficManagement = source._trafficManagement _version = source._version } } @@ -498,6 +524,8 @@ extension LocalModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem case 12: try { try decoder.decodeSingularMessageField(value: &_storage._ambientLighting) }() case 13: try { try decoder.decodeSingularMessageField(value: &_storage._detectionSensor) }() 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) }() default: break } } @@ -552,6 +580,12 @@ extension LocalModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem try { if let v = _storage._paxcounter { try visitor.visitSingularMessageField(value: v, fieldNumber: 14) } }() + try { if let v = _storage._statusmessage { + try visitor.visitSingularMessageField(value: v, fieldNumber: 15) + } }() + try { if let v = _storage._trafficManagement { + try visitor.visitSingularMessageField(value: v, fieldNumber: 16) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -574,6 +608,8 @@ extension LocalModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem if _storage._ambientLighting != rhs_storage._ambientLighting {return false} if _storage._detectionSensor != rhs_storage._detectionSensor {return false} 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._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 e8be5add..99e91556 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/mesh.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/mesh.pb.swift @@ -540,7 +540,7 @@ public enum HardwareModel: SwiftProtobuf.Enum, Swift.CaseIterable { case meshstick1262 // = 121 /// - /// LilyGo T-Beam 1W + /// LilyGo T-Beam 1W case tbeam1Watt // = 122 /// @@ -2306,6 +2306,20 @@ public struct Waypoint: Sendable { fileprivate var _longitudeI: Int32? = nil } +/// +/// Message for node status +public struct StatusMessage: 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. + + public var status: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + /// /// This message will be proxied over the PhoneAPI for the client to deliver to the MQTT server public struct MqttClientProxyMessage: Sendable { @@ -4715,6 +4729,36 @@ extension Waypoint: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationB } } +extension StatusMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".StatusMessage" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}status\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.decodeSingularStringField(value: &self.status) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.status.isEmpty { + try visitor.visitSingularStringField(value: self.status, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: StatusMessage, rhs: StatusMessage) -> Bool { + if lhs.status != rhs.status {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension MqttClientProxyMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".MqttClientProxyMessage" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}topic\0\u{1}data\0\u{1}text\0\u{1}retained\0") diff --git a/MeshtasticProtobufs/Sources/meshtastic/module_config.pb.swift b/MeshtasticProtobufs/Sources/meshtastic/module_config.pb.swift index 007440b4..4d99c2a1 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/module_config.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/module_config.pb.swift @@ -208,6 +208,26 @@ public struct ModuleConfig: Sendable { set {payloadVariant = .paxcounter(newValue)} } + /// + /// TODO: REPLACE + public var statusmessage: ModuleConfig.StatusMessageConfig { + get { + if case .statusmessage(let v)? = payloadVariant {return v} + return ModuleConfig.StatusMessageConfig() + } + set {payloadVariant = .statusmessage(newValue)} + } + + /// + /// Traffic management module config for mesh network optimization + public var trafficManagement: ModuleConfig.TrafficManagementConfig { + get { + if case .trafficManagement(let v)? = payloadVariant {return v} + return ModuleConfig.TrafficManagementConfig() + } + set {payloadVariant = .trafficManagement(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() /// @@ -252,6 +272,12 @@ public struct ModuleConfig: Sendable { /// /// TODO: REPLACE case paxcounter(ModuleConfig.PaxcounterConfig) + /// + /// TODO: REPLACE + case statusmessage(ModuleConfig.StatusMessageConfig) + /// + /// Traffic management module config for mesh network optimization + case trafficManagement(ModuleConfig.TrafficManagementConfig) } @@ -650,6 +676,75 @@ public struct ModuleConfig: Sendable { public init() {} } + /// + /// Config for the Traffic Management module. + /// Provides packet inspection and traffic shaping to help reduce channel utilization + public struct TrafficManagementConfig: 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. + + /// + /// Master enable for traffic management module + public var enabled: Bool = false + + /// + /// Enable position deduplication to drop redundant position broadcasts + public var positionDedupEnabled: Bool = false + + /// + /// Number of bits of precision for position deduplication (0-32) + public var positionPrecisionBits: UInt32 = 0 + + /// + /// Minimum interval in seconds between position updates from the same node + public var positionMinIntervalSecs: UInt32 = 0 + + /// + /// Enable direct response to NodeInfo requests from local cache + public var nodeinfoDirectResponse: Bool = false + + /// + /// Minimum hop distance from requestor before responding to NodeInfo requests + public var nodeinfoDirectResponseMaxHops: UInt32 = 0 + + /// + /// Enable per-node rate limiting to throttle chatty nodes + public var rateLimitEnabled: Bool = false + + /// + /// Time window in seconds for rate limiting calculations + public var rateLimitWindowSecs: UInt32 = 0 + + /// + /// Maximum packets allowed per node within the rate limit window + public var rateLimitMaxPackets: UInt32 = 0 + + /// + /// Enable dropping of unknown/undecryptable packets per rate_limit_window_secs + public var dropUnknownEnabled: Bool = false + + /// + /// Number of unknown packets before dropping from a node + public var unknownPacketThreshold: UInt32 = 0 + + /// + /// Set hop_limit to 0 for relayed telemetry broadcasts (own packets unaffected) + public var exhaustHopTelemetry: Bool = false + + /// + /// Set hop_limit to 0 for relayed position broadcasts (own packets unaffected) + public var exhaustHopPosition: Bool = false + + /// + /// Preserve hop_limit for router-to-router traffic + public var routerPreserveHops: Bool = false + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + } + /// /// Serial Config public struct SerialConfig: Sendable { @@ -1280,6 +1375,22 @@ public struct ModuleConfig: Sendable { public init() {} } + /// + /// StatusMessage config - Allows setting a status message for a node to periodically rebroadcast + public struct StatusMessageConfig: 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. + + /// + /// The actual status string + public var nodeStatus: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + } + public init() {} } @@ -1317,7 +1428,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") + 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 mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -1494,6 +1605,32 @@ extension ModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat self.payloadVariant = .paxcounter(v) } }() + case 14: try { + var v: ModuleConfig.StatusMessageConfig? + var hadOneofValue = false + if let current = self.payloadVariant { + hadOneofValue = true + if case .statusmessage(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.payloadVariant = .statusmessage(v) + } + }() + case 15: try { + var v: ModuleConfig.TrafficManagementConfig? + var hadOneofValue = false + if let current = self.payloadVariant { + hadOneofValue = true + if case .trafficManagement(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.payloadVariant = .trafficManagement(v) + } + }() default: break } } @@ -1557,6 +1694,14 @@ extension ModuleConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementat guard case .paxcounter(let v)? = self.payloadVariant else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 13) }() + case .statusmessage?: try { + guard case .statusmessage(let v)? = self.payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 14) + }() + case .trafficManagement?: try { + guard case .trafficManagement(let v)? = self.payloadVariant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 15) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -1951,6 +2096,101 @@ extension ModuleConfig.PaxcounterConfig: SwiftProtobuf.Message, SwiftProtobuf._M } } +extension ModuleConfig.TrafficManagementConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = ModuleConfig.protoMessageName + ".TrafficManagementConfig" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}enabled\0\u{3}position_dedup_enabled\0\u{3}position_precision_bits\0\u{3}position_min_interval_secs\0\u{3}nodeinfo_direct_response\0\u{3}nodeinfo_direct_response_max_hops\0\u{3}rate_limit_enabled\0\u{3}rate_limit_window_secs\0\u{3}rate_limit_max_packets\0\u{3}drop_unknown_enabled\0\u{3}unknown_packet_threshold\0\u{3}exhaust_hop_telemetry\0\u{3}exhaust_hop_position\0\u{3}router_preserve_hops\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.enabled) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.positionDedupEnabled) }() + case 3: try { try decoder.decodeSingularUInt32Field(value: &self.positionPrecisionBits) }() + case 4: try { try decoder.decodeSingularUInt32Field(value: &self.positionMinIntervalSecs) }() + case 5: try { try decoder.decodeSingularBoolField(value: &self.nodeinfoDirectResponse) }() + case 6: try { try decoder.decodeSingularUInt32Field(value: &self.nodeinfoDirectResponseMaxHops) }() + case 7: try { try decoder.decodeSingularBoolField(value: &self.rateLimitEnabled) }() + case 8: try { try decoder.decodeSingularUInt32Field(value: &self.rateLimitWindowSecs) }() + case 9: try { try decoder.decodeSingularUInt32Field(value: &self.rateLimitMaxPackets) }() + case 10: try { try decoder.decodeSingularBoolField(value: &self.dropUnknownEnabled) }() + case 11: try { try decoder.decodeSingularUInt32Field(value: &self.unknownPacketThreshold) }() + case 12: try { try decoder.decodeSingularBoolField(value: &self.exhaustHopTelemetry) }() + case 13: try { try decoder.decodeSingularBoolField(value: &self.exhaustHopPosition) }() + case 14: try { try decoder.decodeSingularBoolField(value: &self.routerPreserveHops) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.enabled != false { + try visitor.visitSingularBoolField(value: self.enabled, fieldNumber: 1) + } + if self.positionDedupEnabled != false { + try visitor.visitSingularBoolField(value: self.positionDedupEnabled, fieldNumber: 2) + } + if self.positionPrecisionBits != 0 { + try visitor.visitSingularUInt32Field(value: self.positionPrecisionBits, fieldNumber: 3) + } + if self.positionMinIntervalSecs != 0 { + try visitor.visitSingularUInt32Field(value: self.positionMinIntervalSecs, fieldNumber: 4) + } + if self.nodeinfoDirectResponse != false { + try visitor.visitSingularBoolField(value: self.nodeinfoDirectResponse, fieldNumber: 5) + } + if self.nodeinfoDirectResponseMaxHops != 0 { + try visitor.visitSingularUInt32Field(value: self.nodeinfoDirectResponseMaxHops, fieldNumber: 6) + } + if self.rateLimitEnabled != false { + try visitor.visitSingularBoolField(value: self.rateLimitEnabled, fieldNumber: 7) + } + if self.rateLimitWindowSecs != 0 { + try visitor.visitSingularUInt32Field(value: self.rateLimitWindowSecs, fieldNumber: 8) + } + if self.rateLimitMaxPackets != 0 { + try visitor.visitSingularUInt32Field(value: self.rateLimitMaxPackets, fieldNumber: 9) + } + if self.dropUnknownEnabled != false { + try visitor.visitSingularBoolField(value: self.dropUnknownEnabled, fieldNumber: 10) + } + if self.unknownPacketThreshold != 0 { + try visitor.visitSingularUInt32Field(value: self.unknownPacketThreshold, fieldNumber: 11) + } + if self.exhaustHopTelemetry != false { + try visitor.visitSingularBoolField(value: self.exhaustHopTelemetry, fieldNumber: 12) + } + if self.exhaustHopPosition != false { + try visitor.visitSingularBoolField(value: self.exhaustHopPosition, fieldNumber: 13) + } + if self.routerPreserveHops != false { + try visitor.visitSingularBoolField(value: self.routerPreserveHops, fieldNumber: 14) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: ModuleConfig.TrafficManagementConfig, rhs: ModuleConfig.TrafficManagementConfig) -> Bool { + if lhs.enabled != rhs.enabled {return false} + if lhs.positionDedupEnabled != rhs.positionDedupEnabled {return false} + if lhs.positionPrecisionBits != rhs.positionPrecisionBits {return false} + if lhs.positionMinIntervalSecs != rhs.positionMinIntervalSecs {return false} + if lhs.nodeinfoDirectResponse != rhs.nodeinfoDirectResponse {return false} + if lhs.nodeinfoDirectResponseMaxHops != rhs.nodeinfoDirectResponseMaxHops {return false} + if lhs.rateLimitEnabled != rhs.rateLimitEnabled {return false} + if lhs.rateLimitWindowSecs != rhs.rateLimitWindowSecs {return false} + if lhs.rateLimitMaxPackets != rhs.rateLimitMaxPackets {return false} + if lhs.dropUnknownEnabled != rhs.dropUnknownEnabled {return false} + if lhs.unknownPacketThreshold != rhs.unknownPacketThreshold {return false} + if lhs.exhaustHopTelemetry != rhs.exhaustHopTelemetry {return false} + if lhs.exhaustHopPosition != rhs.exhaustHopPosition {return false} + if lhs.routerPreserveHops != rhs.routerPreserveHops {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension ModuleConfig.SerialConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = ModuleConfig.protoMessageName + ".SerialConfig" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}enabled\0\u{1}echo\0\u{1}rxd\0\u{1}txd\0\u{1}baud\0\u{1}timeout\0\u{1}mode\0\u{3}override_console_serial_port\0") @@ -2458,6 +2698,36 @@ extension ModuleConfig.AmbientLightingConfig: SwiftProtobuf.Message, SwiftProtob } } +extension ModuleConfig.StatusMessageConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = ModuleConfig.protoMessageName + ".StatusMessageConfig" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}node_status\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.decodeSingularStringField(value: &self.nodeStatus) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.nodeStatus.isEmpty { + try visitor.visitSingularStringField(value: self.nodeStatus, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: ModuleConfig.StatusMessageConfig, rhs: ModuleConfig.StatusMessageConfig) -> Bool { + if lhs.nodeStatus != rhs.nodeStatus {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 1d264b5b..7022a761 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/portnums.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/portnums.pb.swift @@ -138,6 +138,13 @@ public enum PortNum: SwiftProtobuf.Enum, Swift.CaseIterable { /// chain of messages. case storeForwardPlusplusApp // = 35 + /// + /// Node Status module + /// ENCODING: protobuf + /// This module allows setting an extra string of status for a node. + /// Broadcasts on change and on a timer, possibly once a day. + case nodeStatusApp // = 36 + /// /// Provides a hardware serial interface to send and receive from the Meshtastic network. /// Connect to the RX/TX pins of a device with 38400 8N1. Packets received from the Meshtastic @@ -254,6 +261,7 @@ public enum PortNum: SwiftProtobuf.Enum, Swift.CaseIterable { case 33: self = .ipTunnelApp case 34: self = .paxcounterApp case 35: self = .storeForwardPlusplusApp + case 36: self = .nodeStatusApp case 64: self = .serialApp case 65: self = .storeForwardApp case 66: self = .rangeTestApp @@ -293,6 +301,7 @@ public enum PortNum: SwiftProtobuf.Enum, Swift.CaseIterable { case .ipTunnelApp: return 33 case .paxcounterApp: return 34 case .storeForwardPlusplusApp: return 35 + case .nodeStatusApp: return 36 case .serialApp: return 64 case .storeForwardApp: return 65 case .rangeTestApp: return 66 @@ -332,6 +341,7 @@ public enum PortNum: SwiftProtobuf.Enum, Swift.CaseIterable { .ipTunnelApp, .paxcounterApp, .storeForwardPlusplusApp, + .nodeStatusApp, .serialApp, .storeForwardApp, .rangeTestApp, @@ -355,5 +365,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{2}\u{1d}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{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") } diff --git a/MeshtasticProtobufs/Sources/meshtastic/telemetry.pb.swift b/MeshtasticProtobufs/Sources/meshtastic/telemetry.pb.swift index 5ad5fad3..0c66c6bc 100644 --- a/MeshtasticProtobufs/Sources/meshtastic/telemetry.pb.swift +++ b/MeshtasticProtobufs/Sources/meshtastic/telemetry.pb.swift @@ -208,6 +208,18 @@ public enum TelemetrySensorType: SwiftProtobuf.Enum, Swift.CaseIterable { /// /// BH1750 light sensor case bh1750 // = 45 + + /// + /// HDC1080 Temperature and Humidity Sensor + case hdc1080 // = 46 + + /// + /// STH21 Temperature and R. Humidity sensor + case sht21 // = 47 + + /// + /// Sensirion STC31 CO2 sensor + case stc31 // = 48 case UNRECOGNIZED(Int) public init() { @@ -262,6 +274,9 @@ public enum TelemetrySensorType: SwiftProtobuf.Enum, Swift.CaseIterable { case 43: self = .sen5X case 44: self = .tsl2561 case 45: self = .bh1750 + case 46: self = .hdc1080 + case 47: self = .sht21 + case 48: self = .stc31 default: self = .UNRECOGNIZED(rawValue) } } @@ -314,6 +329,9 @@ public enum TelemetrySensorType: SwiftProtobuf.Enum, Swift.CaseIterable { case .sen5X: return 43 case .tsl2561: return 44 case .bh1750: return 45 + case .hdc1080: return 46 + case .sht21: return 47 + case .stc31: return 48 case .UNRECOGNIZED(let i): return i } } @@ -366,6 +384,9 @@ public enum TelemetrySensorType: SwiftProtobuf.Enum, Swift.CaseIterable { .sen5X, .tsl2561, .bh1750, + .hdc1080, + .sht21, + .stc31, ] } @@ -1260,6 +1281,50 @@ public struct LocalStats: Sendable { /// Number of packets that were dropped because the transmit queue was full. public var numTxDropped: UInt32 = 0 + /// + /// Noise floor value measured in dBm + public var noiseFloor: Int32 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +/// +/// Traffic management statistics for mesh network optimization +public struct TrafficManagementStats: 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. + + /// + /// Total number of packets inspected by traffic management + public var packetsInspected: UInt32 = 0 + + /// + /// Number of position packets dropped due to deduplication + public var positionDedupDrops: UInt32 = 0 + + /// + /// Number of NodeInfo requests answered from cache + public var nodeinfoCacheHits: UInt32 = 0 + + /// + /// Number of packets dropped due to rate limiting + public var rateLimitDrops: UInt32 = 0 + + /// + /// Number of unknown/undecryptable packets dropped + public var unknownPacketDrops: UInt32 = 0 + + /// + /// Number of packets with hop_limit exhausted for local-only broadcast + public var hopExhaustedPackets: UInt32 = 0 + + /// + /// Number of times router hop preservation was applied + public var routerHopsPreserved: UInt32 = 0 + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -1477,6 +1542,16 @@ public struct Telemetry: @unchecked Sendable { set {_uniqueStorage()._variant = .hostMetrics(newValue)} } + /// + /// Traffic management statistics + public var trafficManagementStats: TrafficManagementStats { + get { + if case .trafficManagementStats(let v)? = _storage._variant {return v} + return TrafficManagementStats() + } + set {_uniqueStorage()._variant = .trafficManagementStats(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() public enum OneOf_Variant: Equatable, Sendable { @@ -1501,6 +1576,9 @@ public struct Telemetry: @unchecked Sendable { /// /// Linux host metrics case hostMetrics(HostMetrics) + /// + /// Traffic management statistics + case trafficManagementStats(TrafficManagementStats) } @@ -1529,12 +1607,73 @@ public struct Nau7802Config: Sendable { public init() {} } +/// +/// SEN5X State, for saving to flash +public struct SEN5XState: 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. + + /// + /// Last cleaning time for SEN5X + public var lastCleaningTime: UInt32 = 0 + + /// + /// Last cleaning time for SEN5X - valid flag + public var lastCleaningValid: Bool = false + + /// + /// Config flag for one-shot mode (see admin.proto) + public var oneShotMode: Bool = false + + /// + /// Last VOC state time for SEN55 + public var vocStateTime: UInt32 { + get {return _vocStateTime ?? 0} + set {_vocStateTime = newValue} + } + /// Returns true if `vocStateTime` has been explicitly set. + public var hasVocStateTime: Bool {return self._vocStateTime != nil} + /// Clears the value of `vocStateTime`. Subsequent reads from it will return its default value. + public mutating func clearVocStateTime() {self._vocStateTime = nil} + + /// + /// Last VOC state validity flag for SEN55 + public var vocStateValid: Bool { + get {return _vocStateValid ?? false} + set {_vocStateValid = newValue} + } + /// Returns true if `vocStateValid` has been explicitly set. + public var hasVocStateValid: Bool {return self._vocStateValid != nil} + /// Clears the value of `vocStateValid`. Subsequent reads from it will return its default value. + public mutating func clearVocStateValid() {self._vocStateValid = nil} + + /// + /// VOC state array (8x uint8t) for SEN55 + public var vocStateArray: UInt64 { + get {return _vocStateArray ?? 0} + set {_vocStateArray = newValue} + } + /// Returns true if `vocStateArray` has been explicitly set. + public var hasVocStateArray: Bool {return self._vocStateArray != nil} + /// Clears the value of `vocStateArray`. Subsequent reads from it will return its default value. + public mutating func clearVocStateArray() {self._vocStateArray = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _vocStateTime: UInt32? = nil + fileprivate var _vocStateValid: Bool? = nil + fileprivate var _vocStateArray: UInt64? = nil +} + // MARK: - Code below here is support for the SwiftProtobuf runtime. 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") + 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") } extension DeviceMetrics: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { @@ -2157,7 +2296,7 @@ extension AirQualityMetrics: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem extension LocalStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".LocalStats" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}uptime_seconds\0\u{3}channel_utilization\0\u{3}air_util_tx\0\u{3}num_packets_tx\0\u{3}num_packets_rx\0\u{3}num_packets_rx_bad\0\u{3}num_online_nodes\0\u{3}num_total_nodes\0\u{3}num_rx_dupe\0\u{3}num_tx_relay\0\u{3}num_tx_relay_canceled\0\u{3}heap_total_bytes\0\u{3}heap_free_bytes\0\u{3}num_tx_dropped\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}uptime_seconds\0\u{3}channel_utilization\0\u{3}air_util_tx\0\u{3}num_packets_tx\0\u{3}num_packets_rx\0\u{3}num_packets_rx_bad\0\u{3}num_online_nodes\0\u{3}num_total_nodes\0\u{3}num_rx_dupe\0\u{3}num_tx_relay\0\u{3}num_tx_relay_canceled\0\u{3}heap_total_bytes\0\u{3}heap_free_bytes\0\u{3}num_tx_dropped\0\u{3}noise_floor\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -2179,6 +2318,7 @@ extension LocalStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementatio case 12: try { try decoder.decodeSingularUInt32Field(value: &self.heapTotalBytes) }() case 13: try { try decoder.decodeSingularUInt32Field(value: &self.heapFreeBytes) }() case 14: try { try decoder.decodeSingularUInt32Field(value: &self.numTxDropped) }() + case 15: try { try decoder.decodeSingularInt32Field(value: &self.noiseFloor) }() default: break } } @@ -2227,6 +2367,9 @@ extension LocalStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementatio if self.numTxDropped != 0 { try visitor.visitSingularUInt32Field(value: self.numTxDropped, fieldNumber: 14) } + if self.noiseFloor != 0 { + try visitor.visitSingularInt32Field(value: self.noiseFloor, fieldNumber: 15) + } try unknownFields.traverse(visitor: &visitor) } @@ -2245,6 +2388,67 @@ extension LocalStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementatio if lhs.heapTotalBytes != rhs.heapTotalBytes {return false} if lhs.heapFreeBytes != rhs.heapFreeBytes {return false} if lhs.numTxDropped != rhs.numTxDropped {return false} + if lhs.noiseFloor != rhs.noiseFloor {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension TrafficManagementStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".TrafficManagementStats" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}packets_inspected\0\u{3}position_dedup_drops\0\u{3}nodeinfo_cache_hits\0\u{3}rate_limit_drops\0\u{3}unknown_packet_drops\0\u{3}hop_exhausted_packets\0\u{3}router_hops_preserved\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.packetsInspected) }() + case 2: try { try decoder.decodeSingularUInt32Field(value: &self.positionDedupDrops) }() + case 3: try { try decoder.decodeSingularUInt32Field(value: &self.nodeinfoCacheHits) }() + case 4: try { try decoder.decodeSingularUInt32Field(value: &self.rateLimitDrops) }() + case 5: try { try decoder.decodeSingularUInt32Field(value: &self.unknownPacketDrops) }() + case 6: try { try decoder.decodeSingularUInt32Field(value: &self.hopExhaustedPackets) }() + case 7: try { try decoder.decodeSingularUInt32Field(value: &self.routerHopsPreserved) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.packetsInspected != 0 { + try visitor.visitSingularUInt32Field(value: self.packetsInspected, fieldNumber: 1) + } + if self.positionDedupDrops != 0 { + try visitor.visitSingularUInt32Field(value: self.positionDedupDrops, fieldNumber: 2) + } + if self.nodeinfoCacheHits != 0 { + try visitor.visitSingularUInt32Field(value: self.nodeinfoCacheHits, fieldNumber: 3) + } + if self.rateLimitDrops != 0 { + try visitor.visitSingularUInt32Field(value: self.rateLimitDrops, fieldNumber: 4) + } + if self.unknownPacketDrops != 0 { + try visitor.visitSingularUInt32Field(value: self.unknownPacketDrops, fieldNumber: 5) + } + if self.hopExhaustedPackets != 0 { + try visitor.visitSingularUInt32Field(value: self.hopExhaustedPackets, fieldNumber: 6) + } + if self.routerHopsPreserved != 0 { + try visitor.visitSingularUInt32Field(value: self.routerHopsPreserved, fieldNumber: 7) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: TrafficManagementStats, rhs: TrafficManagementStats) -> Bool { + if lhs.packetsInspected != rhs.packetsInspected {return false} + if lhs.positionDedupDrops != rhs.positionDedupDrops {return false} + if lhs.nodeinfoCacheHits != rhs.nodeinfoCacheHits {return false} + if lhs.rateLimitDrops != rhs.rateLimitDrops {return false} + if lhs.unknownPacketDrops != rhs.unknownPacketDrops {return false} + if lhs.hopExhaustedPackets != rhs.hopExhaustedPackets {return false} + if lhs.routerHopsPreserved != rhs.routerHopsPreserved {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -2370,7 +2574,7 @@ extension HostMetrics: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementati extension Telemetry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Telemetry" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}time\0\u{3}device_metrics\0\u{3}environment_metrics\0\u{3}air_quality_metrics\0\u{3}power_metrics\0\u{3}local_stats\0\u{3}health_metrics\0\u{3}host_metrics\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}time\0\u{3}device_metrics\0\u{3}environment_metrics\0\u{3}air_quality_metrics\0\u{3}power_metrics\0\u{3}local_stats\0\u{3}health_metrics\0\u{3}host_metrics\0\u{3}traffic_management_stats\0") fileprivate class _StorageClass { var _time: UInt32 = 0 @@ -2497,6 +2701,19 @@ extension Telemetry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation _storage._variant = .hostMetrics(v) } }() + case 9: try { + var v: TrafficManagementStats? + var hadOneofValue = false + if let current = _storage._variant { + hadOneofValue = true + if case .trafficManagementStats(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._variant = .trafficManagementStats(v) + } + }() default: break } } @@ -2541,6 +2758,10 @@ extension Telemetry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation guard case .hostMetrics(let v)? = _storage._variant else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 8) }() + case .trafficManagementStats?: try { + guard case .trafficManagementStats(let v)? = _storage._variant else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 9) + }() case nil: break } } @@ -2597,3 +2818,62 @@ extension Nau7802Config: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementa return true } } + +extension SEN5XState: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".SEN5XState" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}last_cleaning_time\0\u{3}last_cleaning_valid\0\u{3}one_shot_mode\0\u{3}voc_state_time\0\u{3}voc_state_valid\0\u{3}voc_state_array\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.lastCleaningTime) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.lastCleaningValid) }() + case 3: try { try decoder.decodeSingularBoolField(value: &self.oneShotMode) }() + case 4: try { try decoder.decodeSingularUInt32Field(value: &self._vocStateTime) }() + case 5: try { try decoder.decodeSingularBoolField(value: &self._vocStateValid) }() + case 6: try { try decoder.decodeSingularFixed64Field(value: &self._vocStateArray) }() + 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 + if self.lastCleaningTime != 0 { + try visitor.visitSingularUInt32Field(value: self.lastCleaningTime, fieldNumber: 1) + } + if self.lastCleaningValid != false { + try visitor.visitSingularBoolField(value: self.lastCleaningValid, fieldNumber: 2) + } + if self.oneShotMode != false { + try visitor.visitSingularBoolField(value: self.oneShotMode, fieldNumber: 3) + } + try { if let v = self._vocStateTime { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 4) + } }() + try { if let v = self._vocStateValid { + try visitor.visitSingularBoolField(value: v, fieldNumber: 5) + } }() + try { if let v = self._vocStateArray { + try visitor.visitSingularFixed64Field(value: v, fieldNumber: 6) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: SEN5XState, rhs: SEN5XState) -> Bool { + if lhs.lastCleaningTime != rhs.lastCleaningTime {return false} + if lhs.lastCleaningValid != rhs.lastCleaningValid {return false} + if lhs.oneShotMode != rhs.oneShotMode {return false} + if lhs._vocStateTime != rhs._vocStateTime {return false} + if lhs._vocStateValid != rhs._vocStateValid {return false} + if lhs._vocStateArray != rhs._vocStateArray {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/itak-example-data-package/iphone.p12 b/itak-example-data-package/iphone.p12 new file mode 100644 index 0000000000000000000000000000000000000000..7f836b2f732d3e19103bc6dd4358d55d5b671d7b GIT binary patch literal 3009 zcmZXVcQ6|cAI6P{5Jc2gidv;eL#WvpQF~LP)UFt{RnZ@U7*Qkks4AssiPh4mz18|D zF>2N-YE#sD``*3l?)~F?&)w&_d;a`>U>I-{1vw=Q1C9aGh+;Ld$ModXa0Q;?JW3HtvwLbzvF9X&XHHP8QROWr$AeUeUaIf}S-agDMV$1e3RjW{;#k7e zn@u)6tY5m?KmE5i&tR~^{u*z9w1pYkaH}LgN=T(t@ZF774xWaZXX2Slq!9?C9P0RQo^4kOI8fgRZxU?alrf73nt?tEYY@K8XG0r z2HNFW6$E~^JTHv3#d5G~P4$X?EK_^lBlKdw{A(Ix+~aNf@tiD^zNHA(Ews+aGbp14 zdM|y!W5Hsfbp^s{B{6AJ`a-Jd_RtNIKKo1?A4Hg9kuy+I@u^g{!)glC_-EP5FnB7m zVx8uJ@u{0_vj2t-6@4?VPyA&R&J_{`y*d{`TkkcXjHZQ@u6I#a8 z+WsCpk7~Em&w|U=W@W#v`4oZfp7vo!N0|ATrP%X$;G4N=)Pp%Zn4q`Co_;`?!8`?P zB`l-5)Nvt~Cf8$D%B=2@mN%0MS*k6Kxe9&x)PEwEqTip-bR(@AZ_L}ofWFczUR^{6}cSC|>* zVA5-!BB4mIej!bf_`$u{O#WNI;*Si$C*J*=Jt$$EeL$$BTmCSC@8goC$9zb|Jge;)rIAfCOu3C!~M&9b9@d46`P1lvK#9^e8l~r8DsEPT%rhR=$FU+~eG) zh(_?u z^B;0Da$#G%&CLx}8BQ7!?~XFXKb*c1y!YxZ=Yz}`8kb-inFC&>vam1#Qc4i~yija5 z8|d|n*bDQXjaTsy+`aYi292F|6@PyA)dtXBinMs`0`E`U_@pM&K-J(#P8`?_Ry|sV zW==AK4o(}F3kz|$TR+e)q?&fc9XDPAr9?BT1j?`QL|-G4tL#33`@wMEd(RSh$@7dL zjgo!2%ahpr=M$bc{;~pYiChV^e_h=AYTEzSAWg^_!y{#c-gt_u@ zUrXJglVgg2P8Dbx;D@#>~MTB;98W5=EdHx zz$$({rWNx>Ee7hxeV6^HW`Y6drp!o%HxDCle=Q2AHrzQAWn=ex^J6lUM2@Te zsT9|zHODlmb=Ns#s?+73jC}XF6!-BX3g{00wzKr#f?keJA+79En%Md=fh3npt-?N4djbJ6J;5e zdx@L<=_7v53mepYZKu(op4#}rTGfCz=SxSj=UZG&bDC1D{l;I?6E5#Wk3~A!Suj0v z`zczix49sXag%%BIOBj#r%y;hSxXaGk3-$7rFI{^CkxgV;x%1wDzb4s?T2BgRQ^W@ z1sEzg07E752aEnWAt3nQdY~nzD8K-=VHm*1|69YX)EwD^5EuKWH2@eu@7m-e>dMaA zv$}FOa{giRb-qgzoZ>c5!F)|z`&1~3g}MsY7auQBo-nmunirQ$q(Wy#GcHysy6Hd6 zgcbe5n`U@{q)MfZieoF(;M~2~`^Af+fgiF0oo*a|UUBl$u^AMJQ?V}-PSR6PMFKBeYnn3FGm2^uOLy$#e&y_%3LTJHBWNwAqn+BuF zs0^V|iI)Br&J2alXp48y@=oWSmi^X?T%w7EJ70Wvl{Ae25o=mkUTCc-;ZI|Cab)&Y zDydi9Kk()!O<`)xqH8^o26d6w2fIRkHLE8l^fRZRjYVO>G@UI8v#(~WWRaZ&(l0AuKvxh` zfC#Pe#8FyF=E~o~W3G%7_-%EYrc=2qo{6?b6LJ%VT$a>RsA>Z8KL*#hK<@$VG+5Tpz@LLbRW;;|*3)~6>Lvka-t7<+^kCH(xoFmA= zwz{tnuP!yYyvM%JalUp}Pjt1sm<*_FHZrtl8R@P|u8Edji>=tmqc8itko0ADrBF#% zzZqI@UBGqL^=@v=-#f@d7a{**vUI@ce{`Ul`MhkZ-;zR7{`3m zj6^2GNz44AmoL%nQ8;xvb+$@z+-&e)=9E_Lfj8rALa!7I-Ym7^Tfd8j+j;H!Hy6&X zEBEv`<$N?;sI@L2v4Zb&4$IjwX9&)BP^gkEQdJ~JKai8MnzYW)e7!cub5l)`&}2oWWVj0rvdpf%}OO zy9OPe*fhj^y%TjOSXX>vyLrsx6m3h%TgoKnes@3<8)|REF|%M`lJtJlfwrjKd35nz zGtt@1V?NRgQbdWk3Iu|E6x!TvU`g<7E9($t)Umhu!*1iqEv>e{=-d;!aXzIiadPuG}yAsbeq*nF+Qi*+u zbgiDNS}Us9aI;3PHwy4fzs|ZN2)p^8??}UPhZVr=lRxoNt@N|62caY@RtEc(-a|1L zCJy6+(EzE$7$_+?smK7#4*KAB(W0bPn%(0kUPlY)FJ#HF0Fb7*9E09UkncYL(|DeM literal 0 HcmV?d00001 diff --git a/itak-example-data-package/manifest.xml b/itak-example-data-package/manifest.xml new file mode 100644 index 00000000..2d2a8140 --- /dev/null +++ b/itak-example-data-package/manifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/itak-example-data-package/server.p12 b/itak-example-data-package/server.p12 new file mode 100644 index 0000000000000000000000000000000000000000..913f4692aa346b4d3ac7e964c0764f8e0bd430b8 GIT binary patch literal 3009 zcmZXVc`y_XAI9xk>y~3lY!>Uty%rI2C-?5slAJAaA7zu9mRu#It=}(lU+Y+k+|oKW zmWVdWl^nUrowK)j=e=g$Kfd$Ke4d%-&+i98U~dBg7!d^aL@=vtf=R+YCx97{M_>;H z5!ms6a1eq3mi})A&Le;Y{~!nu!0;zH|Jwi%P%zuS7C8Paa)DSVOc86HK+!lLkcAX>)GK|oiirh{fY$uTOTzL;tE(ykktHjwA zMU=9&-{{$a7X0FNhu9_E2r%TGjdyuelh^LdlF6hQ*?zq(Hu$Bq=7ZDGG##h7C{z2x zK00wB&;K{VNM!d`H9=syO$!C-jEhz)jwa^c&E}Y=wIYgM zyOo(hY44Zt6w!M4Nt=|b;@D9Duk6rUp zg~y4d6?ns*9?faoZGe<&V$)?s9+)`nXW(M0#cE^Yq3|1rbt4Zgc8EcPxxqI##SmzP zU1sH+&P;W5GhWX%eco)@V7-aK-Jzhc00;NjuMX0h|f!%TKeegY3%MxoU(;8+Z*NA-g1B~;j2~c5MnfW>2 z_2_ZxH8u2-aes-9dK0qOo@4qkN2ICH_aK92_pr^6&x7mmfzbDEl*h8AdUZEt{d|l& z6WUUH&Dp;q4Ca}?ED_Tpo&h_H3$Vp#GQQxRuj0gT_s~4q6!tL4GOFFoLW-QO0GpuQ zn#cr=glJN}uS}fPHGVa3w6Os0Q&S4yTPG#%{}5G;>Pu>Z_n-3@85Zgq=C{>alfBmE zE39D|JWfQ?!RsB|+J*UeYY2BIW18L-)hfa0Tk3Zb>(q-{se*w~5U!k(ZX-#_cd@a` zD24+w$LFGkQf!O>p!JvD09nj_aY=m!ndjw(wi_u~N345iA)+T+WZTmA9WqHu0<)6S z{$Q{!rXr zAOOzK!M@pn#%--7Pe`-Lq7to>;pW4K*WN3-zo)dGK80^`TUa&0qG)GFp=1PsN&kO@ zkVjxbf(T5Cf3WPIlLE8*Uqjv#=R|KA$22P;0nu`mC$28aOa@lty%mLHtM zaS~^p$(f(65N4>67o#L#c!lo9uaKOj5^VB_9Z!KKpsq4vPhYHw5;ETCCmdgJ(byr` zV_Bit_^(>GMabS|*#-q$!($2UR!4>!E2jtIMs9Bc*(INf6WD5r{Cl5^rk-{!@A66Z zquUnXKjbk;7HQ8E^oc_cFGjWTfS3{SF?KSRuPJqqHCV@PpqDv`3P?#raNJz!)Dp^7 zfcYF@Dl0jHPwP}OFEWqm2G&Q^PMG=!g?8S4)fYr%5qpBfEn*zf1R1@}9M z(aWp8@DN@;Fk`Ht?h23~h%eIbdO4m#ZgU)e2otGqipoIcGq2@)5=%GPWH`a(Js@Mg z!L^oy(2kn5OC3(J69StSLDmj%W;{0Ffwwv~ofhDpp zDo&9E+>+;zP*MfNQtbBEF;@>f;7O4Gh&Roql>Dd9~D%XI&xG;%Rw%zOd`0%ZuQ{r3wmMaeVfP z++xSKjSc?Rm*cowBG{wdPp;!FnVQ`XDkPynTw~zYLT;C5xmkpGDdD|v0*RwZ`7ZEy zA$rT{QO4VQT{Aw?DQ2!y0=R-iS&vl2Oo>2Duqm|PU;lLGvSw83IL|YP^z~PE$P2TC zI%`%1rrj^+w$&E38Iy*aCs&~m4I!>{jKqk+pj$z_ch~OAZU9Ek!mdOA{HHK8Ou-~q z^Ugdf*|g-_e&7?MUu}G^VqaIs>_=^k+A6!YiSLvYQ(L&twxRi5ibw0&1kMaWR=SE+ z4h8X`x3$sp{f^t}l;u#xb8KYk{07t6g6*;`krvvMK11qkN+ZFjL)4mfM5@#BP+kM00<(?ckT3kfZh92;KQVBh*lVzqxS+r y0U?H91vANUF#?5{7(kG_@~bd`V}N#- + + + 1 + Win10 Taky Server + true + 172.30.254.210:8089:ssl + + + true + server.p12 + YOURPASSWORD + YOURPASSWORD + iphone.p12 + + From 8bb078d131084f15a04febac174cdecef01b739f Mon Sep 17 00:00:00 2001 From: Jake-B Date: Tue, 17 Feb 2026 16:56:55 -0500 Subject: [PATCH 02/20] Delete Messages fix --- Meshtastic/Persistence/UpdateCoreData.swift | 8 +++++++- Meshtastic/Views/Messages/ChannelList.swift | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index ed5b2af2..32d9bc99 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -156,10 +156,16 @@ extension MeshPackets { nonisolated public func deleteChannelMessages(channel: ChannelEntity, context: NSManagedObjectContext) { do { - let objects = channel.allPrivateMessages + // Copied logic from ChannelEntity.allPrivateMessages, which is always on the MainActor + // But this code may not be on the MainActor. + let fetchRequest = MessageEntity.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "channel == %ld AND toUser == nil AND isEmoji == false", channel.index) + let objects = (try? context.fetch(fetchRequest)) ?? [MessageEntity]() + for object in objects { context.delete(object) } + try context.save() } catch let error as NSError { Logger.data.error("\(error.localizedDescription, privacy: .public)") diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index 477a03ed..bf3c2752 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -162,8 +162,13 @@ struct ChannelList: View { Button(role: .destructive) { Task { await MeshPackets.shared.deleteChannelMessages(channel: channelToDeleteMessages!) - context.refresh(myInfo, mergeChanges: true) - channelToDeleteMessages = nil + await MainActor.run { + context.refresh(channel, mergeChanges: true) + context.refresh(myInfo, mergeChanges: true) + + // Reset state + channelToDeleteMessages = nil + } } } label: { Text("Delete") From 72e5212941f1ee24dba4f1b8f8da3fc2cedf2fcd Mon Sep 17 00:00:00 2001 From: Jake-B Date: Wed, 18 Feb 2026 09:41:02 -0500 Subject: [PATCH 03/20] Bump version to 2.7.9 --- Meshtastic.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 1544d0eb..a31fa183 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -2170,7 +2170,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.8; + MARKETING_VERSION = 2.7.9; OTHER_LDFLAGS = ( "-weak_framework", SwiftUI, @@ -2209,7 +2209,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.8; + MARKETING_VERSION = 2.7.9; OTHER_LDFLAGS = ( "-weak_framework", SwiftUI, From 24fe868a95f3950d4f944029fc625c8066efef27 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Wed, 18 Feb 2026 09:30:03 -0800 Subject: [PATCH 04/20] Bump widgets version --- Localizable.xcstrings | 3 +-- Meshtastic.xcodeproj/project.pbxproj | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 03458818..25bac479 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -41604,7 +41604,6 @@ } }, "TAK" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -49740,4 +49739,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index a31fa183..4c264fbb 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -2245,7 +2245,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.8; + MARKETING_VERSION = 2.7.9; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2278,7 +2278,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.8; + MARKETING_VERSION = 2.7.9; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 1dcecc451915abbea87d049ba691bf65b281de60 Mon Sep 17 00:00:00 2001 From: niccellular <79813408+niccellular@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:42:29 -0500 Subject: [PATCH 05/20] 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. --- .../Helpers/TAK/TAKMeshtasticBridge.swift | 6 ++- Meshtastic/Helpers/TAK/TAKServerManager.swift | 2 + .../Views/Settings/TAKServerConfig.swift | 38 ++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift index 9ed42c90..23a08afe 100644 --- a/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift +++ b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift @@ -147,6 +147,8 @@ final class TAKMeshtasticBridge { return } + let channel = UInt32(TAKServerManager.shared.channel) + // Determine send method based on CoT type let sendMethod = GenericCoTHandler.shared.classifySendMethod(for: cotMessage) @@ -159,7 +161,7 @@ final class TAKMeshtasticBridge { } do { - try await accessoryManager.sendTAKPacket(takPacket) + try await accessoryManager.sendTAKPacket(takPacket, channel: channel) Logger.tak.info("Sent TAKPacket to mesh: \(cotMessage.type)") } catch { Logger.tak.error("Failed to send TAKPacket to mesh: \(error.localizedDescription)") @@ -169,7 +171,7 @@ final class TAKMeshtasticBridge { // Use EXI compression on ATAK_FORWARDER port (257) GenericCoTHandler.shared.accessoryManager = accessoryManager do { - try await GenericCoTHandler.shared.sendGenericCoT(cotMessage) + try await GenericCoTHandler.shared.sendGenericCoT(cotMessage, channel: channel) Logger.tak.info("Sent generic CoT to mesh via ATAK_FORWARDER: \(cotMessage.type)") } catch { Logger.tak.error("Failed to send generic CoT to mesh: \(error.localizedDescription)") diff --git a/Meshtastic/Helpers/TAK/TAKServerManager.swift b/Meshtastic/Helpers/TAK/TAKServerManager.swift index e3e3caa7..182e47bb 100644 --- a/Meshtastic/Helpers/TAK/TAKServerManager.swift +++ b/Meshtastic/Helpers/TAK/TAKServerManager.swift @@ -26,6 +26,8 @@ final class TAKServerManager: ObservableObject { // MARK: - Configuration (persisted via AppStorage) + @AppStorage("takServerChannel") var channel: Int = 0 + @AppStorage("takServerEnabled") var enabled = false { didSet { Task { diff --git a/Meshtastic/Views/Settings/TAKServerConfig.swift b/Meshtastic/Views/Settings/TAKServerConfig.swift index 749b54fc..09d9d60d 100644 --- a/Meshtastic/Views/Settings/TAKServerConfig.swift +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -8,6 +8,7 @@ import SwiftUI import UniformTypeIdentifiers import OSLog +import CoreData enum CertificateImportType { case p12 @@ -15,6 +16,15 @@ enum CertificateImportType { } struct TAKServerConfig: View { + @Environment(\.managedObjectContext) var context + @EnvironmentObject var accessoryManager: AccessoryManager + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \ChannelEntity.index, ascending: true)], + predicate: NSPredicate(format: "role > 0"), + animation: .default + ) private var channels: FetchedResults + @StateObject private var takServer = TAKServerManager.shared @State private var showingFileImporter = false @State private var importType: CertificateImportType = .p12 @@ -140,6 +150,17 @@ struct TAKServerConfig: View { .foregroundColor(.secondary) } + if !channels.isEmpty { + Picker(selection: $takServer.channel) { + ForEach(channels, id: \.index) { channel in + channelLabel(channel) + .tag(Int(channel.index)) + } + } label: { + Label("TAK Channel Index", systemImage: "bubble.left.and.bubble.right") + } + } + if takServer.isRunning { Button { Task { @@ -152,7 +173,7 @@ struct TAKServerConfig: View { } header: { Text("Configuration") } footer: { - Text("Secure mTLS connection on port 8089. Both server and client certificates are required.") + Text("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.") } } @@ -280,6 +301,21 @@ struct TAKServerConfig: View { } + // MARK: - Channel Label + + @ViewBuilder + private func channelLabel(_ channel: ChannelEntity) -> some View { + if channel.name?.isEmpty ?? false { + if channel.role == 1 { + Text(String("PrimaryChannel").camelCaseToWords()) + } else { + Text(String("Channel \(channel.index)").camelCaseToWords()) + } + } else { + Text(String(channel.name ?? "Channel \(channel.index)").camelCaseToWords()) + } + } + // MARK: - Import Handlers private func handleP12Import(_ result: Result<[URL], Error>) { From b9b5fc9c8b5a965733dc596a4e807f1255c0846a Mon Sep 17 00:00:00 2001 From: Austin Hargis <25471876+austinhargis@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:48:26 -0500 Subject: [PATCH 06/20] Changed capitalization from 'environment' to 'Environment' for section header. (#1591) --- Meshtastic/Views/Settings/AppSettings.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index 23ed226e..495a2910 100644 --- a/Meshtastic/Views/Settings/AppSettings.swift +++ b/Meshtastic/Views/Settings/AppSettings.swift @@ -70,7 +70,7 @@ struct AppSettings: View { } #endif } - Section(header: Text("environment")) { + Section(header: Text("Environment")) { VStack(alignment: .leading) { Toggle(isOn: $environmentEnableWeatherKit) { Label("Weather Conditions", systemImage: "cloud.sun") From 7ba9c57ac8ae8830c61b4dcddb2ce236a86ac039 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:03:07 -0700 Subject: [PATCH 07/20] =?UTF-8?q?Add=20Danish=20(da)=20translations=20?= =?UTF-8?q?=E2=80=94=20resolves=20merge=20conflicts=20from=20PR=20#1609=20?= =?UTF-8?q?(#1612)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- Localizable.xcstrings | 6102 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 6011 insertions(+), 91 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 25bac479..5f7a4fd9 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -2,10 +2,24 @@ "sourceLanguage" : "en", "strings" : { "" : { - "shouldTranslate" : false + "shouldTranslate" : false, + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } }, "\t%@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "\t%@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41,6 +55,12 @@ }, " %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -76,6 +96,12 @@ }, " %@%%" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -97,71 +123,83 @@ }, "shouldTranslate" : false }, - ": %@" : { + " : %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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" + } + }, "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" } } }, @@ -169,6 +207,12 @@ }, "(Re)define PIN_GPS_EN for your board." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "(Re)definer PIN_GPS_EN for dit printkort." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -209,6 +253,12 @@ }, "%@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -244,6 +294,12 @@ }, "%@ - %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -291,6 +347,12 @@ }, "%@ - %@ - %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ - %3$@" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -338,6 +400,12 @@ }, "%@ - %@ Towards %@ Back" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ Mod %3$@ Tilbage" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -384,6 +452,12 @@ }, "%@ - No Response" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - Intet svar" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -430,6 +504,12 @@ }, "%@ - Not Sent" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - Ikke afsendt" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -476,6 +556,12 @@ }, "%@ (%@)" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ (%2$@)" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -524,6 +610,12 @@ "%@ %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -571,6 +663,12 @@ }, "%@ %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$lld" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -618,6 +716,12 @@ }, "%@ away" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ væk" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -664,6 +768,12 @@ }, "%@ can be up to %@ bytes long." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ kan være op til %@ bytes lang." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -710,6 +820,12 @@ }, "%@ Channels?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ kanaler?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -766,6 +882,12 @@ }, "%@ dB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -806,6 +928,12 @@ }, "%@, %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -853,6 +981,12 @@ }, "%@: %lld / %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$lld / %3$lld" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -900,6 +1034,12 @@ }, "%@%%" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -940,6 +1080,12 @@ }, "%@°F" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@°F" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -980,6 +1126,12 @@ }, "%@mA" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@mA" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1014,6 +1166,12 @@ }, "%@V" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@V" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1048,6 +1206,12 @@ }, "%d" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1088,6 +1252,24 @@ }, "%d Hops" : { "localizations" : { + "da" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ét hop" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d hop" + } + } + } + } + }, "en" : { "variations" : { "plural" : { @@ -1219,6 +1401,12 @@ "%d%%" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1277,6 +1465,12 @@ "%lf" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lf" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1317,6 +1511,12 @@ }, "%lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1474,6 +1674,12 @@ }, "%lld or less hops away" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "afstand på %lld eller færre hop" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -1514,6 +1720,12 @@ }, "%lld Readings Total" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samlet %lld aflæsninger" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1548,6 +1760,12 @@ }, "%lld Total Detection Events" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samlet %lld detektioner" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1582,6 +1800,12 @@ }, "%lld%%" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1622,6 +1846,12 @@ }, "%llddb Transmit Power" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dB sendestyrke" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -1668,6 +1898,12 @@ }, "%llddBm Transmit Power" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dBm sendestyrke" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -1711,6 +1947,12 @@ }, "< 1%" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "< 1%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1763,6 +2005,12 @@ }, "🦕 End of life Version 🦖 ☄️" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦕 Ikke-supporteret version 🦖 ☄️" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1820,6 +2068,12 @@ }, "1 byte" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 byte" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1860,6 +2114,12 @@ }, "1 hop away" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 hop væk" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1901,6 +2161,12 @@ "2.4 Ghz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "2.4 GHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1935,6 +2201,12 @@ }, "7" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "7" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1997,6 +2269,12 @@ }, "25" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "25" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2037,6 +2315,12 @@ }, "50" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "50" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2077,6 +2361,12 @@ }, "75" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "75" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2117,6 +2407,12 @@ }, "100" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "100" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2157,6 +2453,12 @@ }, "128 bit" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "128 bit" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2208,6 +2510,12 @@ }, "256 bit" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "256 bit" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2266,9 +2574,7 @@ } } }, - "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" : { "ja" : { @@ -2293,6 +2599,12 @@ }, "A Meshtastic QR code contains the LoRa config and channel values needed for radios to communicate. You can share a complete channel configuration using the Replace Channels option, if you choose Add Channels your shared channels will be added to the channels on the receiving radio." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "En Meshtastic-QR-kode indeholder LoRa-konfigurationen og kanalværdierne, der er nødvendige for radiokommunikationen. Du kan dele en komplet kanalkonfiguration med Udskift Kanaler-funktionen. Hvis du vælger Tilføj kanaler vil dine delte kanaler også blive tilføjet på den modtagende radioenhed." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -2395,6 +2707,12 @@ }, "A Trace Route was sent, no response has been received." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Der er igangsat en rutesporing (trace route), men der er ikke modtaget svar." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2452,6 +2770,12 @@ }, "About" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Om" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -2492,6 +2816,12 @@ }, "About Meshtastic" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Om Meshtastic" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -2532,6 +2862,12 @@ }, "Accuracy %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Præcision %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -2572,6 +2908,12 @@ }, "Ack SNR: %@ dB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ack SNR: %@ dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2606,6 +2948,12 @@ }, "Ack Time: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Bekræftelsestidspunkt: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2641,6 +2989,12 @@ "Acknowledged" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtagelse bekræftet" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -2711,6 +3065,12 @@ }, "Acknowledged by another node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtagelse bekræftet af en anden node" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2745,6 +3105,12 @@ }, "Actions" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Handlinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -2785,6 +3151,12 @@ }, "Active" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiv" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -2825,6 +3197,12 @@ }, "Activity" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivitet" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -2871,6 +3249,12 @@ }, "ADC Override" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "ADC-tilsidesættelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -2927,11 +3311,15 @@ } } }, - "Add CA" : { - - }, + "Add CA" : {}, "Add Channel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj kanal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2966,6 +3354,12 @@ }, "Add Channels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj kanaler" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3062,6 +3456,12 @@ }, "Add to favorites" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj til foretrukne" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -3102,6 +3502,12 @@ }, "Additional help" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yderligere hjælp" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3142,6 +3548,12 @@ }, "Address" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adresse" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3204,6 +3616,12 @@ }, "Administration" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administration" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3244,6 +3662,12 @@ }, "Administration Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administration aktiveret" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3272,6 +3696,12 @@ }, "Advanced" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avanceret" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3312,6 +3742,12 @@ }, "Advanced Device GPS" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avanceret indbygget GPS" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3353,6 +3789,12 @@ "Advanced GPIO Options" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avancerede GPIO-indstillinger" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3387,6 +3829,12 @@ }, "Advanced Position Flags" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avancerede positionsflag" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3428,6 +3876,12 @@ "After" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efter" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -3580,6 +4034,12 @@ }, "After config values save the node will reboot." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noden skal genstartes med de nye indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -3651,6 +4111,12 @@ "Afternoon" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eftermiddag" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -3691,6 +4157,12 @@ }, "Airtime" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taletid" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -3761,6 +4233,12 @@ }, "Alert" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alarm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3795,6 +4273,12 @@ }, "Alert GPIO buzzer when receiving a bell" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Udløs GPIO-sirene ved modtagelse af en ASCII-klokke" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3835,6 +4319,12 @@ }, "Alert GPIO buzzer when receiving a message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Advarsel GPIO-vibrator ved modtagelse af en besked" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3869,6 +4359,12 @@ }, "Alert GPIO vibra motor when receiving a bell" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Advarsel GPIO-vibrator ved modtagelse af en ASCII-klokke" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3909,6 +4405,12 @@ }, "Alert GPIO vibra motor when receiving a message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Advarsel GPIO-vibrator ved modtagelse af en besked" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3943,6 +4445,12 @@ }, "Alert when receiving a bell" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Giv besked ved modtagelse af en ASCII-klokke" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3983,6 +4491,12 @@ }, "Alert when receiving a message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giv besked ved modtagelse af en besked" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4017,6 +4531,12 @@ }, "All" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4063,6 +4583,12 @@ }, "Allow Position Requests" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tillad positions-anmodninger" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4097,6 +4623,12 @@ }, "Alt" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højde" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4131,6 +4663,12 @@ }, "Altitude" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højde" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4171,6 +4709,12 @@ }, "Altitude %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højde %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4211,6 +4755,12 @@ }, "Altitude Geoidal Separation" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geoidhøjde Adskillelse" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4245,6 +4795,12 @@ }, "Altitude is Mean Sea Level" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højde er middelhavsniveau" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4280,6 +4836,12 @@ "Always On" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altid tændt" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4350,6 +4912,12 @@ }, "Always point north" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peg altid mod nord" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4390,6 +4958,12 @@ }, "Ambient Lighting" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Omgivelsesbelysning" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4454,6 +5028,12 @@ }, "Ambient Lighting Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration af omgivelsesbelysning" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4519,6 +5099,12 @@ "Ambient Lighting module config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration af ambient belysningsmodul modtaget: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -4583,6 +5169,12 @@ }, "An open source, off-grid, decentralized, mesh network that runs on affordable, low-power radios." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Et open-source, elnetsuafhængigt, decentraliseret mehs-netværk, der er drevet af billige, energieffektive radioer." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4629,6 +5221,12 @@ }, "Any missed messages will be delivered again." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle missede beskeder vil blive leveret igen." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4670,6 +5268,12 @@ "App connected or stand alone messaging device." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "App forbundet eller selvstændig beskedenhed." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4740,6 +5344,12 @@ }, "App Data" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-data" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4786,6 +5396,12 @@ }, "App Files" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-filer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4864,6 +5480,12 @@ }, "App Settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4910,6 +5532,12 @@ }, "Apple Apps" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple-apps" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4950,6 +5578,12 @@ }, "Approximate Location" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Omtrentlig placering" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -4990,6 +5624,12 @@ }, "Are you sure you want to delete this message?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på, at du vil slette denne besked?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5030,6 +5670,12 @@ }, "Are you sure you want to factory reset the node?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vil du nulstille noden til fabriksindstillinger?" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -5076,6 +5722,12 @@ }, "Are you sure?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker?" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -5147,6 +5799,12 @@ "Australia / New Zealand" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Australien og New Zealand" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5197,6 +5855,12 @@ }, "Automatically toggles to the next page on the screen like a carousel, based the specified interval." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skifter automatisk til den næste side på skærmen som en karrusel, baseret på det angivne interval." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5237,6 +5901,12 @@ }, "Available modem presets, default is Long Fast." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilgængelige modemforudindstillinger, standard er Lang Hurtig." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -5283,6 +5953,12 @@ }, "Available Radios" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilgængelige radioer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -5354,6 +6030,12 @@ "Back" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilbage" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -5469,6 +6151,12 @@ "Bad" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dårlig" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5504,6 +6192,12 @@ "Bad Request" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejl i forespørgsel" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5568,6 +6262,12 @@ }, "Bandwidth" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Båndbredde" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -5615,6 +6315,12 @@ "Bar" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Søjle" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5655,6 +6361,12 @@ }, "Bar Series" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Søjlediagramserie" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5696,6 +6408,12 @@ "Barometric Pressure" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Barometertryk" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5736,6 +6454,12 @@ }, "Battery" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batteri" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -5801,6 +6525,12 @@ "Battery Level" : { "comment" : "VoiceOver label for battery gauge", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batteriniveau" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -5872,6 +6602,12 @@ "Battery Level %" : { "comment" : "VoiceOver value for battery level", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterinveau %" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -6013,6 +6749,12 @@ }, "Baud" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baud" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6074,6 +6816,12 @@ "Biking" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "På cykel" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -6120,6 +6868,12 @@ }, "BLE" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLE" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6160,6 +6914,12 @@ }, "BLE Pin must be 6 digits long." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLE-pin skal være 6 cifre." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -6230,6 +6990,12 @@ }, "Bluetooth" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -6300,6 +7066,12 @@ }, "Bluetooth Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth-indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -6371,6 +7143,12 @@ "Bluetooth config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth-konfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -6502,6 +7280,12 @@ "Broadcast Interval" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Broadcast-interval" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6543,6 +7327,12 @@ "Broadcasts GPS position packets as priority." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sender GPS-positionspakker som prioritet." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -6614,6 +7404,12 @@ "Broadcasts location as message to default channel regularly for to assist with device recovery." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sender placering som besked til standardkanal regelmæssigt for at hjælpe med enhedsgendannelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -6685,6 +7481,12 @@ "Broadcasts telemetry packets as priority." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sender telemetripakker som prioritet" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -6755,6 +7557,12 @@ }, "Button GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GPIO-knap" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6795,6 +7603,12 @@ }, "Buy Complete Radios" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Køb komplette radioer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6835,6 +7649,12 @@ }, "Buzzer GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO-vibrator" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6875,6 +7695,12 @@ }, "By enabling this feature, you acknowledge and expressly consent to the transmission of your device’s real-time geographic location over the MQTT protocol without encryption. This location data may be used for purposes such as live map reporting, device tracking, and related telemetry functions." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ved at aktivere denne funktion anerkender du og giver udtrykkeligt samtykke til overførsel af din enheds geolokation i realtid over MQTT-protokollen uden kryptering. Disse positionsdata kan bruges til formål som livekortrapportering, enhedssporing og relaterede telemetriefunktioner." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -6904,6 +7730,12 @@ "Bytes" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bytes" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -6997,6 +7829,12 @@ }, "Call Sign" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kaldesignal" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -7043,6 +7881,12 @@ }, "Call Sign must not be empty" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kaldesignal må ikke være tomt" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -7089,6 +7933,12 @@ }, "Cancel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuller" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -7160,6 +8010,12 @@ "Canned Message module config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurationsmodul for standardbesked modtaget: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7224,6 +8080,12 @@ }, "Canned Messages" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foruddefinerede beskeder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -7294,6 +8156,12 @@ }, "Canned Messages Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurer Forhåndsdefinerede Meddelelser" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -7365,6 +8233,12 @@ "Canned Messages Messages Received For: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtagne beskeder for: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7429,6 +8303,12 @@ }, "Carousel Interval" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Karusselinterval" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7463,6 +8343,12 @@ }, "Categories" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kategorier" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -7509,6 +8395,12 @@ }, "Category" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kategori" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -7549,6 +8441,12 @@ }, "Ch1 Current" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch1 strøm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7583,6 +8481,12 @@ }, "Ch1 Voltage" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch1 spænding" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7617,6 +8521,12 @@ }, "Ch2 Current" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch2 strøm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7651,6 +8561,12 @@ }, "Ch2 Voltage" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch2 spænding" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7685,6 +8601,12 @@ }, "Ch3 Current" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch3 strøm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7719,6 +8641,12 @@ }, "Ch3 Voltage" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch3 spænding" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7753,6 +8681,12 @@ }, "Channel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -7823,6 +8757,12 @@ }, "Channel 0 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 0 inkluderet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7863,6 +8803,12 @@ }, "Channel 1" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7897,6 +8843,12 @@ }, "Channel 1 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 1 inkluderet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7937,6 +8889,12 @@ }, "Channel 2" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 2" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7971,6 +8929,12 @@ }, "Channel 2 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 2 inkluderet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8011,6 +8975,12 @@ }, "Channel 3" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 3" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8045,6 +9015,12 @@ }, "Channel 3 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 3 inkluderet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8085,6 +9061,12 @@ }, "Channel 4 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 4 inkluderet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8125,6 +9107,12 @@ }, "Channel 5 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 5 inkluderet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8165,6 +9153,12 @@ }, "Channel 6 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 6 inkluderet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8205,6 +9199,12 @@ }, "Channel 7 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 7 inkluderet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8285,6 +9285,12 @@ }, "Channel Name" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanalnavn" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8325,6 +9331,12 @@ }, "Channel number must be between 0 and 7." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanalnummeret skal være mellem 0 og 7." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8365,6 +9377,12 @@ }, "Channel Role" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanalrolle" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8405,6 +9423,12 @@ }, "Channel URL" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal-URL" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8439,6 +9463,12 @@ }, "Channel Utilization" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanaludnyttelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -8509,6 +9539,12 @@ }, "Channel Utilization %@%%" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanaludnyttelsesgrad %@%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8543,6 +9579,12 @@ }, "Channels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanaler" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -8614,6 +9656,12 @@ "Channels being added from the QR code did not save. When adding channels the names must be unique." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanaler tilføjet fra QR-koden blev ikke gemt. Når kanaler tilføjes, skal navnene være unikke." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8677,6 +9725,12 @@ "Chart" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Graf" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8717,6 +9771,12 @@ }, "CHG" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ÆND" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8752,6 +9812,12 @@ "China" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kina" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8803,6 +9869,12 @@ }, "Clear" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tøm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8843,6 +9915,12 @@ }, "Clear App Data" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tøm app-data" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -8913,6 +9991,12 @@ }, "Clear Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tøm log" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8976,6 +10060,12 @@ "Client" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klient" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9026,15 +10116,17 @@ } } }, - "Client CA Certificate" : { - - }, - "Client Configuration" : { - - }, + "Client CA Certificate" : {}, + "Client Configuration" : {}, "Client Hidden" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skjult klient" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -9081,6 +10173,12 @@ }, "Client History" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klienthistorik" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9121,6 +10219,12 @@ }, "Client History Request Sent" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klienthistorik-anmodning sendt" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9162,6 +10266,12 @@ "Client Mute" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tavs klient (client mute)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9202,6 +10312,12 @@ }, "Client options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klientindstillinger" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9242,6 +10358,12 @@ }, "Clockwise Rotary Event" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Med uret roterende hændelse" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9282,6 +10404,12 @@ }, "Close" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luk" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -9352,6 +10480,12 @@ }, "Coding Rate" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kodningshastighed" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9392,6 +10526,12 @@ }, "Color" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Farve" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -9460,6 +10600,12 @@ }, "Communicating" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommunikerer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9494,6 +10640,12 @@ }, "Community Support" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Support fra fællesskabet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9538,6 +10690,12 @@ }, "Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9576,11 +10734,15 @@ } } }, - "Configuration" : { - - }, + "Configuration" : {}, "Configuration for: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration for %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -9621,6 +10783,12 @@ }, "Configuration Presets" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standardkonfigurationer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9661,6 +10829,12 @@ }, "Configure" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurér" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -9781,6 +10955,12 @@ }, "Confirm" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bekræft" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9831,6 +11011,12 @@ }, "Connect to a Node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslut en Node" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -9871,6 +11057,12 @@ }, "Connect to MQTT via Proxy" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslut MQTT over proxy" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -9899,6 +11091,12 @@ }, "Connect to new radio?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslut ny radio" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9933,6 +11131,12 @@ }, "Connected" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilsluttet" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10003,6 +11207,12 @@ }, "Connected Node %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilsluttet Node %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10043,6 +11253,12 @@ }, "Connected Radio" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilsluttet radio" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10077,6 +11293,12 @@ }, "Connecting . ." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslutter . ." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10147,6 +11369,12 @@ }, "Connecting to a new radio will clear all app data on the phone." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvis du tilslutter en ny radio bliver all appens data på telefonen slettet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10181,6 +11409,12 @@ }, "Connection Attempt %lld of 10" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslutningsforsøg %lld af 10" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10243,6 +11477,12 @@ }, "Consent to Share Unencrypted Node Data via MQTT" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samtykke til at dele ukrypterede node-data via MQTT" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -10323,6 +11563,12 @@ "Contacts (%@)" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontakter (%@)" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10393,6 +11639,12 @@ }, "Control Type" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontroltype" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10433,6 +11685,12 @@ }, "Controls the blinking LED on the device. For most devices this will control one of the up to 4 LEDS, the charger and GPS LEDs are not controllable." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Styrer den blinkende LED på enheden. For de fleste enheder vil dette styre en af de op til 4 LED'er, oplader- og GPS-LED'er kan ikke styres." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10473,6 +11731,12 @@ }, "Convex Hull" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Convex hull" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10513,6 +11777,12 @@ }, "Coordinate" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koordinat" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10553,6 +11823,12 @@ }, "Coordinate %@, %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koordinat %@, %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10599,6 +11875,12 @@ }, "Coordinates:" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koordinater:" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10639,6 +11921,12 @@ }, "Copy" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kopier" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10709,6 +11997,12 @@ }, "Could not find node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke finde node" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10755,6 +12049,12 @@ }, "Counter Clockwise Rotary Event" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mod-uret Rundt Roterende Begivenhed" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10789,6 +12089,12 @@ }, "Create Waypoint" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opret viapunkt" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10851,6 +12157,12 @@ }, "Created: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oprettet: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10913,6 +12225,12 @@ }, "Current" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Strøm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10947,6 +12265,12 @@ }, "Current Firmware Version: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuværende firmwareversion: %@." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -10993,6 +12317,12 @@ }, "Current Firmware Version: %@, Latest Firmware Version: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuværende firmwareversion: %@. Seneste firmwareversion: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -11039,6 +12369,12 @@ }, "Current: %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Strøm: %lld" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -11079,6 +12415,12 @@ }, "Currently the recommended way to update ESP32 devices is using the web flasher on a desktop computer from a chrome based browser. It does not work on mobile devices or over BLE." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I øjeblikket er den anbefalede måde at opdatere ESP32-enheder på at bruge web-flasheren på en stationær computer fra en Chrome-baseret browser. Det fungerer ikke på mobile enheder eller over BLE." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11119,6 +12461,12 @@ }, "Date" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dato" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -11159,6 +12507,12 @@ }, "Debug" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejlfinding" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11199,6 +12553,12 @@ }, "Debug Logs" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejlfindingslogs" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -11239,6 +12599,12 @@ }, "Debug Logs%@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejlfindingslogs %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11273,6 +12639,12 @@ }, "Default" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -11344,6 +12716,12 @@ "Default 128x64 screen layout" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standardskærmlayout på 128x64" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11384,6 +12762,12 @@ }, "Delete" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -11452,9 +12836,7 @@ } } }, - "Delete All" : { - - }, + "Delete All" : {}, "Delete all config, keys and BLE bonds? " : { "localizations" : { "it" : { @@ -11513,6 +12895,12 @@ }, "Delete all device metrics?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet alle enhedens måledata?" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -11577,6 +12965,12 @@ }, "Delete all environment metrics?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet alle miljødata?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11617,6 +13011,12 @@ }, "Delete all pax data?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet alle persontællingsdata?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11657,6 +13057,12 @@ }, "Delete all positions?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet alle positioner?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11691,6 +13097,12 @@ }, "Delete Message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet besked" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11725,6 +13137,12 @@ }, "Delete Messages" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet beskeder" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11759,6 +13177,12 @@ }, "Delete Node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet node" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -11799,6 +13223,12 @@ }, "Delete Node?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet node?" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -11839,6 +13269,12 @@ }, "Delete Power metrics?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet alle energiforbrugsdata?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11873,6 +13309,12 @@ }, "Description" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskrivelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -11913,6 +13355,12 @@ }, "Description must be less than 100 bytes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskrivelsen skal være under 100 bytes" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11969,6 +13417,12 @@ }, "Detection" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detektion" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12003,6 +13457,12 @@ }, "Detection event" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detektionshændelse" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12038,6 +13498,12 @@ "Detection Sensor" : { "extractionState" : "manual", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detektionssensor" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -12108,6 +13574,12 @@ }, "Detection Sensor Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detektionssensor-indstillinger" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -12166,6 +13638,12 @@ }, "Detection Sensor Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detektionssensor-log" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12200,6 +13678,12 @@ }, "Detection sensor messages are received as text messages. If you enable notifications you will recieve a notification for each detection message received and a corresponding unread message badge." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registreringssensorbeskeder modtages som tekstbeskeder. Hvis du aktiverer meddelelser, vil du modtage en meddelelse for hver registreringsbesked, der modtages, samt et tilsvarende badge for ulæste beskeder." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12241,6 +13725,12 @@ "Detection Sensor module config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registrering af sensors modulkonfiguration modtaget: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -12305,6 +13795,12 @@ }, "Developers" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udviklere" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12345,6 +13841,12 @@ }, "Device" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhed" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -12415,6 +13917,12 @@ }, "Device Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsopsætning" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -12486,6 +13994,12 @@ "Device config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedskonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -12556,6 +14070,12 @@ }, "Device Configuration" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsopsætning" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -12608,6 +14128,12 @@ }, "Device GPS" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enheds-GPS" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -12654,6 +14180,12 @@ }, "Device is managed by a mesh administrator, the user is unable to access any of the device settings." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enheden administreres af en mesh-administrator, brugeren har ikke adgang til enhedens indstillinger." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12695,6 +14227,12 @@ "Device Metadata received from: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsmetadata modtaget fra: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -12766,6 +14304,12 @@ "Device Metrics" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsmåledata" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12806,6 +14350,12 @@ }, "Device Metrics Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsmetriklog" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12846,6 +14396,12 @@ }, "Device Model: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsmodel: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -12902,6 +14458,12 @@ }, "Device Role" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsrolle" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12942,6 +14504,12 @@ }, "Device Screen" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsskærm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12983,6 +14551,12 @@ "Device that does not forward packets from other devices." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhed, der ikke videresender pakker fra andre enheder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -13054,6 +14628,12 @@ "Device that only broadcasts as needed for stealth or power savings." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhed, der kun sender efter behov for at opnå diskretion eller energibesparelse." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -13124,6 +14704,12 @@ }, "Dilution of precision (DOP) PDOP used by default" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard PDOP bruges som udgangspunkt" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13158,6 +14744,12 @@ }, "Direct" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkte" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -13204,6 +14796,12 @@ }, "Direct Message Help" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hjælp til direkte beskeder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -13272,6 +14870,12 @@ }, "Direct Messages" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkte beskeder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -13342,6 +14946,12 @@ }, "Direct messages are using the new public key infrastructure for encryption. Requires firmware version 2.5 or greater." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkte beskeder bruger den nye public key-infrastruktur til kryptering. Kræver firmware-version 2.5 eller nyere" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13382,6 +14992,12 @@ }, "Direct messages are using the shared key for the channel." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkte beskeder bruger den fælles krypteringsnøgle for kanalen." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13422,6 +15038,12 @@ }, "Disabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktiveret" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -13492,6 +15114,12 @@ }, "Disconnect" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fra" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -13606,6 +15234,12 @@ }, "Dismiss" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afvis" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -13676,6 +15310,12 @@ }, "Display" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skærm" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -13746,6 +15386,12 @@ }, "Display Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skærmopsætning" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -13811,6 +15457,12 @@ "Display config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skærmopsætning modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -13881,6 +15533,12 @@ }, "Display Fahrenheit" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis Fahrenheit" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13921,6 +15579,12 @@ }, "Display Mode" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Display Mode" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13983,6 +15647,12 @@ }, "Display Units" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Display Units" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14023,6 +15693,12 @@ }, "Distance" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -14123,6 +15799,12 @@ }, "Documentation" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dokumentation" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -14197,6 +15879,12 @@ }, "Double Tap as Button" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dobbelttryk som knap" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14238,6 +15926,12 @@ "Down" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nede" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -14308,6 +16002,12 @@ }, "Downlink Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downlink slået til" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14346,11 +16046,15 @@ } } }, - "Download TAK Server Data Package" : { - - }, + "Download TAK Server Data Package" : {}, "Drag & Drop Firmware Update" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Træk-og-slip firmwareopdatering" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14391,6 +16095,12 @@ }, "Drag & Drop Firmware Update Documentation" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Træk-og-slip firmwareopdateringsdokumentation" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14431,6 +16141,12 @@ }, "Drag & Drop is the recommended way to update firmware for NRF devices. If your iPhone or iPad is USB-C it will work with your regular USB-C charging cable, for lightning devices you need the Apple Lightning to USB camera adaptor." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Træk og slip er den anbefalede måde at opdatere firmware til NRF-enheder. Hvis din iPhone eller iPad har USB-C, vil det fungere med dit almindelige USB-C-opladerkabel, for Lightning-enheder har du brug for Apple Lightning til USB-kameraadapter." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14472,6 +16188,12 @@ "Driving" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kører" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -14518,6 +16240,12 @@ }, "Drop Pin in Maps" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Placer nål i kort" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14574,6 +16302,12 @@ }, "Echo" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Echo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -14632,6 +16366,12 @@ }, "Editing Waypoint" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redigerer viapunkt" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14667,6 +16407,12 @@ "Eighteen Hours" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atten timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -14737,6 +16483,12 @@ }, "Elev. Gain" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højdeforøgelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -14777,6 +16529,12 @@ }, "Emoji" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emoji" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14817,6 +16575,12 @@ }, "Empty" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tom" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14867,6 +16631,12 @@ }, "Enable broadcasting packets via UDP over the local network." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver udsendelse af pakker via UDP over det lokale netværk." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14923,6 +16693,12 @@ }, "Enable Notifications" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tillad notifikationer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14961,11 +16737,15 @@ } } }, - "Enable TAK Server" : { - - }, + "Enable TAK Server" : {}, "Enable this device as a Store and Forward server. Requires an ESP32 device with PSRAM." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivér denne enhed som en Store and Forward-server. Kræver en ESP32-enhed med PSRAM." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15000,6 +16780,12 @@ }, "Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiveret" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -15071,6 +16857,12 @@ "Enables automatic TAK PLI broadcasts and reduces routine broadcasts." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiverer automatiske TAK PLI-udsendelser og reducerer rutineudsendelser" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -15141,6 +16933,12 @@ }, "Enables devices with native I2S audio output to use the RTTTL over speaker like a buzzer. T-Watch S3 and T-Deck for example have this capability." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiverer enheder med native I2S-lydudgang til at bruge RTTTL over højttaler som en buzzer. T-Watch S3 og T-Deck har for eksempel denne kapabilitet." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15203,6 +17001,12 @@ }, "Enables the detection sensor module, it needs to be enabled on both the node with the sensor, and any nodes that you want to receive detection sensor text messages or view the detection sensor log and chart." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiverer detektionssensormodulet. Det skal være aktiveret både på noden med sensoren og på alle noder, hvor du ønsker at modtage detektionssensor-tekstbeskeder eller se detektionssensorloggen og diagrammet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15243,6 +17047,12 @@ }, "Enables the store and forward module." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiverer butiks- og videresendelsesmodulet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15277,6 +17087,12 @@ }, "Enabling Ethernet will disable the bluetooth connection to the app." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivering af Ethernet vil deaktivere bluetooth-forbindelsen til appen." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15305,6 +17121,12 @@ }, "Enabling WiFi will disable the bluetooth connection to the app." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivering af WiFi vil deaktivere Bluetooth-forbindelsen til appen." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -15321,6 +17143,12 @@ }, "Encoder Press Event" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encoder trykhændelse" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15355,6 +17183,12 @@ }, "Encrypted" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Krypteret" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -15426,6 +17260,12 @@ "Encrypted Send Failed" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Krypteret afsendelse mislykkedes" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -15460,6 +17300,12 @@ }, "Encryption Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kryptering aktiveret" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15500,6 +17346,12 @@ }, "Enter DFU Mode" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gå ind i DFU-tilstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -15562,14 +17414,16 @@ } } }, - "Enter P12 Password" : { - - }, - "Enter the password for the PKCS#12 file" : { - - }, + "Enter P12 Password" : {}, + "Enter the password for the PKCS#12 file" : {}, "environment" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "miljø" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -15616,6 +17470,12 @@ }, "Environment" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Miljø" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -15663,6 +17523,12 @@ "Environment Metrics" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Miljødata" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15713,6 +17579,12 @@ }, "Environment Metrics Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Miljødata-log" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15763,6 +17635,12 @@ }, "Erase all app data?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slette alle app-data?" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -15809,6 +17687,12 @@ }, "Erase all device and app data?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slette alle enheds- og appdata?" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -15855,6 +17739,12 @@ }, "Error: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejl: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15895,6 +17785,12 @@ }, "ESP 32 OTA update is a work in progress, click the button below to send your device a reboot into ota admin message." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "ESP 32 OTA-opdatering er et igangværende arbejde, klik på knappen nedenfor for at sende din enhed en genstart til ota admin-besked" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15935,6 +17831,12 @@ }, "ESP32 Device Firmware Update" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "ESP32-enhedens firmwareopdatering" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15975,6 +17877,12 @@ }, "Ethernet Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ethernet-indstillinger" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16010,6 +17918,12 @@ "European Union 433MHz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "EU 433 MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16045,6 +17959,12 @@ "European Union 868MHz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "EU 868 MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16080,6 +18000,12 @@ "Evening" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aften" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16120,6 +18046,12 @@ }, "Exchange Positions" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Byt Positioner" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16165,6 +18097,12 @@ "Exclamation" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udråb" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16257,6 +18195,12 @@ }, "Expire" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udløbe" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16297,6 +18241,12 @@ }, "Expires" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udløber" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16337,6 +18287,12 @@ }, "Expires: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udløber: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16371,6 +18327,12 @@ }, "Export" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporter" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16411,6 +18373,12 @@ }, "External Notification" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekstern meddelelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16481,6 +18449,12 @@ }, "External Notification Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekstern notifikationskonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16552,6 +18526,12 @@ "External Notification module config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moduletilkonfiguration for ekstern meddelelse modtaget: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -16616,6 +18596,12 @@ }, "Factory Reset" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gendan til fabriksindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16685,6 +18671,12 @@ "Failed to encode message content" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke kode meddelelsens indhold" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16729,6 +18721,12 @@ }, "Failed to get a valid position to exchange" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke få en gyldig position til udveksling" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16763,6 +18761,12 @@ }, "Failed to get a valid position to exchange." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke få en gyldig position til at bytte." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16798,6 +18802,12 @@ "Fair" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retfærdig" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16838,6 +18848,12 @@ }, "Favorite" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foretrukne" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16911,6 +18927,12 @@ }, "Favorites" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foretrukne" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16951,6 +18973,12 @@ }, "Favorites and nodes with recent messages show up at the top of the contact list." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foretrukne og noder med nylige beskeder vises øverst på kontaktlisten" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -16997,6 +19025,12 @@ }, "Fetch the latest position of a cetain node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hent seneste position for én node" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17037,6 +19071,12 @@ }, "Fifteen Minutes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Femten minutter" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17072,6 +19112,12 @@ "Fifteen Seconds" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Femten sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17142,6 +19188,12 @@ }, "File Storage" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filopbevaring" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17215,6 +19267,12 @@ }, "Find a contact" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Find en kontaktperson" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17255,6 +19313,12 @@ }, "Find a node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Find en node" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17295,6 +19359,12 @@ }, "Finish" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afslut" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17388,6 +19458,12 @@ }, "Firmware" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Firmware" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17434,6 +19510,12 @@ }, "Firmware update docs" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Firmware opdateringsdokumenter" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17474,6 +19556,12 @@ }, "Firmware Updates" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Firmware-opdateringer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17520,6 +19608,12 @@ }, "Firmware Version" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Firmware-version" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17590,6 +19684,12 @@ }, "First heard" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Første gang hørt" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17625,6 +19725,12 @@ "Five Hours" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fem timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17695,6 +19801,12 @@ }, "Five Minutes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fem minutter" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17736,6 +19848,12 @@ "Five Seconds" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fem sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17806,6 +19924,12 @@ }, "Fixed Pin" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fastgjort pin " + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -17876,6 +20000,12 @@ }, "Fixed Position" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fast position" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17910,6 +20040,12 @@ }, "Flip Screen" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vend Skærm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17944,6 +20080,12 @@ }, "Flip screen vertically" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vend skærm lodret" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17979,6 +20121,12 @@ "Follow" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Følg" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -18050,6 +20198,12 @@ "Follow with heading" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Følg med overskrift" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -18120,6 +20274,12 @@ }, "For all Mqtt functionality other than the map report you must also set uplink and downlink for each channel you want to bridge over Mqtt." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "For al MQTT-funktionalitet bortset fra kortrapporten skal du også indstille uplink og downlink for hver kanal, du vil forbinde til, over MQTT." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18160,6 +20320,12 @@ }, "For everyone" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "For alle" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -18200,6 +20366,12 @@ }, "For me" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "For mig" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -18241,6 +20413,12 @@ "Forty Eight Hours" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otteogfyrre timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -18312,6 +20490,12 @@ "Forty Five Seconds" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Femogfyrre sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -18383,6 +20567,12 @@ "Four Hours" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fire timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -18454,6 +20644,12 @@ "Four Seconds" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fire sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -18524,6 +20720,12 @@ }, "Frequency" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frekvens" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -18564,6 +20766,12 @@ }, "Frequency Override" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frekvensoverride" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18598,6 +20806,12 @@ }, "Frequency Slot" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frekvensplads" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18632,6 +20846,12 @@ }, "Friendly name" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Venligt navn" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18672,6 +20892,12 @@ }, "Friendly name used to format message sent to mesh. Example: A name \"Motion\" would result in a message \"Motion detected\"" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Venligt navn, der bruges til at formatere beskeder sendt til mesh. Eksempel: Et navn \"Motion\" ville resultere i en besked \"Motion detected\"" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18728,6 +20954,12 @@ }, "Full Support" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fuld support" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18760,9 +20992,7 @@ } } }, - "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" : { "ja" : { @@ -18787,6 +21017,12 @@ }, "Generate QR Code" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generer QR-kode" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -18857,6 +21093,12 @@ }, "Get custom waterproof solar and detection sensor router nodes, aluminium desktop nodes and rugged handsets." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Få brugerdefinerede vandtætte sol- og detektionssensorroutere, aluminium desktop-noder og robuste håndsæt." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18891,6 +21133,12 @@ }, "Get Node Position" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hent nodeposition" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -18937,6 +21185,12 @@ }, "Get NRF DFU from the App Store" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hent NRF DFU fra App Store" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18999,6 +21253,12 @@ }, "Get the latest stable firmware" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hent den nyeste stabile firmware" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19062,6 +21322,12 @@ "Good" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Godt" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19096,6 +21362,12 @@ }, "GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19136,6 +21408,12 @@ }, "GPIO Output Duration" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO-outputvarighed" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19170,6 +21448,12 @@ }, "GPIO pin for rotary encoder A port." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO-pin for drejeenkoder A-port" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19204,6 +21488,12 @@ }, "GPIO pin for rotary encoder B port." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO-pin til drejeenkoder B-port." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19238,6 +21528,12 @@ }, "GPIO pin for rotary encoder Press port." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO-pin til roterende enkoder Press-port" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19272,6 +21568,12 @@ }, "GPIO Pin to monitor" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO-pin til overvågning" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19306,6 +21608,12 @@ }, "GPS EN GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS PÅ GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19346,6 +21654,12 @@ }, "GPS Receive GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS Indgang GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19386,6 +21700,12 @@ }, "GPS Transmit GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS Send GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19426,6 +21746,12 @@ }, "Group Message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gruppemeddelelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -19472,6 +21798,12 @@ }, "Gusts %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stød %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19507,6 +21839,12 @@ "HaHa" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "HaHa" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -19569,6 +21907,12 @@ }, "Hardware" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hardware" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19610,6 +21954,12 @@ "Hazardous" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Farlig" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19644,6 +21994,12 @@ }, "Heading" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retning" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19678,6 +22034,12 @@ }, "Heading: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retning: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -19719,6 +22081,12 @@ "Heard" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hørt" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -19790,6 +22158,12 @@ "Heart" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hjerte" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -19860,6 +22234,12 @@ }, "Hide alerts" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skjul alarmer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19894,6 +22274,12 @@ }, "Hide Alerts" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skjul Alarmer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19928,6 +22314,12 @@ }, "HIGH" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "HØJ" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -19975,6 +22367,12 @@ "Hiking" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vandrer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -20021,6 +22419,12 @@ }, "History Return Max" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historik Return Max" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20055,6 +22459,12 @@ }, "History Return Window" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vindue for historikreturnering" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20089,6 +22499,12 @@ }, "Hops Away" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hops væk" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -20129,6 +22545,12 @@ }, "Hops Away %d" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hop væk %d" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -20170,6 +22592,12 @@ "Hops Away:" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hop væk:" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -20210,6 +22638,12 @@ }, "Hops Away: %d" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hop væk: %d" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -20250,6 +22684,12 @@ }, "Hour" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -20296,6 +22736,12 @@ }, "Hourly Duty Cycle" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Driftcyklus pr. time" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20330,6 +22776,12 @@ }, "How long the screen remains on after the user button is pressed or messages are received." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvor lang tid skærmen forbliver tændt, efter brugeren har trykket på knappen, eller meddelelser er modtaget." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20370,6 +22822,12 @@ }, "How often device metrics are sent out over the mesh. Default is 30 minutes." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvor ofte enhedens metrik sendes ud over mesh-netværket. Standard er 30 minutter." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20410,6 +22868,12 @@ }, "How often environment metrics are sent out over the mesh. Default is 30 minutes." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvor ofte miljømålinger sendes ud over netværket. Standard er 30 minutter." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20450,6 +22914,12 @@ }, "How often power metrics are sent out over the mesh. Default is 30 minutes." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvor ofte effektmålinger sendes ud over mesh-netværket. Standardindstillingen er 30 minutter." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20490,6 +22960,12 @@ }, "How often should we try to get a GPS position." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvor ofte skal vi forsøge at få en GPS-position" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20530,6 +23006,12 @@ }, "How often to send detection sensor state to mesh regardless of detection. Default is Never." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvor ofte tilstanden for detektionssensoren skal sendes til mesh uanset detektion. Standard er Aldrig." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20570,6 +23052,12 @@ }, "How often we can send a message to the mesh when people are detected." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvor ofte vi kan sende en besked til netværket, når personer registreres" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -20634,6 +23122,12 @@ }, "How to update Firmware" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sådan opdateres firmware" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -20681,6 +23175,12 @@ "Hum" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brum" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20715,6 +23215,12 @@ }, "Humidity" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luftfugtighed" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -20756,6 +23262,12 @@ "Hybrid" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hybrid" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20821,6 +23333,12 @@ "Hybrid Flyover" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hybrid Luftfoto" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20885,6 +23403,12 @@ }, "I have read and understand the above. I voluntarily consent to the unencrypted transmission of my node data via MQTT." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jeg har læst og forstået ovenstående. Jeg giver frivilligt samtykke til ukrypteret transmission af mine node-data via MQTT." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -20913,6 +23437,12 @@ }, "IAQ" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20953,6 +23483,12 @@ }, "IAQ " : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ " + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20993,6 +23529,12 @@ }, "IAQ %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ %lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21033,6 +23575,12 @@ }, "Icon" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikon" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -21095,6 +23643,12 @@ }, "If DOP is set, use HDOP / VDOP values instead of PDOP" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvis DOP er indstillet, brug HDOP / VDOP værdier i stedet for PDOP" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21135,6 +23689,12 @@ }, "If enabled, the 'output' Pin will be pulled active high, disabled means active low." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvis aktiveret, vil 'output'-pinden blive trukket aktiv høj, deaktiveret betyder aktiv lav" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21175,6 +23735,12 @@ }, "If it is hard to access your device's reset button enter DFU mode here." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvis det er svært at få adgang til din enheds nulstillingsknap, skal du gå ind i DFU-tilstand her." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21215,6 +23781,12 @@ }, "If set, any packets you send will be echoed back to your device." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvis indstillet, vil alle pakker, du sender, blive sendt tilbage til din enhed." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21255,6 +23827,12 @@ }, "If the default region topic is too busy you can choose a more local topic." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvis standardregionsemnet er for travlt, kan du vælge et mere lokalt emne." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21295,6 +23873,12 @@ }, "Ignore MQTT" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorer MQTT" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21335,6 +23919,12 @@ }, "Ignore Node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorer node" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21375,6 +23965,12 @@ }, "Ignored" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignoreret" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21416,6 +24012,12 @@ "Ignores observed messages from foreign meshes like Local Only, but takes it step further by also ignoring messages from nodes not already in the node's known list." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorerer observerede meddelelser fra fremmede mesh-netværk ligesom kun lokale, men tager det et skridt videre ved også at ignorere meddelelser fra noder, der ikke allerede er på nodens kendte liste." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -21445,6 +24047,12 @@ "Ignores observed messages from foreign meshes that are open or those which it cannot decrypt. Only rebroadcasts message on the nodes local primary / secondary channels." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorerer observerede meddelelser fra fremmede netværk, der er åbne, eller dem, som den ikke kan dekryptere. Genudsender kun meddelelser på noderne lokale primære / sekundære kanaler." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -21471,20 +24079,18 @@ } } }, - "Import" : { - - }, - "Import .pem" : { - - }, - "Import Custom .p12" : { - - }, - "Import Error" : { - - }, + "Import" : {}, + "Import .pem" : {}, + "Import Custom .p12" : {}, + "Import Error" : {}, "Import Route" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importér rute" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -21547,6 +24153,12 @@ }, "Include" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inkluder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -21687,6 +24299,12 @@ "India" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indien" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -21716,6 +24334,12 @@ "Indoor Air Quality" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indendørs luftkvalitet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21756,6 +24380,12 @@ }, "Indoor Air Quality (IAQ)" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indeklimakvalitet (IAQ)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21797,6 +24427,12 @@ "Infrastructure node on a tower or mountain top only. Not to be used for roofs or mobile nodes. Needs exceptional coverage. Visible in Nodes list." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Infrastrukturnode på et tårn eller en bjergtop. Må ikke bruges til hustage eller mobile knuder. Kræver ekstraordinært god dækning. Synlig i knudelisten." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -21867,6 +24503,12 @@ }, "Inputs" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Input" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21930,6 +24572,12 @@ "Inverted top bar for 2 Color display" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Omvendt topbjælke til 2-farvevisning" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21971,6 +24619,12 @@ "Japan" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Japan" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22005,6 +24659,12 @@ }, "JSON Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON aktiveret" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22045,6 +24705,12 @@ }, "JSON mode is a limited, unencrypted MQTT output for locally integrating with home assistant" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON-tilstand er en begrænset, ukrypteret MQTT-udgang til lokal integration med Home Assistant" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22085,6 +24751,12 @@ }, "Jump to present" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gå til nutid" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -22113,6 +24785,12 @@ }, "Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -22181,6 +24859,12 @@ }, "Key Mapping" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tastetilknytning" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22215,6 +24899,12 @@ }, "Key Size" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nøglestørrelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -22256,6 +24946,12 @@ "Korea" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korea" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22290,6 +24986,12 @@ }, "Last heard" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sidst hørt" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -22358,6 +25060,12 @@ }, "Latitude" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Breddegrad" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -22448,6 +25156,12 @@ }, "LED Heartbeat" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LED-hjertebanken" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22488,6 +25202,12 @@ }, "LED State" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LED-tilstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -22535,6 +25255,12 @@ "Left" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Venstre" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -22605,6 +25331,12 @@ }, "Level" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Niveau" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -22675,6 +25407,12 @@ }, "Licensed Operator" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Licenseret operatør" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22715,6 +25453,12 @@ }, "Limit all periodic broadcast intervals especially telemetry and position. If you need to increase hops, do it on nodes at the edges, not the ones in the middle. MQTT is not advised when you are duty cycle restricted because the gateway node is then doing all the work." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Begræns alle periodiske udsendelsesintervaller, især telemetri og position. Hvis du har brug for at øge antallet af hop, skal du gøre det på noder i kanterne, ikke dem i midten. MQTT anbefales ikke, når du er begrænset af duty cycle, fordi gateway-noden så udfører alt arbejdet." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22755,6 +25499,12 @@ }, "Line Series" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linjeserie" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22789,6 +25539,12 @@ }, "Loading Logs. . ." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indlæser logfiler. . ." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22847,6 +25603,12 @@ }, "Location:" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Placering:" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -22893,6 +25655,12 @@ }, "Locked" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Låst" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -22939,6 +25707,12 @@ }, "Log Levels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logniveauer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22979,6 +25753,12 @@ }, "Logging" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logning" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -23049,6 +25829,12 @@ }, "Logs" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logfiler" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23090,6 +25876,12 @@ "Logs:" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logfiler:" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23130,6 +25922,12 @@ }, "Long Name" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Langt navn" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -23176,6 +25974,12 @@ }, "Long press to favorite or mute the contact or delete a conversation." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk længe for at tilføje som foretrukken eller slå kontakten fra lyd eller slette en samtale." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -23223,6 +26027,12 @@ "Long Range - Fast" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lang Rækkevidde - Hurtig" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23258,6 +26068,12 @@ "Long Range - Moderate" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lang rækkevidde - Moderat" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23293,6 +26109,12 @@ "Long Range - Slow" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lang rækkevidde - Langsom" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23327,6 +26149,12 @@ }, "Longitude" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Længdegrad" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -23417,6 +26245,12 @@ }, "LoRa" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoRa" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -23487,6 +26321,12 @@ }, "LoRa Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoRa-konfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -23574,6 +26414,12 @@ "LoRa config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoRa konfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -23645,6 +26491,12 @@ "Lost and Found" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mistet og fundet" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -23691,6 +26543,12 @@ }, "LOW" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LAV" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23748,6 +26606,12 @@ "M5 Stack Card KB / RAK Keypad" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "M5 Stack Card KB / RAK Tastatur" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -23801,6 +26665,12 @@ "Malaysia 433MHz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malaysia 433 MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23836,6 +26706,12 @@ "Malaysia 919MHz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malaysia 919MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23870,6 +26746,12 @@ }, "Manage Channels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrer kanaler" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -24004,6 +26886,12 @@ }, "Managed Device" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administreret enhed" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24061,6 +26949,12 @@ "Manual Configuration" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel konfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -24173,6 +27067,12 @@ }, "Map Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kortindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -24290,6 +27190,12 @@ "Map Publish Interval" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kortudgivelsesinterval" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24330,6 +27236,12 @@ }, "Map Report" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kortrapport" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24371,6 +27283,12 @@ "Max Retransmission Reached" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimal Genudsendelse Nået" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -24442,6 +27360,12 @@ "Medium Range - Fast" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mellem rækkevidde - Hurtig" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24477,6 +27401,12 @@ "Medium Range - Slow" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mellem rækkevidde - Langsom" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24511,6 +27441,12 @@ }, "Mesh activity update" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdatering af meshningsaktivitet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24545,6 +27481,12 @@ }, "Mesh Live Activity" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mesh Live Aktivitet" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -24615,6 +27557,12 @@ }, "Mesh Map" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mesh-kort" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -24733,6 +27681,12 @@ }, "Meshtastic Node %@ has shared channels with you" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic-noden %@ har delt kanaler med dig" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -24795,6 +27749,12 @@ }, "Meshtastic® Copyright Meshtastic LLC" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic® er copyright Meshtastic LLC" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24829,6 +27789,12 @@ }, "Message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Besked" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -24869,6 +27835,12 @@ }, "Message content exceeds 200 bytes." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskedindhold overstiger 200 byte." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -24909,6 +27881,12 @@ }, "Message Details" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meddelelsesdetaljer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -24980,6 +27958,12 @@ "Message received from the text message app." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Besked modtaget fra sms-appen." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25073,6 +28057,12 @@ }, "Message Status Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskedstatusindstillinger" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25107,6 +28097,12 @@ }, "Messages" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskeder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25171,6 +28167,12 @@ }, "Messages separate with |" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskeder adskilt med |" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25234,6 +28236,12 @@ "Metric" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metrisk" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25269,6 +28277,12 @@ "Midday" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Middag" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25309,6 +28323,12 @@ }, "Minimum Distance" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimum afstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25350,6 +28370,12 @@ "Minimum Interval" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimumsinterval" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25391,6 +28417,12 @@ "Minimum time between detection broadcasts" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimum tid mellem detektionsudsendelser" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25425,6 +28457,12 @@ }, "Mininum time between detection broadcasts. Default is 45 seconds." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimaltid mellem detektion broadcasts. Standard er 45 sekunder." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25459,6 +28497,12 @@ }, "Mode" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25529,6 +28573,12 @@ }, "Model" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25570,6 +28620,12 @@ "Moderate" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moderat" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25610,6 +28666,12 @@ }, "Module Configuration" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modulkonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25681,6 +28743,12 @@ "Morning" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Morgen" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25721,6 +28789,12 @@ }, "Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/tips/)" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "De fleste data på dit mesh sendes over hovedkanalen. Du kan oprette sekundære kanaler for at skabe yderligere beskedgrupper sikret med deres egen nøgle. [Kanal konfigurationstips](https://meshtastic.org/docs/configuration/tips/)" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25791,6 +28865,12 @@ }, "MQTT" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25831,6 +28911,12 @@ }, "MQTT Client Proxy" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT-klientproxy" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25901,6 +28987,12 @@ }, "MQTT Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT-konfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -25972,6 +29064,12 @@ "MQTT module config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT-modulkonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -26040,11 +29138,15 @@ } } }, - "mTLS" : { - - }, + "mTLS" : {}, "Multiplier" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplikator" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -26103,6 +29205,12 @@ }, "Must be a single emoji" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skal være en enkelt emoji" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26138,6 +29246,12 @@ "MyInfo received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "MyInfo modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -26209,6 +29323,12 @@ "Nag timeout" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Banke-timeout" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26237,6 +29357,12 @@ }, "Name" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navn" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -26283,6 +29409,12 @@ }, "Name must be less than 30 bytes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navn skal være mindre end 30 byte" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -26329,6 +29461,12 @@ }, "Navigate to node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rutevejvisning til node" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26363,6 +29501,12 @@ }, "Nearby Topics" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nærliggende emner" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26397,6 +29541,12 @@ }, "Network" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netværk" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -26467,6 +29617,12 @@ }, "Network Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netværkskonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -26538,6 +29694,12 @@ "Network config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netværkskonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -26608,6 +29770,12 @@ }, "Network Status Orange" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netværksstatus Orange" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26648,6 +29816,12 @@ }, "Network Status Red" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netværksstatus rød" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26689,6 +29863,12 @@ "New Node" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ny node" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26724,6 +29904,12 @@ "New Node has been discovered" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ny node fundet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26781,6 +29967,12 @@ "New Zealand 865MHz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Zealand 865MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26815,6 +30007,12 @@ }, "Newer firmware is available" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nyere firmware er tilgængelig" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -26862,6 +30060,12 @@ "Nighttime" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nattetid" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -26903,6 +30107,12 @@ "NMEA Positions" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "NMEA-positioner" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -26974,6 +30184,12 @@ "No Channel" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen kanal" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27044,6 +30260,12 @@ }, "No Connected Node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ingen forbundne noder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27131,6 +30353,12 @@ }, "No device connected" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen enhed tilsluttet" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27201,6 +30429,12 @@ }, "No Device Metrics" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen enhedsdata" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27235,6 +30469,12 @@ }, "No Environment Metrics" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen miljødata" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27298,6 +30538,12 @@ "No Interface" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen grænseflade" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27385,6 +30631,12 @@ }, "No PAX Counter Logs" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen PAX-logfiler" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27426,6 +30678,12 @@ "No PIN (Just Works)" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen PIN (Bare fungerer)" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27496,6 +30754,12 @@ }, "No Positions" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen positioner" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27536,6 +30800,12 @@ }, "No Power Metrics" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen energidata" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27571,6 +30841,12 @@ "No Response" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intet svar" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27642,6 +30918,12 @@ "No Route" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen rute" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27712,6 +30994,12 @@ }, "Node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27752,6 +31040,12 @@ }, "Node Core Data Backup %@/%@ - %@ - %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Node Core Data-Backup %1$@/%2$@ - %3$@ - %4$@" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -27799,6 +31093,12 @@ "Node does not have positions" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noden er ikke positioneret" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27839,6 +31139,12 @@ }, "Node History" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodehistorik" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27880,6 +31186,12 @@ "Node Info Broadcast Interval" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node Info Broadcast Interval" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27914,6 +31226,12 @@ }, "Node Map" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node-kort" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27954,6 +31272,12 @@ }, "Node Number" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodenummer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -27994,6 +31318,12 @@ }, "Nodes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28059,6 +31389,12 @@ "Nodes (%@)" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noder (%@)" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28130,6 +31466,12 @@ "None" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28200,6 +31542,12 @@ }, "Not a valid route file" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke en gyldig rute-fil" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28235,6 +31583,12 @@ "Not Authorized" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen adgangsret" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28306,6 +31660,12 @@ "Not Present" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke til stede" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28364,6 +31724,12 @@ }, "Notes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noter" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28470,6 +31836,12 @@ }, "Number of hops" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antal hop" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28510,6 +31882,12 @@ }, "Number of records" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antal poster" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28550,6 +31928,12 @@ }, "Number of satellites" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antal satellitter" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28606,6 +31990,12 @@ }, "OK" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28646,6 +32036,12 @@ }, "Ok to MQTT" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK til MQTT" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28686,6 +32082,12 @@ }, "OLED Type" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OLED-type" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28733,6 +32135,12 @@ "On Boot Only" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kun ved opstart" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28803,6 +32211,12 @@ }, "Onboarding for licensed operators requires firmware 2.0.20 or greater. Make sure to refer to your local regulations and contact the local amateur frequency coordinators with questions." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onboarding af licenserede operatører kræver firmware 2.0.20 eller nyere. Sørg for at henvise til dine lokale regler og kontakt de lokale amatørfrekvenskoordinatorer med spørgsmål." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28843,6 +32257,12 @@ }, "One Hour" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Én time" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28913,6 +32333,12 @@ }, "One Minute" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Et minut" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -28984,6 +32410,12 @@ "One Second" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ét sekund" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -29054,6 +32486,12 @@ }, "Online" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Online" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -29095,6 +32533,12 @@ "Only permitted for SENSOR, TRACKER and TAK_TRACKER roles, this will inhibit all rebroadcasts, not unlike CLIENT_MUTE role." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kun tilladt for SENSOR-, TRACKER- og TAK_TRACKER-roller, dette vil hæmme alle genudsendelser, ikke ulig CLIENT_MUTE-rollen." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -29124,6 +32568,12 @@ "Only rebroadcasts packets from the core portnums: NodeInfo, Text, Position, Telemetry, and Routing." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kun videresender pakker fra kerneportnumre: NodeInfo, Text, Position, Telemetry og Routing." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -29162,6 +32612,12 @@ }, "Open Settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åbn indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -29209,6 +32665,12 @@ "Optimized for 2 color displays" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optimeret til 2-farve skærme" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29244,6 +32706,12 @@ "Optimized for ATAK system communication, reduces routine broadcasts." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optimeret til ATAK-systemkommunikation, reducerer rutinemæssige udsendelser" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -29314,6 +32782,12 @@ }, "Optional fields to include when assembling position messages. the more fields are included, the larger the message will be - leading to longer airtime and a higher risk of packet loss" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valgfrie felter at inkludere, når positionsmeddelelser samles. Jo flere felter, der inkluderes, jo større bliver meddelelsen - hvilket fører til længere sendetid og en højere risiko for pakkeloss" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29354,6 +32828,12 @@ }, "Optional GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valgfri GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29388,6 +32868,12 @@ }, "Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -29458,6 +32944,12 @@ }, "OS Log Entry Details" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OS-logindlægdetaljer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29492,6 +32984,12 @@ }, "OTA Updates are not supported on this NRF Device." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OTA-opdateringer understøttes ikke på denne NRF-enhed." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29532,6 +33030,12 @@ }, "OTA Updates are not supported on your platform." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OTA-opdateringer understøttes ikke på din platform." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29572,6 +33076,12 @@ }, "Other data sources" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Andre datakilder" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29606,6 +33116,12 @@ }, "Output live debug logging over serial, view and export position-redacted device logs over Bluetooth." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output live debug logning via seriel, se og eksporter positionsredigerede enhedslogger via Bluetooth" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -29646,6 +33162,12 @@ }, "Output pin buzzer GPIO " : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output pin buzzer GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29680,6 +33202,12 @@ }, "Output pin GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output pin GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29714,6 +33242,12 @@ }, "Output pin vibra GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output pin vibra GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29749,6 +33283,12 @@ "Overlanding" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overlanding" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29789,6 +33329,12 @@ }, "Override automatic OLED screen detection." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilsidesæt automatisk OLED-skærmdetektion." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29855,6 +33401,12 @@ }, "Pairing Mode" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parringstilstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -29925,6 +33477,12 @@ }, "Password" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adgangskode" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -29995,6 +33553,12 @@ }, "Pause" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -30065,6 +33629,12 @@ }, "PAX Counter" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX tæller" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -30123,6 +33693,12 @@ }, "PAX Counter Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX-tæller konfiguration" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -30176,6 +33752,12 @@ "PAX Counter config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX-tæller konfiguration modtaget: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30216,6 +33798,12 @@ }, "PAX Counter Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX-tællerlog" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30257,6 +33845,12 @@ "PAX Counter message received from: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX Counter-besked modtaget fra: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -30321,6 +33915,12 @@ }, "paxcounter.log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "paxcounter.log" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -30349,6 +33949,12 @@ }, "Perform a factory reset on the node you are connected to" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udfør en fabriksnulstilling på den node, du er tilsluttet" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -30390,6 +33996,12 @@ "Philippines 433MHz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filippinerne 433 MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30425,6 +34037,12 @@ "Philippines 868MHz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filippinerne 868 MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30460,6 +34078,12 @@ "Philippines 915MHz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filippinerne 915MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30494,6 +34118,12 @@ }, "Phone GPS" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telefon GPS" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -30586,6 +34216,12 @@ }, "Pin %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fastgør %lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30620,6 +34256,12 @@ }, "Pin A" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fastgør A" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30654,6 +34296,12 @@ }, "Pin B" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fastgør B" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30688,6 +34336,12 @@ }, "PKI based node administration, requires firmware version 2.5+" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PKI-baseret nodeadministration kræver firmwareversion 2.5+" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -30734,6 +34388,12 @@ }, "Please be advised that because the map report is not encrypted, your data may be stored and displayed permanently by third parties. Meshtastic does not assume responsibility for any such storage, display or disclosure of this data." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vær opmærksom på, at fordi kortrapporten ikke er krypteret, kan dine data blive gemt og vist permanent af tredjeparter. Meshtastic påtager sig ikke ansvar for lagring, visning eller offentliggørelse af disse data." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -30762,6 +34422,12 @@ }, "Please connect to a radio to configure settings." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opret forbindelse til en radio for at konfigurere indstillinger." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -30809,6 +34475,12 @@ "Please set a region" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angiv venligst en region" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -30849,6 +34521,12 @@ }, "Points of Interest" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interessante steder" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30884,6 +34562,12 @@ "Poop" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afføring" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -30952,11 +34636,15 @@ } } }, - "Port" : { - - }, + "Port" : {}, "Position" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Placering" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -31009,6 +34697,12 @@ }, "Position Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positionskonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -31144,6 +34838,12 @@ }, "Position Exchange Failed" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Placering udveksling mislykkedes" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31178,6 +34878,12 @@ }, "Position Exchange Requested" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positionsudveksling anmodet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31212,6 +34918,12 @@ }, "Position Flags" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positionsflag" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31246,6 +34958,12 @@ }, "Position Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positionslog" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31280,6 +34998,12 @@ }, "Position Log %lld Points" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positionslog %lld punkter" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31314,6 +35038,12 @@ }, "Position Packet" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Position pakke" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31348,6 +35078,12 @@ }, "Position Sent" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Position sendt" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -31388,6 +35124,12 @@ }, "Positions Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positioner aktiveret" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31428,6 +35170,12 @@ }, "Positions will be provided by your device GPS, if you select disabled or not present you can set a fixed position." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positioner vil blive angivet af din enheds GPS, hvis du vælger deaktiveret eller ikke til stede, kan du indstille en fast position." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31468,6 +35216,12 @@ }, "Power" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strøm" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -31532,6 +35286,12 @@ }, "Power Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energikonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -31597,6 +35357,12 @@ "Power config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strømkonfiguration modtaget: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31638,6 +35404,12 @@ "Power Metrics" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energidata" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31672,6 +35444,12 @@ }, "Power Metrics Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energidata-log" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31706,6 +35484,12 @@ }, "Power Off" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluk" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31741,6 +35525,12 @@ "Power Options" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strømindstillinger" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31769,6 +35559,12 @@ }, "Power Saving" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strømbesparelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -31833,6 +35629,12 @@ }, "Power Screen" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strøm Skærm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31877,6 +35679,12 @@ }, "Powered" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Drevet" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -31917,6 +35725,12 @@ }, "Precise Location" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Præcis placering" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -31957,6 +35771,12 @@ }, "Presets" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forudindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -32003,6 +35823,12 @@ }, "Press Pin" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk fastgør" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32037,6 +35863,12 @@ }, "Pressure" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32071,6 +35903,12 @@ }, "Primary" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primær" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -32141,6 +35979,12 @@ }, "Primary Admin Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primær administratørnøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -32187,6 +36031,12 @@ }, "Primary GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primær GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32221,6 +36071,12 @@ }, "Private Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privat nøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -32268,6 +36124,12 @@ "Process" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behandl" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -32384,6 +36246,12 @@ }, "Project information" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Projektinformation" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -32431,6 +36299,12 @@ "Protobufs" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protobufs" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -32539,6 +36413,12 @@ }, "Public Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offentlig nøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -32585,6 +36465,12 @@ }, "Public Key Encryption" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offentlig nøglekryptering" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32625,6 +36511,12 @@ }, "Public Key Mismatch" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offentlig nøgle uoverensstemmelse" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32665,6 +36557,12 @@ }, "PWD" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PWD" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32700,6 +36598,12 @@ "Question" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spørgsmål" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -32770,6 +36674,12 @@ }, "Radiation" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stråling" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32804,6 +36714,12 @@ }, "Radio Configuration" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radiokonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -32875,6 +36791,12 @@ "RAK Rotary Encoder" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "RAK Rotary Encoder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -32945,6 +36867,12 @@ }, "Range Test" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rækkeviddetest" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -33015,6 +36943,12 @@ }, "Range Test Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration for rækkeviddetest" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -33086,6 +37020,12 @@ "Range Test module config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interval Test-modulkonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -33156,6 +37096,12 @@ }, "Reboot" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genstart" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -33226,6 +37172,12 @@ }, "Reboot node?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genstart node?" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -33297,6 +37249,12 @@ "Rebroadcast any observed message, if it was on our private channel or from another mesh with the same lora params." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genudsend enhver observeret besked, hvis den var på vores private kanal eller fra et andet netværk med de samme lora-parametre." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -33325,6 +37283,12 @@ }, "Rebroadcast Mode" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genudsendelsestilstand" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33365,6 +37329,12 @@ }, "Receive data (rxd) GPIO pin" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtage data (rxd) GPIO-pin" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33400,6 +37370,12 @@ "Received a negative acknowledgment" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Modtog en negativ bekræftelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -33471,6 +37447,12 @@ "Received Ack" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtaget kvittering" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -33546,6 +37528,12 @@ "Recipient Ack" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtagerkvittering" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -33620,6 +37608,12 @@ }, "Recording route" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optager rute" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -33660,6 +37654,12 @@ }, "Refresh device metadata" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdater enhedsmetadata" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33716,6 +37716,12 @@ }, "Region" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -33763,6 +37769,12 @@ "Regional Duty Cycle Limit Reached" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Regional driftcyklusgrænse er nået" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -33849,6 +37861,12 @@ }, "Release Notes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udgivelsesnoter" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33881,11 +37899,15 @@ } } }, - "Reload Bundled Certificates" : { - - }, + "Reload Bundled Certificates" : {}, "Remote administration for: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjernadministration for: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33920,6 +37942,12 @@ }, "Remote Legacy Admin: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern Legacy Admin: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33954,6 +37982,12 @@ }, "Remote PKI Admin: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern-PKI-Admin: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33988,6 +38022,12 @@ }, "Remove" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34028,6 +38068,12 @@ }, "Remove from favorites" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern fra foretrukne" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34068,6 +38114,12 @@ }, "Remove from ignored" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern fra ignoreret" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34109,6 +38161,12 @@ "Repeater" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gentager" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34155,6 +38213,12 @@ }, "Replace Channels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erstat kanaler" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34189,6 +38253,12 @@ }, "Reply" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Svar" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34259,6 +38329,12 @@ }, "Request Legacy Admin: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmod om administrator (gammel): %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34293,6 +38369,12 @@ }, "Request PKI Admin: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmod om PKI Admin: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34328,6 +38410,12 @@ "Requested Canned Messages Module Messages for node: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmodet modulmeddelelser for færdiglavede meddelelser til node: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -34392,6 +38480,12 @@ }, "Requires that there be an accelerometer on your device." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kræver, at der er et accelerometer på din enhed." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34426,6 +38520,12 @@ }, "Reset App Settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nulstil appindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34472,6 +38572,12 @@ }, "Reset NodeDB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tøm node-database" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34510,11 +38616,15 @@ } } }, - "Reset to Default" : { - - }, + "Reset to Default" : {}, "Restart" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genstart" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34553,11 +38663,15 @@ } } }, - "Restart Server" : { - - }, + "Restart Server" : {}, "Restart to the node you are connected to" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genstart noden, du har forbindelse til" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34620,6 +38734,12 @@ }, "Resume" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genoptag" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34771,6 +38891,12 @@ }, "Review the app" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gennemgå appen" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34818,6 +38944,12 @@ "Right" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højre" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34888,6 +39020,12 @@ }, "Ringtone" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ringetone" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -34958,6 +39096,12 @@ }, "Ringtone Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ringetonekonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35022,6 +39166,12 @@ }, "Ringtone Transfer Language" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprog til overførsel af ringetoner" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35068,6 +39218,12 @@ }, "Ringtone Transfer Language(RTTTL) Ringtone String used by supported buzzers in external notifications." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ringtone Transfer Language (RTTTL) Ringtone String brugt af understøttede buzzere i eksterne meddelelser" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -35126,6 +39282,12 @@ }, "Role" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rolle" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35167,6 +39329,12 @@ "Role: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rolle: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35207,6 +39375,12 @@ }, "Roles" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Roller" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35247,6 +39421,12 @@ }, "Root Topic" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hovedemne" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35281,6 +39461,12 @@ }, "Rotary 1" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rotary 1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35321,6 +39507,12 @@ }, "Route Back: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Returrute: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35355,6 +39547,12 @@ }, "Route Lines" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruteliner" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35411,6 +39609,12 @@ }, "Route Recorder" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruteoptager" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35451,6 +39655,12 @@ }, "Route recording paused" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruteoptagelse sat på pause" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35491,6 +39701,12 @@ }, "Route: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rute: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35532,6 +39748,12 @@ "Router" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Router" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35579,6 +39801,12 @@ "Router Late" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Router forsinket" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35619,6 +39847,12 @@ }, "Routes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruter" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35660,6 +39894,12 @@ "Routing received for RequestID: %@ Ack Status: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruting modtaget for RequestID: %@ Kvitteringsstatus: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35730,6 +39970,12 @@ }, "RSSI %@ dBm" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %@ dBm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35764,6 +40010,12 @@ }, "RSSI %ddB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %ddB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35799,6 +40051,12 @@ "RSSI %llddB" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %llddB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35834,6 +40092,12 @@ "RTTTL Ringtone config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "RTTTL Ringtone-konfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -35905,6 +40169,12 @@ "Russia" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rusland" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -35933,6 +40203,12 @@ }, "RX Boosted Gain" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forstærket RX-forstærkning" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35968,6 +40244,12 @@ "Same as behavior as ALL but skips packet decoding and simply rebroadcasts them. Only available in Repeater role. Setting this on any other roles will result in ALL behavior." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samme som adfærd som ALL, men springer pakkedekodning over og genudsender dem blot. Kun tilgængelig i Repeater-rollen. At indstille dette på andre roller vil resultere i ALL-adfærd." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -35997,6 +40279,12 @@ "Satellite" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Satellit" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36056,6 +40344,12 @@ "Satellite Flyover" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Satellitoverflyvning" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36120,6 +40414,12 @@ }, "Sats" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sats" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36160,6 +40460,12 @@ }, "Sats Estimate %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sats anslå %lld" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36200,6 +40506,12 @@ }, "Sats in view: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Satellitter i sigte: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36240,6 +40552,12 @@ }, "Save" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36310,6 +40628,12 @@ }, "Save Channel Settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem kanalindstillinger" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36345,6 +40669,12 @@ "Save Config for %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem konfiguration for %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36415,6 +40745,12 @@ }, "Save User Config to %@?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem brugeropsætning på %@?" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36461,6 +40797,12 @@ }, "Saves a CSV with the range test message details, currently only available on ESP32 devices with a web server." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gemmer en CSV-fil med detaljer om intervaltestbeskeder, i øjeblikket kun tilgængelig på ESP32-enheder med en webserver" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36529,6 +40871,12 @@ }, "Screen on for" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Skærm tændt i " + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36563,6 +40911,12 @@ }, "Search" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Søg" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36603,6 +40957,12 @@ }, "Second" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sekund" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36637,6 +40997,12 @@ }, "Secondary" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sekundær" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36707,6 +41073,12 @@ }, "Secondary Admin Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sekundær administratortasten" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36751,11 +41123,15 @@ } } }, - "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." : {}, "Security" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sikkerhed" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36802,6 +41178,12 @@ }, "Security Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sikkerhedsindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36848,6 +41230,12 @@ }, "Security Config Settings require a firmware version 2.5+" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sikkerhedsindstillinger kræver mindst firmware-version 2.5" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36895,6 +41283,12 @@ "Select" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -36965,6 +41359,12 @@ }, "Select a channel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg en kanal" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -37005,6 +41405,12 @@ }, "Select a conversation" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg en samtale" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37039,6 +41445,12 @@ }, "Select a conversation type" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg en samtaletype" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37083,6 +41495,12 @@ }, "Select a node from the drop down to manage connected or remote devices." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg en node fra listen for at (fjern)administrere enheden." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -37111,6 +41529,12 @@ }, "Select a Trace Route" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vælg en rutesporing (Trace route)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37155,6 +41579,12 @@ }, "Select Channel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg kanal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37233,6 +41663,12 @@ }, "Send" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -37273,6 +41709,12 @@ }, "Send ${messageContent} to ${channelNumber}" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send ${messageContent} til ${channelNumber}" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -37313,6 +41755,12 @@ }, "Send ${messageContent} to ${nodeNumber}" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send Send ${messageContent} til ${nodeNumber}" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37347,6 +41795,12 @@ }, "Send a Direct Message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send en direkte besked" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -37363,6 +41817,12 @@ }, "Send a Group Message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send en gruppebesked" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -37403,6 +41863,12 @@ }, "Send a heartbeat to advertise the server's presence." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send et hjerteslag for at annoncere serverens tilstedeværelse." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37437,6 +41903,12 @@ }, "Send a message to a certain meshtastic channel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send en besked til én Meshtastic-kanal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37471,6 +41943,12 @@ }, "Send a message to a certain meshtastic node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send en besked til én Meshtastic-node" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -37487,6 +41965,12 @@ }, "Send a position on the primary channel when the user button is triple clicked." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send en position på den primære kanal, når brugerknappen trykkes tre gange." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37527,6 +42011,12 @@ }, "Send a shutdown to the node you are connected to" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send en sluk-kommando til enheden, du er tilkoblet." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -37567,6 +42057,12 @@ }, "Send a Waypoint" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send et viapunkt" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -37607,6 +42103,12 @@ }, "Send ASCII bell with alert message. Useful for triggering external notification on bell." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Send ASCII-klokke med advarselsbesked. Nyttig til at udløse ekstern notifikation ved bip." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37647,6 +42149,12 @@ }, "Send Bell" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Send ASCII-klokke" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -37693,6 +42201,12 @@ }, "Send Heartbeat" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send hjerteslag" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -37785,6 +42299,12 @@ }, "Send Reboot OTA" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send genstart OTA" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37826,6 +42346,12 @@ "Sender Interval" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afsenderinterval" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37867,6 +42393,12 @@ "Sensor" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensor" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -37913,6 +42445,12 @@ }, "Sensor options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensorindstillinger" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37948,6 +42486,12 @@ "Sensor Options" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensorindstillinger" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37977,6 +42521,12 @@ "Sent a Channel for: %@ Channel Index %d" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sendte en kanal for: %@ Kanal indeks %d" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -38042,6 +42592,12 @@ "Sent a LoRa.Config for: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sendte en LoRa.Config for: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38113,6 +42669,12 @@ "Sent a Position Packet from the Apple device GPS to node: %@@" : { "extractionState" : "manual", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geoposition (fra Apple-enheden) sendt til noden %@@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38184,6 +42746,12 @@ "Sent a Trace Route Request to node: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Sendte en rutesporing (trace route) til node: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38255,6 +42823,12 @@ "Sent a Waypoint Packet from: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viapunkt-pakke afsendt fra: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38326,6 +42900,12 @@ "Sent message %@ from %@ to %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sendte besked %@ fra %@ til %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38396,6 +42976,12 @@ }, "Sequence number" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sekvensnummer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38436,6 +43022,12 @@ }, "Sequence: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sekvens: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38476,6 +43068,12 @@ }, "Serial" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seriel" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -38540,6 +43138,12 @@ }, "Serial Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seriel-konfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38610,6 +43214,12 @@ }, "Serial Console" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seriel-konsol" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38650,6 +43260,12 @@ }, "Serial Console over the Stream API." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seriekonsol over Stream-API'en." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38691,6 +43307,12 @@ "Serial module config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seriemodulkonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38761,6 +43383,12 @@ }, "Series" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serier" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38795,6 +43423,12 @@ }, "Server" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38835,6 +43469,12 @@ }, "Server Address" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serveradresse" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -38873,11 +43513,15 @@ } } }, - "Server Certificate" : { - - }, + "Server Certificate" : {}, "Server Option" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serverindstilling" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38910,11 +43554,15 @@ } } }, - "Server Status" : { - - }, + "Server Status" : {}, "Set" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indstil" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38949,6 +43597,12 @@ }, "Set LoRa Region" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg LoRa-region" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39019,6 +43673,12 @@ }, "Set the GPIO pins for RXD and TXD." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indstil GPIO-bolerne for RXD og TXD." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39075,6 +43735,12 @@ }, "Sets the maximum number of hops, default is 3. Increasing hops also increases congestion and should be used carefully. O hop broadcast messages will not get ACKs." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indstiller det maksimale antal hop, standard er 3. At øge antallet af hop øger også belastningen og bør ske med forsigtighed. O hop-broadcast-beskeder vil ikke modtage ACKs." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39153,6 +43819,12 @@ }, "Settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39224,6 +43896,12 @@ "Seventy Two Hours" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tooghalvfjerds timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39344,6 +44022,12 @@ }, "Share QR Code" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Del QR-kode" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39414,6 +44098,12 @@ }, "Share QR Code & Link" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Del QR-kode og link" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39476,6 +44166,12 @@ }, "Shared Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fælles krypteringsnøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39516,6 +44212,12 @@ }, "Sharing Meshtastic Channels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deling af Meshtastic-kanaler" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39586,6 +44288,12 @@ }, "Short Name" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kort navn" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39633,6 +44341,12 @@ "Short Range - Fast" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kort Rækkevidde - Hurtig" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39668,6 +44382,12 @@ "Short Range - Slow" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kort rækkevidde - Langsom" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39703,6 +44423,12 @@ "Short Range - Turbo" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kort rækkevidde - Turbo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39753,6 +44479,12 @@ }, "Show alerts" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis alarmer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39793,6 +44525,12 @@ }, "Show Alerts" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis alarmer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39833,6 +44571,12 @@ }, "Show nodes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis noder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39873,6 +44617,12 @@ }, "Show on device screen" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis på enhedsskærm" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -39913,6 +44663,12 @@ }, "Show on the mesh map." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis på mesh-kort" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40004,6 +44760,12 @@ }, "Shut Down" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luk ned" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40044,6 +44806,12 @@ }, "Shut Down Node?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluk node?" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40084,6 +44852,12 @@ }, "Shutdown Node?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluk node?" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40124,6 +44898,12 @@ }, "Shutdown on Power Loss" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luk ned ved strømtab" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40188,6 +44968,12 @@ }, "Signal %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signal %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40223,6 +45009,12 @@ "Simple" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enkel" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40294,6 +45086,12 @@ "Singapore 923MHz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Singapore 923 MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40329,6 +45127,12 @@ "Six Hours" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seks timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40400,6 +45204,12 @@ "Skiing" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skiløb" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40446,6 +45256,12 @@ }, "Smart Position" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Smart Position" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40480,6 +45296,12 @@ }, "SNR" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40514,6 +45336,12 @@ }, "SNR %@ dB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@ dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40548,6 +45376,12 @@ }, "SNR %@dB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40582,6 +45416,12 @@ }, "Soil Moisture" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jordfugtighed" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40616,6 +45456,12 @@ }, "Soil Temp" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jordtemperatur" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40651,6 +45497,12 @@ "Specifies how long the monitored GPIO should output." : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angiver hvor længe den overvågede GPIO skal udlæse." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40679,6 +45531,12 @@ }, "Speed" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hastighed" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40719,6 +45577,12 @@ }, "Speed %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hastighed %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40759,6 +45623,12 @@ }, "Speed: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hastighed: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40827,6 +45697,12 @@ }, "Spread Factor" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spredningsfaktor" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40861,6 +45737,12 @@ }, "SSID" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSID" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -40932,6 +45814,12 @@ "Standard" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -40985,6 +45873,12 @@ "Standard Muted" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard dæmpet" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -41049,6 +45943,12 @@ }, "Start" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -41120,6 +46020,12 @@ "State Broadcast Interval" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "State Broadcast Interval" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41152,9 +46058,7 @@ } } }, - "Status" : { - - }, + "Status" : {}, "Stay Connected Anywhere" : { "localizations" : { "de" : { @@ -41179,6 +46083,12 @@ }, "Store & Forward" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem og videresend" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41219,6 +46129,12 @@ }, "Store & Forward Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurer Opbevaring og Videreformidling" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41260,6 +46176,12 @@ "Store & Forward module config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Store & Forward-modulkonfiguration modtaget: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -41324,6 +46246,12 @@ }, "Store and forward servers require an ESP32 device with PSRAM or Linux Native." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre- og videresendelsesservere kræver en ESP32-enhed med PSRAM eller Linux Native" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41358,6 +46286,12 @@ }, "Subscribed" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abonneret" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -41399,6 +46333,12 @@ "Subsystem" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Undersystem" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41461,6 +46401,12 @@ }, "Supported" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Understøttet" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -41501,6 +46447,12 @@ }, "Supported I2C Connected sensors will be detected automatically, sensors are BMP280, BME280, BME680, MCP9808, INA219, INA260, LPS22 and SHTC3." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Understøttede I2C Connected- sensorer bliver automatisk genkendt: BMP280, BME280, BME680, MCP9808, INA219, INA260, LPS22 and SHTC3." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41542,6 +46494,12 @@ "Table" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabel" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41577,6 +46535,12 @@ "Taiwan" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taiwan" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -41605,6 +46569,12 @@ }, "TAK" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAK" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -41649,12 +46619,16 @@ } } }, - "TAK Server" : { - - }, + "TAK Server" : {}, "TAK Tracker" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAK-sporer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -41701,6 +46675,12 @@ }, "Takes a Meshtastic channel URL and saves the channel settings." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tager en Meshtastic-kanal-URL og gemmer kanalindstillingerne" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41773,6 +46753,12 @@ }, "Tapback" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tapback" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -41843,6 +46829,12 @@ }, "Telemetry" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telemetri" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -41913,6 +46905,12 @@ }, "Telemetry Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telemetrikonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -41984,6 +46982,12 @@ "Telemetry module config received: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telemetrimodulkonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42055,6 +47059,12 @@ "Temp" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temp" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42095,6 +47105,12 @@ }, "Temperature" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperatur" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42135,6 +47151,12 @@ }, "Ten Minutes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ti minutter" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42206,6 +47228,12 @@ "Ten Seconds" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ti sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42276,6 +47304,12 @@ }, "Tertiary Admin Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tertiær admin-nøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42323,6 +47357,12 @@ "Text Message" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "SMS-besked" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42394,6 +47434,12 @@ "TFT Full Color Displays" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TFT-farvedisplays" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42429,6 +47475,12 @@ "Thailand" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thailand" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42463,6 +47515,12 @@ }, "The amount of time to wait before we consider your packet as done." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den tid vi venter, før vi anser din pakke som færdig." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42497,6 +47555,12 @@ }, "The compass heading on the screen outside of the circle will always point north." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kompasretningen på skærmen uden for cirklen vil altid pege mod nord." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42531,6 +47595,12 @@ }, "The dew point is %@ right now." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dugpunktet er %@ lige nu." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42571,6 +47641,12 @@ }, "The fastest that position updates will be sent if the minimum distance has been satisfied" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den hurtigste hastighed, som positionsopdateringer vil blive sendt med, hvis afstanden er over minimumsafstanden." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42605,6 +47681,12 @@ }, "The last 4 of the device MAC address will be appended to the short name to set the device's BLE Name. Short name can be up to 4 bytes long." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "De sidste 4 cifre i enhedens MAC-adresse vil blive tilføjet til det korte navn for at angive enhedens BLE-navn. Kort navnet kan være op til 4 bytes langt." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42651,6 +47733,12 @@ }, "The maximum interval that can elapse without a node broadcasting a position" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det maksimale tidsrum uden at noden sender sin position" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42685,6 +47773,12 @@ }, "The Meshtastic Apple apps support firmware version %@ and above." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic Apple-apps understøtter firmwareversion %@ og derover." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42725,6 +47819,12 @@ }, "The minimum distance change in meters to be considered for a smart position broadcast." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den mindste afstandsændring i meter, der skal overvejes for en smart positionsudsendelse." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42766,6 +47866,12 @@ "The packet is too large" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pakken er for stor" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42836,6 +47942,12 @@ }, "The primary public key authorized to send admin messages to this node." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den primære offentlige nøgle, der er godkendt til at sende administratorbeskeder til denne node." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42882,6 +47994,12 @@ }, "The region where you will be using your radios." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det område, hvor du vil bruge dine radioer." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -42928,6 +48046,12 @@ }, "The root topic to use for MQTT." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rodemnet, der skal bruges til MQTT" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42985,6 +48109,12 @@ }, "The secondary public key authorized to send admin messages to this node." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den sekundære offentlige nøgle, der er autoriseret til at sende admin-beskeder til denne node." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -43031,6 +48161,12 @@ }, "The state of the LED (on/off)" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilstanden for LED'en (tændt/slukket)" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -43071,6 +48207,12 @@ }, "The tertiary public key authorized to send admin messages to this node." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den tertiære offentlige nøgle autoriseret til at sende admin-beskeder til denne node." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -43117,6 +48259,12 @@ }, "The URL for the channel settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL'en for kanalindstillingerne" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43224,6 +48372,12 @@ "These settings will %@ channels. The current LoRa Config will be replaced, if there are substantial changes to the LoRa config the device will reboot" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disse indstillinger vil %@ kanaler. Den nuværende LoRa-konfiguration vil blive erstattet, hvis der er betydelige ændringer i LoRa-konfigurationen, vil enheden genstarte" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43252,6 +48406,12 @@ }, "Thirty Minutes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tredive minutter" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -43323,6 +48483,12 @@ "Thirty Seconds" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tredive sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -43394,6 +48560,12 @@ "Thirty Six Hours" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seksogtredive timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -43464,6 +48636,12 @@ }, "This conversation will be deleted." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denne samtale vil blive slettet." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43498,6 +48676,12 @@ }, "This could take a while, response will appear in the trace route log for the node it was sent to." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Dette kan tage et stykke tid. Svaret vil vises i rutesporingsloggen (trace route) for den node, det blev sendt til." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43532,6 +48716,12 @@ }, "This device will send out range test messages on the selected interval." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denne enhed vil sende rækkeviddetestbeskeder ud med det valgte interval." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43572,6 +48762,12 @@ }, "This message was likely not delivered." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denne besked blev sandsynligvis ikke leveret." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -43612,6 +48808,12 @@ }, "This node does not support any configurable modules." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noden understøtter ingen konfigurerbare moduler." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43646,6 +48848,12 @@ }, "This will disable fixed position and remove the currently set position." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dette vil deaktivere fast position og fjerne den aktuelt indstillede position" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43680,6 +48888,12 @@ }, "This will send a current position from your phone and enable fixed position." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dette vil sende en nuværende position fra din telefon og aktivere fast position" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43721,6 +48935,12 @@ "Three Hours" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tre timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -43792,6 +49012,12 @@ "Three Seconds" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tre sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -43863,6 +49089,12 @@ "Thumbs Down" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tommel ned" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -43934,6 +49166,12 @@ "Thumbs Up" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tommel op" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -44004,6 +49242,12 @@ }, "Time" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tid" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -44044,6 +49288,12 @@ }, "Time Stamp" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsstempel" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -44084,6 +49334,12 @@ }, "Time Zone" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidszone" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -44124,6 +49380,12 @@ }, "Time zone for dates on the device screen and log." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidszone for datoer på enhedens skærm og log." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -44164,6 +49426,12 @@ }, "Timeout" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeout" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -44234,6 +49502,12 @@ }, "Timestamp" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsstempel" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -44318,11 +49592,15 @@ } } }, - "TLS Certificates" : { - - }, + "TLS Certificates" : {}, "TLS Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TLS-kryptering aktiveret" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44363,6 +49641,12 @@ }, "To comply with privacy laws like CCPA and GDPR, we avoid sharing exact location data. Instead, we use anonymized or approximate (imprecise) location information to protect your privacy." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "For at overholde privatlivslove som CCPA og GDPR undgår vi at dele præcise lokaliseringsdata. I stedet bruger vi anonymiseret eller omtrentlig (upræcis) lokaliseringsinformation for at beskytte dit privatliv." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -44408,6 +49692,12 @@ "Topic: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emne: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44442,6 +49732,12 @@ }, "Total" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -44482,6 +49778,12 @@ }, "Total PAX" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sum af personer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44522,6 +49824,12 @@ }, "Trace Route" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporing (trace route)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44584,6 +49892,12 @@ }, "Trace Route Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporingslog" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44619,6 +49933,12 @@ "Trace Route request returned: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporingen returnerede: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -44689,6 +50009,12 @@ }, "Trace Route Sent" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporing igangsat" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44723,6 +50049,12 @@ }, "Trace route sent to %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporing (trace route) sendt til %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44757,6 +50089,12 @@ }, "Trace route to %@ was not sent." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporing %@ blev ikke igangsat." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44791,6 +50129,12 @@ }, "Trace Route was rate limited. You can send a trace route a maximum of once every thirty seconds." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporing (trace route) var begrænset af rate. Du kan højst sende en rutesporing én gang hvert halve minut." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44848,6 +50192,12 @@ "Tracker" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sporingsprogram" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -44882,6 +50232,12 @@ }, "Traffic" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trafik" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -44922,6 +50278,12 @@ }, "Transmit data (txd) GPIO pin" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmitter data (txd) GPIO-pin" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44956,6 +50318,12 @@ }, "Transmit Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overførsel aktiveret" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44996,6 +50364,12 @@ }, "Treat double tap on supported accelerometers as a user button press." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behandl dobbelttryk på understøttede accelerometre som et brugertastetryk." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45036,6 +50410,12 @@ }, "TriggerType" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TriggerType" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45070,6 +50450,12 @@ }, "Triple Click Ad Hoc Ping" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Triple Klik Ad Hoc Ping" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45104,6 +50490,12 @@ }, "Try Again" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prøv igen" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -45145,6 +50537,12 @@ "Twelve Hours" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tolv timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -45216,6 +50614,12 @@ "Twenty Four Hours" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fireogtyve timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -45286,6 +50690,12 @@ }, "Two Hours" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "To timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -45357,6 +50767,12 @@ "Two Minutes" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "To minutter" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -45428,6 +50844,12 @@ "Two Seconds" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "To sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -45498,6 +50920,12 @@ }, "UDP Broadcast" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "UDP-udsendelse" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45533,6 +50961,12 @@ "Ukraine 433MHz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukraine 433 MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45568,6 +51002,12 @@ "Ukraine 868MHz" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukraine 868 MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45602,6 +51042,12 @@ }, "Un-Favorite" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern foretrukken" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45637,6 +51083,12 @@ "Unhealthy" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usund" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45672,6 +51124,12 @@ "Unhealthy for Sensitive Groups" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usundt for følsomme grupper" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45707,6 +51165,12 @@ "United States" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "USA" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45741,6 +51205,12 @@ }, "Units displayed on the device screen" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enheder vist på enhedens skærm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45810,6 +51280,12 @@ }, "Unknown" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukendt" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -45874,6 +51350,12 @@ }, "Unknown Age" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukendt alder" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -45994,6 +51476,12 @@ }, "Unset" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern indstilling" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -46064,6 +51552,12 @@ }, "Unsupported" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke understøttet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46099,6 +51593,12 @@ "Up" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Op" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -46169,6 +51669,12 @@ }, "Up Down 1" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Op Ned 1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46209,6 +51715,12 @@ }, "Update Interval" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdateringsinterval" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46243,6 +51755,12 @@ }, "Update Your Firmware" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdater din firmware" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -46313,6 +51831,12 @@ }, "Updated Node Stats Data." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdaterede statistikker for noden." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46347,6 +51871,12 @@ }, "Updated: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdateret: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -46387,6 +51917,12 @@ }, "Uplink Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uplink aktiveret" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46697,6 +52233,12 @@ }, "Uptime" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oppetid" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46759,6 +52301,12 @@ }, "Use a PWM output (like the RAK Buzzer) for tunes instead of an on/off output. This will ignore the output, output duration and active settings and use the device config buzzer GPIO option instead." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brug en PWM-udgang (som RAK Buzzer) til melodier i stedet for en tænd/sluk-udgang. Dette vil ignorere udgang, udgangsvarighed og aktive indstillinger og bruge enhedens konfigurationsbuzzer-GPIO-option i stedet." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46799,6 +52347,12 @@ }, "Use I2S As Buzzer" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brug I2S som buzzer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46861,6 +52415,12 @@ }, "Use Preset" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brug forudindstilling" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -46907,6 +52467,12 @@ }, "Use PWM Buzzer" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brug PWM-summer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46969,6 +52535,12 @@ }, "Used to create a shared key with a remote device." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruges til at oprette en fælles krypteringsnøgle med en anden enhed." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47031,6 +52603,12 @@ }, "User" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -47101,6 +52679,12 @@ }, "User Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brugerindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -47147,6 +52731,12 @@ }, "User Details" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brugerdetaljer" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -47193,6 +52783,12 @@ }, "User Id" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruger-ID" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47310,6 +52906,12 @@ }, "Username" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brugernavn" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -47380,6 +52982,12 @@ }, "Uses pullup resistor" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruger pullup-modstand" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47414,6 +53022,12 @@ }, "Utilizes the network connection on your phone to connect to MQTT." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udnytter netværksforbindelsen på din telefon til at oprette forbindelse til MQTT" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47454,6 +53068,12 @@ }, "Vehicle heading" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Køretøjets retning" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -47494,6 +53114,12 @@ }, "Vehicle speed" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Køretøjets hastighed" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -47550,6 +53176,12 @@ }, "Version %@ includes substantial network optimizations and extensive changes to devices and client apps. Only nodes version %@ and above are supported." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %1$@ inkluderer betydelige netværksoptimeringer og omfattende ændringer til enheder og klientapps. Kun noder version %2$@ og nyere understøttes." + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -47596,6 +53228,12 @@ }, "Version: %@ (%@)" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version: %1$@ (%2$@)" + } + }, "en" : { "stringUnit" : { "state" : "new", @@ -47631,6 +53269,12 @@ "Version: %1$@ (%2$@)" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version: %1$@ (%2$@)" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -47678,6 +53322,12 @@ "Very Unhealthy" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meget usund" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47712,6 +53362,12 @@ }, "Via Lora" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via Lora" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -47752,6 +53408,12 @@ }, "Via Mqtt" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via Mqtt" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -47798,6 +53460,12 @@ }, "Voltage" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spænding" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -47868,6 +53536,12 @@ }, "Volts %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volt %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47902,6 +53576,12 @@ }, "Waiting" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Venter" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -47972,6 +53652,12 @@ }, "Waiting to be acknowledged. . ." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afventer bekræftelse…" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48006,6 +53692,12 @@ }, "Wake Screen on tap or motion" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Væk skærmen ved tryk eller bevægelse" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48041,6 +53733,12 @@ "Walking" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gåtur" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -48088,6 +53786,12 @@ "Wave" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bølge" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -48156,6 +53860,12 @@ }, "Waypoint Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viapunkt-indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -48197,6 +53907,12 @@ "Waypoint Packet received from node: %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viapunkt-pakke modtaget fra node: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -48277,6 +53993,12 @@ }, "Weather Conditions" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vejrforhold" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -48323,6 +54045,12 @@ }, "Web Flasher" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Web Flasher" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48357,6 +54085,12 @@ }, "Website" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Websted" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48397,6 +54131,12 @@ }, "Weight" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vægt" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48453,6 +54193,12 @@ }, "What does the lock mean?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvad betyder låsen?" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -48499,6 +54245,12 @@ }, "What is Meshtastic?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvad er Meshtastic?" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -48545,6 +54297,12 @@ }, "What licensed operator mode does:\n* Sets the node name to your call sign \n* Broadcasts node info every 10 minutes \n* Overrides frequency, dutycycle and tx power \n* Disables encryption" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Hvad licenseret operatørtilstand gør:\n* Indstiller nodenavnet til dit kaldesignal \n* Udsender nodeinfo hvert 10. minut \n* Tilsidesætter frekvens, arbejdstidscyklus og sendeeffekt \n* Deaktiverer kryptering" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48585,6 +54343,12 @@ }, "When enabled the PAX Counter module counts the number of people passing by using WiFi and Bluetooth. Both WiFI and Bluetooth must be disabled for PAX counter to work." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når det er aktiveret, tæller PAX Counter modulet antallet af personer, der passerer ved at bruge WiFi og Bluetooth. Både WiFi og Bluetooth skal være deaktiveret for at PAX counter kan fungere." + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -48637,6 +54401,12 @@ }, "When using in GPIO mode, keep the output on for this long. " : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når du bruger i GPIO-tilstand, hold outputten tændt i så lang tid." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48677,6 +54447,12 @@ }, "Whether or not use INPUT_PULLUP mode for GPIO pin. Only applicable if the board uses pull-up resistors on the pin" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Om INPUT_PULLUP-tilstand skal bruges til GPIO-pin. Kun relevant hvis kortet bruger pull-up modstande på pinnen" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48711,6 +54487,12 @@ }, "WiFi" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "WiFi" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48751,6 +54533,12 @@ }, "WiFi Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "WiFi-indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -48791,6 +54579,12 @@ }, "Will sleep everything as much as possible, for the tracker and sensor role this will also include the lora radio. Don't use this setting if you want to use your device with the phone apps or are using a device without a user button." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vil sove alt så meget som muligt, for tracker- og sensorrollen vil dette også omfatte lora-radioen. Brug ikke denne indstilling, hvis du vil bruge din enhed med telefonapps eller bruger en enhed uden en brugerknap." + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -48849,6 +54643,12 @@ }, "Wind" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vind" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48884,6 +54684,12 @@ "Wind Direction" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vindretning" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -48925,6 +54731,12 @@ "Wind Speed" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vindhastighed" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -48966,6 +54778,12 @@ "Within %@" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inden for %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -49012,6 +54830,12 @@ }, "x" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "x" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49046,6 +54870,12 @@ }, "X: %@, Y: %d" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$d" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -49093,6 +54923,12 @@ }, "X: %@, Y: %f" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$f" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -49140,6 +54976,12 @@ }, "X: %@, Y: %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$lld" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -49187,6 +55029,12 @@ }, "y" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "j" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49239,6 +55087,12 @@ }, "Yesterday" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I går" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -49279,6 +55133,12 @@ }, "You can also update your Meshtastic device over bluetooth using the Nordic DFU app." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du kan også opdatere din Meshtastic-enhed over bluetooth ved hjælp af Nordic DFU-appen." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49319,6 +55179,12 @@ }, "You can send and receive channel (group chats) and direct messages. From any message you can long press to see available actions like copy, reply, tapback and delete as well as delivery details." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du kan sende og modtage kanal- (gruppechat) og direkte beskeder. Fra enhver besked kan du langtrykke for at se tilgængelige handlinger som kopier, svar, tapback og slet samt leveringsdetaljer." + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -49389,6 +55255,12 @@ }, "Your current location will be set as the fixed position and broadcast over the mesh on the position interval." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din nuværende placering vil blive sat som den faste position og udsendt over nettet på positionsintervallet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49429,6 +55301,12 @@ }, "Your Firmware is up to date" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedens firmware er opdateret" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -49475,6 +55353,12 @@ }, "Your MQTT Server must support TLS." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din MQTT-server skal understøtte TLS" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49509,6 +55393,12 @@ }, "Your node will periodically send an unencrypted map report packet to the configured MQTT server, this includes id, short and long name, approximate location, hardware model, role, firmware version, LoRa region, modem preset and primary channel name." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din node vil med jævne mellemrum sende en ukrypteret kortrapportpakke til den konfigurerede MQTT-server, dette inkluderer id, kort og langt navn, omtrentlig placering, hardwaremodel, rolle, firmwareversion, LoRa-region, modemindstilling og primærkanalnavn." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -49537,6 +55427,12 @@ }, "Your node’s operating frequency is calculated based on the region, modem preset, and this field. When 0, the slot is automatically calculated based on the primary channel name." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din nodes driftsfrekvens beregnes baseret på regionen, modemforindstillingen og dette felt. Når det er 0, beregnes slot automatisk baseret på det primære kanals navn." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49571,6 +55467,12 @@ }, "Your position has been sent with a request for a response with their position. You will receive a notification when a position is returned." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din position er blevet sendt med en anmodning om svar med deres position. Du vil modtage en besked, når en position er returneret." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49621,6 +55523,12 @@ }, "Your region has a %lld%% duty cycle. MQTT is not advised when you are duty cycle restricted, the extra traffic will quickly overwhelm your LoRa mesh." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Din region har en %lld%% driftcyklus. MQTT anbefales ikke, når du er driftcyklusbegrænset, den ekstra trafik vil hurtigt overvælde dit LoRa-mesh." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49661,6 +55569,12 @@ }, "Your region has a %lld%% hourly duty cycle, your radio will stop sending packets when it reaches the hourly limit." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Din region har en %lld%% timebaseret driftscyklus, din radio vil stoppe med at sende pakker, når det når grænsen pr. time." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49695,6 +55609,12 @@ }, "Your route file must have both Latitude and Longitude columns and headers." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din rute-fil skal have både breddegrad og længdegrad kolonner og overskrifter" + } + }, "it" : { "stringUnit" : { "state" : "translated", From 256a1593cc36493d32500573b9126c16f3eba266 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:55:59 -0700 Subject: [PATCH 08/20] Migrate test project to Swift Testing and add connect view and router tests (#1643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- Meshtastic.xcodeproj/project.pbxproj | 4 + MeshtasticTests/ConnectViewTests.swift | 493 +++++++++++++++++++++++++ MeshtasticTests/RouterTests.swift | 314 ++++++++++++---- 3 files changed, 730 insertions(+), 81 deletions(-) create mode 100644 MeshtasticTests/ConnectViewTests.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 4c264fbb..e3504190 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 */; }; @@ -411,6 +412,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 = ""; }; @@ -900,6 +902,7 @@ 25F5D5C82C4375A8008036E3 /* MeshtasticTests */ = { isa = PBXGroup; children = ( + AA00010022E2730EC0060000 /* ConnectViewTests.swift */, 25F5D5D02C4375DF008036E3 /* RouterTests.swift */, ); path = MeshtasticTests; @@ -1657,6 +1660,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */, 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/MeshtasticTests/ConnectViewTests.swift b/MeshtasticTests/ConnectViewTests.swift new file mode 100644 index 00000000..cbbcd331 --- /dev/null +++ b/MeshtasticTests/ConnectViewTests.swift @@ -0,0 +1,493 @@ +import Foundation +import SwiftUI +import Testing + +@testable import Meshtastic + +// MARK: - Device Tests + +@Suite("Device") +struct DeviceTests { + + static let testUUID = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")! + + @Test func creation() { + let device = Device( + id: DeviceTests.testUUID, + name: "Test Radio", + transportType: .ble, + identifier: "BLE-001" + ) + #expect(device.id == DeviceTests.testUUID) + #expect(device.name == "Test Radio") + #expect(device.transportType == .ble) + #expect(device.identifier == "BLE-001") + #expect(device.connectionState == .disconnected) + #expect(device.rssi == nil) + #expect(device.num == nil) + #expect(device.wasRestored == false) + #expect(device.isManualConnection == false) + } + + @Test func creationWithAllProperties() { + let device = Device( + id: DeviceTests.testUUID, + name: "Full Radio", + transportType: .tcp, + identifier: "192.168.1.1:4403", + connectionState: .connected, + rssi: -60, + num: 123456, + wasRestored: true, + isManualConnection: true + ) + #expect(device.connectionState == .connected) + #expect(device.rssi == -60) + #expect(device.num == 123456) + #expect(device.wasRestored == true) + #expect(device.isManualConnection == true) + } + + @Test(arguments: [ + (-50, BLESignalStrength.strong), + (-64, BLESignalStrength.strong), + (-65, BLESignalStrength.normal), + (-80, BLESignalStrength.normal), + (-84, BLESignalStrength.normal), + (-85, BLESignalStrength.weak), + (-100, BLESignalStrength.weak), + ]) + func signalStrength(rssi: Int, expected: BLESignalStrength) { + let device = Device( + id: DeviceTests.testUUID, + name: "Radio", + transportType: .ble, + identifier: "BLE-001", + rssi: rssi + ) + #expect(device.getSignalStrength() == expected) + } + + @Test func signalStrengthNilWhenNoRSSI() { + let device = Device( + id: DeviceTests.testUUID, + name: "Radio", + transportType: .ble, + identifier: "BLE-001" + ) + #expect(device.getSignalStrength() == nil) + } + + @Test func rssiStringWithValue() { + var device = Device( + id: DeviceTests.testUUID, + name: "Radio", + transportType: .ble, + identifier: "BLE-001", + rssi: -72 + ) + #expect(device.rssiString == "-72 dBm") + + device.rssi = -100 + #expect(device.rssiString == "-100 dBm") + } + + @Test func rssiStringWithoutValue() { + let device = Device( + id: DeviceTests.testUUID, + name: "Radio", + transportType: .ble, + identifier: "BLE-001" + ) + #expect(device.rssiString == "n/a") + } + + @Test func descriptionWithBothNames() { + var device = Device( + id: DeviceTests.testUUID, + name: "Radio", + transportType: .ble, + identifier: "BLE-001" + ) + device.shortName = "TST" + device.longName = "Test Node" + #expect(device.description == "Test Node (TST)") + } + + @Test func descriptionWithShortNameOnly() { + var device = Device( + id: DeviceTests.testUUID, + name: "Radio", + transportType: .ble, + identifier: "BLE-001" + ) + device.shortName = "TST" + #expect(device.description == "TST") + } + + @Test func descriptionWithLongNameOnly() { + var device = Device( + id: DeviceTests.testUUID, + name: "Radio", + transportType: .ble, + identifier: "BLE-001" + ) + device.longName = "Test Node" + #expect(device.description == "Test Node") + } + + @Test func descriptionWithNoNames() { + let device = Device( + id: DeviceTests.testUUID, + name: "Radio", + transportType: .ble, + identifier: "BLE-001" + ) + #expect(device.description == "Device(id: \(DeviceTests.testUUID))") + } + + @Test func hashEquality() { + let device1 = Device( + id: DeviceTests.testUUID, + name: "Radio", + transportType: .ble, + identifier: "BLE-001" + ) + let device2 = Device( + id: DeviceTests.testUUID, + name: "Radio", + transportType: .ble, + identifier: "BLE-001" + ) + #expect(device1 == device2) + #expect(device1.hashValue == device2.hashValue) + } + + @Test func codableRoundTrip() throws { + var device = Device( + id: DeviceTests.testUUID, + name: "Radio", + transportType: .ble, + identifier: "BLE-001", + connectionState: .connected, + rssi: -70, + num: 99 + ) + device.shortName = "RDO" + device.longName = "My Radio" + device.firmwareVersion = "2.5.0" + + let data = try JSONEncoder().encode(device) + let decoded = try JSONDecoder().decode(Device.self, from: data) + + #expect(decoded.id == device.id) + #expect(decoded.name == device.name) + #expect(decoded.transportType == device.transportType) + #expect(decoded.identifier == device.identifier) + #expect(decoded.connectionState == device.connectionState) + #expect(decoded.rssi == device.rssi) + #expect(decoded.num == device.num) + #expect(decoded.shortName == device.shortName) + #expect(decoded.longName == device.longName) + #expect(decoded.firmwareVersion == device.firmwareVersion) + } +} + +// MARK: - TransportType Tests + +@Suite("TransportType") +struct TransportTypeTests { + + @Test func allCases() { + let cases = TransportType.allCases + #expect(cases.count == 3) + #expect(cases.contains(.ble)) + #expect(cases.contains(.tcp)) + #expect(cases.contains(.serial)) + } + + @Test(arguments: [ + (TransportType.ble, "BLE"), + (TransportType.tcp, "TCP"), + (TransportType.serial, "Serial"), + ]) + func rawValues(type: TransportType, expected: String) { + #expect(type.rawValue == expected) + } + + @Test func initFromRawValue() { + #expect(TransportType(rawValue: "BLE") == .ble) + #expect(TransportType(rawValue: "TCP") == .tcp) + #expect(TransportType(rawValue: "Serial") == .serial) + #expect(TransportType(rawValue: "invalid") == nil) + } + + @Test func codableRoundTrip() throws { + for type in TransportType.allCases { + let data = try JSONEncoder().encode(type) + let decoded = try JSONDecoder().decode(TransportType.self, from: data) + #expect(decoded == type) + } + } +} + +// MARK: - ConnectionState Tests + +@Suite("ConnectionState") +struct ConnectionStateTests { + + @Test func equality() { + #expect(ConnectionState.disconnected == .disconnected) + #expect(ConnectionState.connecting == .connecting) + #expect(ConnectionState.connected == .connected) + #expect(ConnectionState.disconnected != .connected) + #expect(ConnectionState.connecting != .disconnected) + } + + @Test func codableRoundTrip() throws { + let states: [ConnectionState] = [.disconnected, .connecting, .connected] + for state in states { + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(ConnectionState.self, from: data) + #expect(decoded == state) + } + } +} + +// MARK: - BLESignalStrength Tests + +@Suite("BLESignalStrength") +struct BLESignalStrengthTests { + + @Test func rawValues() { + #expect(BLESignalStrength.weak.rawValue == 0) + #expect(BLESignalStrength.normal.rawValue == 1) + #expect(BLESignalStrength.strong.rawValue == 2) + } + + @Test func initFromRawValue() { + #expect(BLESignalStrength(rawValue: 0) == .weak) + #expect(BLESignalStrength(rawValue: 1) == .normal) + #expect(BLESignalStrength(rawValue: 2) == .strong) + #expect(BLESignalStrength(rawValue: 3) == nil) + } +} + +// MARK: - TransportStatus Tests + +@Suite("TransportStatus") +struct TransportStatusTests { + + @Test func equality() { + #expect(TransportStatus.uninitialized == .uninitialized) + #expect(TransportStatus.ready == .ready) + #expect(TransportStatus.discovering == .discovering) + #expect(TransportStatus.error("test") == .error("test")) + #expect(TransportStatus.error("a") != .error("b")) + #expect(TransportStatus.ready != .discovering) + } +} + +// MARK: - NavigationState Tests + +@Suite("NavigationState") +struct NavigationStateTests { + + @Test func defaultState() { + let state = NavigationState() + #expect(state.selectedTab == .connect) + #expect(state.messages == nil) + #expect(state.nodeListSelectedNodeNum == nil) + #expect(state.map == nil) + #expect(state.settings == nil) + } + + @Test(arguments: [ + NavigationState.Tab.messages, + NavigationState.Tab.connect, + NavigationState.Tab.nodes, + NavigationState.Tab.map, + NavigationState.Tab.settings, + ]) + func tabRawValues(tab: NavigationState.Tab) { + #expect(NavigationState.Tab(rawValue: tab.rawValue) == tab) + } + + @Test func messagesNavigationState() { + let channels = MessagesNavigationState.channels(channelId: 1, messageId: 100) + let directMessages = MessagesNavigationState.directMessages(userNum: 42, messageId: 200) + + let state1 = NavigationState(selectedTab: .messages, messages: channels) + let state2 = NavigationState(selectedTab: .messages, messages: directMessages) + + #expect(state1 != state2) + #expect(state1.messages != nil) + #expect(state2.messages != nil) + } + + @Test func mapNavigationState() { + let selectedNode = MapNavigationState.selectedNode(12345) + let waypoint = MapNavigationState.waypoint(67890) + + #expect(selectedNode != waypoint) + #expect(MapNavigationState.selectedNode(12345) == selectedNode) + } + + @Test func settingsNavigationState() { + #expect(SettingsNavigationState(rawValue: "about") == .about) + #expect(SettingsNavigationState(rawValue: "appSettings") == .appSettings) + #expect(SettingsNavigationState(rawValue: "lora") == .lora) + #expect(SettingsNavigationState(rawValue: "mqtt") == .mqtt) + #expect(SettingsNavigationState(rawValue: "nonexistent") == nil) + } + + @Test func hashable() { + let state1 = NavigationState(selectedTab: .connect) + let state2 = NavigationState(selectedTab: .connect) + let state3 = NavigationState(selectedTab: .messages) + + #expect(state1 == state2) + #expect(state1 != state3) + #expect(state1.hashValue == state2.hashValue) + } +} + +// MARK: - InvalidVersion View Tests + +@Suite("InvalidVersion") +struct InvalidVersionTests { + + @Test func viewCreation() { + let view = InvalidVersion(minimumVersion: "2.5.0", version: "2.3.0") + #expect(view.minimumVersion == "2.5.0") + #expect(view.version == "2.3.0") + } + + @Test func viewCreationWithEmptyVersions() { + let view = InvalidVersion() + #expect(view.minimumVersion == "") + #expect(view.version == "") + } +} + +// MARK: - ConnectedDevice View Tests + +@Suite("ConnectedDevice") +struct ConnectedDeviceTests { + + @Test func connectedState() { + let view = ConnectedDevice(deviceConnected: true, name: "TEST") + #expect(view.deviceConnected == true) + #expect(view.name == "TEST") + #expect(view.mqttProxyConnected == false) + #expect(view.showActivityLights == true) + } + + @Test func disconnectedState() { + let view = ConnectedDevice(deviceConnected: false, name: "?") + #expect(view.deviceConnected == false) + #expect(view.name == "?") + } + + @Test func withMQTTOptions() { + let view = ConnectedDevice( + deviceConnected: true, + name: "MQTT", + mqttProxyConnected: true, + mqttUplinkEnabled: true, + mqttDownlinkEnabled: true, + mqttTopic: "msh/US/2/e/#" + ) + #expect(view.mqttProxyConnected == true) + #expect(view.mqttUplinkEnabled == true) + #expect(view.mqttDownlinkEnabled == true) + #expect(view.mqttTopic == "msh/US/2/e/#") + } + + @Test func phoneOnlyMode() { + let view = ConnectedDevice( + deviceConnected: true, + name: "PHON", + phoneOnly: true, + showActivityLights: false + ) + #expect(view.phoneOnly == true) + #expect(view.showActivityLights == false) + } +} + +// MARK: - CircleText View Tests + +@Suite("CircleText") +struct CircleTextTests { + + @Test func defaultCircleSize() { + let view = CircleText(text: "AB", color: .blue) + #expect(view.text == "AB") + #expect(view.circleSize == 45) + } + + @Test func customCircleSize() { + let view = CircleText(text: "XY", color: .red, circleSize: 90) + #expect(view.text == "XY") + #expect(view.circleSize == 90) + } + + @Test func emojiText() { + let view = CircleText(text: "😝", color: .orange, circleSize: 80) + #expect(view.text == "😝") + #expect(view.circleSize == 80) + } +} + +// MARK: - BatteryCompact View Tests + +@Suite("BatteryCompact") +struct BatteryCompactTests { + + @Test func creationWithLevel() { + let view = BatteryCompact(batteryLevel: 75, font: .caption, iconFont: .callout, color: .accentColor) + #expect(view.batteryLevel == 75) + } + + @Test func creationWithNilLevel() { + let view = BatteryCompact(batteryLevel: nil, font: .caption, iconFont: .callout, color: .accentColor) + #expect(view.batteryLevel == nil) + } + + @Test func pluggedInLevel() { + let view = BatteryCompact(batteryLevel: 101, font: .caption, iconFont: .callout, color: .accentColor) + #expect(view.batteryLevel! > 100) + } + + @Test func chargingLevel() { + let view = BatteryCompact(batteryLevel: 100, font: .caption, iconFont: .callout, color: .accentColor) + #expect(view.batteryLevel == 100) + } +} + +// MARK: - SignalStrengthIndicator View Tests + +@Suite("SignalStrengthIndicator") +struct SignalStrengthIndicatorTests { + + @Test func defaultDimensions() { + let view = SignalStrengthIndicator(signalStrength: .strong) + #expect(view.signalStrength == .strong) + #expect(view.width == 8) + #expect(view.height == 40) + } + + @Test func customDimensions() { + let view = SignalStrengthIndicator(signalStrength: .weak, width: 5, height: 20) + #expect(view.signalStrength == .weak) + #expect(view.width == 5) + #expect(view.height == 20) + } + + @Test(arguments: [BLESignalStrength.weak, .normal, .strong]) + func allStrengthLevels(strength: BLESignalStrength) { + let view = SignalStrengthIndicator(signalStrength: strength) + #expect(view.signalStrength == strength) + } +} diff --git a/MeshtasticTests/RouterTests.swift b/MeshtasticTests/RouterTests.swift index 96bd70af..1175dc59 100644 --- a/MeshtasticTests/RouterTests.swift +++ b/MeshtasticTests/RouterTests.swift @@ -1,148 +1,300 @@ import Foundation -import XCTest +import Testing @testable import Meshtastic -final class RouterTests: XCTestCase { +@Suite("Router") +struct RouterTests { - func testInitialState() async throws { + // MARK: - Initialization + + @Test func defaultInitialState() async { let router = await Router() + let state = await router.navigationState + #expect(state.selectedTab == .connect) + #expect(state.messages == nil) + #expect(state.nodeListSelectedNodeNum == nil) + #expect(state.map == nil) + #expect(state.settings == nil) + } + + @Test func customInitialState() async { + let custom = NavigationState(selectedTab: .map, map: .waypoint(42)) + let router = await Router(navigationState: custom) + let state = await router.navigationState + #expect(state == custom) + } + + // MARK: - Invalid URL Handling + + @Test func invalidSchemeIsIgnored() async throws { + let router = await Router() + let url = try #require(URL(string: "https:///messages")) + await router.route(url: url) let tab = await router.navigationState.selectedTab - XCTAssertEqual(tab, .connect) + #expect(tab == .connect) } - func testRouteMessages() async throws { - try await assertRoute( - router: Router(), - "meshtastic:///messages", - NavigationState(selectedTab: .messages) - ) + @Test func unknownPathIsIgnored() async throws { + let router = await Router() + let url = try #require(URL(string: "meshtastic:///unknown")) + await router.route(url: url) + let state = await router.navigationState + #expect(state == NavigationState(selectedTab: .connect)) } - func testRouteMessagesWithChannelIdAndMessageId() async throws { - try await assertRoute( - router: Router(), - "meshtastic:///messages?channelId=0&messageId=1122334455", - NavigationState( - selectedTab: .messages, - messages: .channels( - channelId: 0, - messageId: 1122334455 - ) - ) - ) - } + // MARK: - Connect - func testRouteMessagesWithUserNumAndMessageId() async throws { + @Test func routeConnect() async throws { try await assertRoute( - router: Router(), - "meshtastic:///messages?userNum=123456789&messageId=9876543210", - NavigationState( - selectedTab: .messages, - messages: .directMessages( - userNum: 123456789, - messageId: 9876543210 - ) - ) - ) - } - - func testRouteConnect() async throws { - try await assertRoute( - router: Router(), "meshtastic:///connect", NavigationState(selectedTab: .connect) ) } - func testRouteNodes() async throws { + // MARK: - Messages + + @Test func routeMessages() async throws { + try await assertRoute( + "meshtastic:///messages", + NavigationState(selectedTab: .messages) + ) + } + + @Test func routeMessagesWithChannelIdAndMessageId() async throws { + try await assertRoute( + "meshtastic:///messages?channelId=0&messageId=1122334455", + NavigationState( + selectedTab: .messages, + messages: .channels(channelId: 0, messageId: 1122334455) + ) + ) + } + + @Test func routeMessagesWithChannelIdOnly() async throws { + try await assertRoute( + "meshtastic:///messages?channelId=5", + NavigationState( + selectedTab: .messages, + messages: .channels(channelId: 5, messageId: nil) + ) + ) + } + + @Test func routeMessagesWithUserNumAndMessageId() async throws { + try await assertRoute( + "meshtastic:///messages?userNum=123456789&messageId=9876543210", + NavigationState( + selectedTab: .messages, + messages: .directMessages(userNum: 123456789, messageId: 9876543210) + ) + ) + } + + @Test func routeMessagesWithUserNumOnly() async throws { + try await assertRoute( + "meshtastic:///messages?userNum=42", + NavigationState( + selectedTab: .messages, + messages: .directMessages(userNum: 42, messageId: nil) + ) + ) + } + + @Test func routeMessagesWithOnlyMessageIdIgnoresIt() async throws { + try await assertRoute( + "meshtastic:///messages?messageId=999", + NavigationState(selectedTab: .messages) + ) + } + + @Test func routeMessagesWithNonNumericParamsIgnoresThem() async throws { + try await assertRoute( + "meshtastic:///messages?channelId=abc&messageId=xyz", + NavigationState(selectedTab: .messages) + ) + } + + // MARK: - Nodes + + @Test func routeNodes() async throws { try await assertRoute( - router: Router(), "meshtastic:///nodes", NavigationState(selectedTab: .nodes) ) } - func testRouteNodesWithNodeNum() async throws { + @Test func routeNodesWithNodeNum() async throws { try await assertRoute( - router: Router(), "meshtastic:///nodes?nodenum=1234567890", - NavigationState( - selectedTab: .nodes, - nodeListSelectedNodeNum: 1234567890 - ) + NavigationState(selectedTab: .nodes, nodeListSelectedNodeNum: 1234567890) ) } - func testRouteMap() async throws { + @Test func routeNodesWithNonNumericNodeNum() async throws { + try await assertRoute( + "meshtastic:///nodes?nodenum=abc", + NavigationState(selectedTab: .nodes) + ) + } + + // MARK: - Map + + @Test func routeMap() async throws { try await assertRoute( - router: Router(), "meshtastic:///map", NavigationState(selectedTab: .map) ) } - func testRouteMapWithWaypointId() async throws { + @Test func routeMapWithWaypointId() async throws { try await assertRoute( - router: Router(), "meshtastic:///map?waypointId=123456", - NavigationState( - selectedTab: .map, - map: .waypoint(123456) - ) + NavigationState(selectedTab: .map, map: .waypoint(123456)) ) } - func testRouteMapWithNodeNum() async throws { + @Test func routeMapWithNodeNum() async throws { try await assertRoute( - router: Router(), "meshtastic:///map?nodenum=1234567890", - NavigationState( - selectedTab: .map, - map: .selectedNode(1234567890) - ) + NavigationState(selectedTab: .map, map: .selectedNode(1234567890)) ) } - func testRouteSettings() async throws { + @Test func routeMapWithBothNodeNumAndWaypointIdPrefersNode() async throws { + try await assertRoute( + "meshtastic:///map?nodenum=111&waypointId=222", + NavigationState(selectedTab: .map, map: .selectedNode(111)) + ) + } + + @Test func routeMapWithNonNumericParamsIgnoresThem() async throws { + try await assertRoute( + "meshtastic:///map?nodenum=abc&waypointId=xyz", + NavigationState(selectedTab: .map) + ) + } + + // MARK: - Settings + + @Test func routeSettings() async throws { try await assertRoute( - router: Router(), "meshtastic:///settings", - NavigationState( - selectedTab: .settings - ) + NavigationState(selectedTab: .settings) ) } - func testRouteSettingsAbout() async throws { + @Test(arguments: [ + ("about", SettingsNavigationState.about), + ("appSettings", SettingsNavigationState.appSettings), + ("routes", SettingsNavigationState.routes), + ("routeRecorder", SettingsNavigationState.routeRecorder), + ("lora", SettingsNavigationState.lora), + ("channels", SettingsNavigationState.channels), + ("shareQRCode", SettingsNavigationState.shareQRCode), + ("user", SettingsNavigationState.user), + ("bluetooth", SettingsNavigationState.bluetooth), + ("device", SettingsNavigationState.device), + ("display", SettingsNavigationState.display), + ("network", SettingsNavigationState.network), + ("position", SettingsNavigationState.position), + ("power", SettingsNavigationState.power), + ("ambientLighting", SettingsNavigationState.ambientLighting), + ("cannedMessages", SettingsNavigationState.cannedMessages), + ("detectionSensor", SettingsNavigationState.detectionSensor), + ("externalNotification", SettingsNavigationState.externalNotification), + ("mqtt", SettingsNavigationState.mqtt), + ("rangeTest", SettingsNavigationState.rangeTest), + ("paxCounter", SettingsNavigationState.paxCounter), + ("ringtone", SettingsNavigationState.ringtone), + ("serial", SettingsNavigationState.serial), + ("security", SettingsNavigationState.security), + ("storeAndForward", SettingsNavigationState.storeAndForward), + ("telemetry", SettingsNavigationState.telemetry), + ("debugLogs", SettingsNavigationState.debugLogs), + ("appFiles", SettingsNavigationState.appFiles), + ("firmwareUpdates", SettingsNavigationState.firmwareUpdates), + ("tak", SettingsNavigationState.tak), + ]) + func routeSettingsPage(path: String, expected: SettingsNavigationState) async throws { try await assertRoute( - router: Router(), - "meshtastic:///settings/about", - NavigationState( - selectedTab: .settings, - settings: .about - ) + "meshtastic:///settings/\(path)", + NavigationState(selectedTab: .settings, settings: expected) ) } - func testRouteSettingsInvalidSetting() async throws { + @Test func routeSettingsInvalidSetting() async throws { try await assertRoute( - router: Router(), "meshtastic:///settings/invalidSetting", - NavigationState( - selectedTab: .settings - ) + NavigationState(selectedTab: .settings) ) } + // MARK: - navigateToNodeDetail + + @Test func navigateToNodeDetail() async { + let router = await Router() + await router.navigateToNodeDetail(nodeNum: 9876543210) + let state = await router.navigationState + #expect(state.selectedTab == .nodes) + #expect(state.nodeListSelectedNodeNum == 9876543210) + } + + // MARK: - State Transitions + + @Test func routingToNewTabClearsPreviousState() async throws { + let router = await Router() + + // First, route to messages with channel state + let messagesURL = try #require(URL(string: "meshtastic:///messages?channelId=1&messageId=100")) + await router.route(url: messagesURL) + let messagesState = await router.navigationState + #expect(messagesState.selectedTab == .messages) + #expect(messagesState.messages != nil) + + // Then route to map — messages state should remain but tab changes + let mapURL = try #require(URL(string: "meshtastic:///map?waypointId=42")) + await router.route(url: mapURL) + let mapState = await router.navigationState + #expect(mapState.selectedTab == .map) + #expect(mapState.map == .waypoint(42)) + } + + @Test func consecutiveRoutesUpdateState() async throws { + let router = await Router() + + let nodesURL = try #require(URL(string: "meshtastic:///nodes?nodenum=111")) + await router.route(url: nodesURL) + let first = await router.navigationState + #expect(first.selectedTab == .nodes) + #expect(first.nodeListSelectedNodeNum == 111) + + let nodesURL2 = try #require(URL(string: "meshtastic:///nodes?nodenum=222")) + await router.route(url: nodesURL2) + let second = await router.navigationState + #expect(second.selectedTab == .nodes) + #expect(second.nodeListSelectedNodeNum == 222) + } + + @Test func invalidSchemeDoesNotMutateExistingState() async throws { + let initial = NavigationState(selectedTab: .map, map: .waypoint(99)) + let router = await Router(navigationState: initial) + let badURL = try #require(URL(string: "https:///messages")) + await router.route(url: badURL) + let state = await router.navigationState + #expect(state == initial) + } + + // MARK: - Helpers + private func assertRoute( - router: Router, _ urlString: String, _ destination: NavigationState ) async throws { - let url = try XCTUnwrap(URL(string: urlString)) + let router = await Router() + let url = try #require(URL(string: urlString)) await router.route(url: url) let state = await router.navigationState - XCTAssertEqual(state, destination) + #expect(state == destination) } } From 68050e6fb8f21efa0376cdebd5125541439e8c9b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:42:59 -0700 Subject: [PATCH 09/20] Fix merge conflicts in PR #1614 (Spanish translations) (#1644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- Localizable.xcstrings | 7144 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 7144 insertions(+) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 5f7a4fd9..1083039f 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -20,6 +20,12 @@ "value" : "\t%@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "\t%@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -61,6 +67,12 @@ "value" : "%@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -102,6 +114,12 @@ "value" : "%@%%" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " %@%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -213,6 +231,12 @@ "value" : "(Re)definer PIN_GPS_EN for dit printkort." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Re)define el PIN_GPS_EN para tu placa." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -259,6 +283,12 @@ "value" : "%@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -306,6 +336,12 @@ "value" : "%1$@ - %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -359,6 +395,12 @@ "value" : "%1$@ - %2$@ - %3$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ - %3$@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -464,6 +506,12 @@ "value" : "%@ - Keine Antwort" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - Ninguna respuesta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -516,6 +564,12 @@ "value" : "%@ - Nicht gesendet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - No enviado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -568,6 +622,12 @@ "value" : "%1$@ (%2$@)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ (%2$@)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -622,6 +682,12 @@ "value" : "%1$@ %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -675,6 +741,12 @@ "value" : "%1$@ %2$lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -728,6 +800,12 @@ "value" : "%@ entfernt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "a %@ de distancia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -780,6 +858,12 @@ "value" : "%@ kann bis zu %@ Byte lang sein." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ puede ser hasta %@ bytes de longitud." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -826,6 +910,12 @@ "value" : "%@ kanaler?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Canales?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -866,6 +956,12 @@ }, "%@ config data was requested via PKC admin but no response has been returned from the remote node." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ datos de configuración solicitados via PKC admin pero no se ha recibido respuesta desde el nodo remoto." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -888,6 +984,12 @@ "value" : "%@ dB" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -940,6 +1042,12 @@ "value" : "%1$@, %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -993,6 +1101,12 @@ "value" : "%1$@: %2$lld / %3$lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$lld / %3$lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1040,6 +1154,12 @@ "value" : "%@%%" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1086,6 +1206,12 @@ "value" : "%@°F" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@°F" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1132,6 +1258,12 @@ "value" : "%@mA" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@mA" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1172,6 +1304,12 @@ "value" : "%@V" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@V" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1212,6 +1350,12 @@ "value" : "%d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1288,6 +1432,24 @@ } } }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d Salto" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d Saltos" + } + } + } + } + }, "it" : { "variations" : { "plural" : { @@ -1407,6 +1569,12 @@ "value" : "%d%%" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1448,6 +1616,12 @@ "%f%%" : { "extractionState" : "stale", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%f%%" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1471,6 +1645,12 @@ "value" : "%lf" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lf" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1517,6 +1697,12 @@ "value" : "%lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1564,6 +1750,12 @@ "value" : "%1$lld %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld %2$@" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1616,6 +1808,24 @@ } } }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld feature" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld features" + } + } + } + } + }, "ru" : { "variations" : { "plural" : { @@ -1686,6 +1896,12 @@ "value" : "%lld oder weniger Hops entfernt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld o menos saltos de distancia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1726,6 +1942,12 @@ "value" : "Samlet %lld aflæsninger" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Lecturas Totales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1766,6 +1988,12 @@ "value" : "Samlet %lld detektioner" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Eventos de Detección Totales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1806,6 +2034,12 @@ "value" : "%lld%%" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1858,6 +2092,12 @@ "value" : "%llddb Übertragungsleistung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%llddb Potencia de Transmisión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1953,6 +2193,12 @@ "value" : "< 1%" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "< 1%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1995,6 +2241,12 @@ "comment" : "A warning label below the picker, indicating that the selected update interval is not one of the optimized options.", "isCommentAutoGenerated" : true, "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ El valor configurado: (%@) no es una de las opciones optimizadas." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -2011,6 +2263,12 @@ "value" : "🦕 Ikke-supporteret version 🦖 ☄️" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦕 Versión End of life 🦖 ☄️" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2046,6 +2304,12 @@ "0" : { "extractionState" : "stale", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2057,6 +2321,12 @@ }, "1" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "1" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2074,6 +2344,12 @@ "value" : "1 byte" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 byte" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2120,6 +2396,12 @@ "value" : "1 hop væk" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "a 1 salto de distancia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2167,6 +2449,12 @@ "value" : "2.4 GHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "2.4 Ghz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2207,6 +2495,12 @@ "value" : "7" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "7" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2247,6 +2541,12 @@ }, "12 Hour Clock" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reloj 12 Horas" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2275,6 +2575,12 @@ "value" : "25" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "25" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2321,6 +2627,12 @@ "value" : "50" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "50" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2367,6 +2679,12 @@ "value" : "75" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "75" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2413,6 +2731,12 @@ "value" : "100" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "100" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2459,6 +2783,12 @@ "value" : "128 bit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "128 bit" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2499,6 +2829,12 @@ }, "180" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "180" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2516,6 +2852,12 @@ "value" : "256 bit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "256 bit" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2560,6 +2902,12 @@ }, "A channel index of 0 indicates the primary channel where broadcast packets are sent from. Location data is broadcast from the first channel where it is enabled with firmware 2.7 forward." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El índice de canal 0 indica el canal principal desde donde se envían los paquetes de broadcast. Los datos de ubicación se transmiten desde el primer canal donde esté habilitado con el firmware 2.7 en adelante." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -2577,6 +2925,12 @@ "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un candado verde significa que el canal está encriptado de forma segura con una clave AES 128 o 256 bit." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2611,6 +2965,12 @@ "value" : "In a Meshtastic LoRa Mesh there are up to 8 channels. The first one is the Primary channel where most activity happens and is required. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. It talks on its primary and your secondary channel. A channel with the name 'admin' controls nodes remotely. Other channels are for private groups, each with its own key." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un código QR Meshtastic contiene la configuración LoRa y los valores de los canales necesarios para la comunicación radio. Puedes compartir la configuración de canales completa usando la opción Reemplazar Canales, si seleccionas Agregar Canales tus canales compartidos serán añadidos a los canales existentes en la radio receptora." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -2675,6 +3035,12 @@ }, "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" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un candado rojo abierto significa que el canal no está encriptado de forma segura y se usa para datos de ubicación precisos, que no tiene clave o que tiene una clave de 1 byte conocida." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -2691,6 +3057,12 @@ }, "A red open lock with a warning means the channel is not securely encrypted and is used for precise location data which is being uplinked to the internet via MQTT, it uses either no key at all or a 1 byte known key." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un candado rojo abierto con un warning significa que el canal no está encriptado de forma segura y se usa para datos de ubicación precisos que están siendo subidos a internet via MQTT, que no tiene clave o que tiene una clave de 1 byte conocida." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -2713,6 +3085,12 @@ "value" : "Der er igangsat en rutesporing (trace route), men der er ikke modtaget svar." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un Trace Route fue enviado, no se ha recibido respuesta." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2748,6 +3126,12 @@ "A yellow open lock lock means the channel is not securely encrypted but it not used for precise location data, it uses either no key at all or a 1 byte known key." : { "extractionState" : "stale", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un candado amarillo abierto significa que el canal no está encriptado de forma segura pero no se usa para datos de ubicación precisos, que no tiene clave o que tiene una clave de 1 byte conocida." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2760,6 +3144,12 @@ "comment" : "A description of a yellow open lock in the Channels Help view.", "isCommentAutoGenerated" : true, "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un candado amarillo abierto significa que el canal no está encriptado de forma segura pero no se usa para datos de ubicación precisos, que no tiene clave o que tiene una clave de 1 byte conocida." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -2782,6 +3172,12 @@ "value" : "Über Meshtastic" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acerca de" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2828,6 +3224,12 @@ "value" : "Über Meshtastic" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acerca de Meshtastic" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2874,6 +3276,12 @@ "value" : "Genauigkeit %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Precisión %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2914,6 +3322,12 @@ "value" : "Ack SNR: %@ dB" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ack SNR: %@ dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2954,6 +3368,12 @@ "value" : "Bekræftelsestidspunkt: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo ACK: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3001,6 +3421,12 @@ "value" : "Bestätigt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -3071,6 +3497,12 @@ "value" : "Modtagelse bekræftet af en anden node" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmado por otro nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3117,6 +3549,12 @@ "value" : "Aktionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acciones" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3163,6 +3601,12 @@ "value" : "Aktiv" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3209,6 +3653,12 @@ "value" : "Aktivität" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actividad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3261,6 +3711,12 @@ "value" : "ADC Override" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ADC Override" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -3320,6 +3776,12 @@ "value" : "Tilføj kanal" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3360,6 +3822,12 @@ "value" : "Tilføj kanaler" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Canales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3394,6 +3862,12 @@ }, "Add Contact" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Contacto" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3422,6 +3896,12 @@ }, "Add Meshtastic Node %@ as a contact" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar nodo Meshtastic %@ como contacto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3468,6 +3948,12 @@ "value" : "Zu Favoriten hinzufügen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar a favoritos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3508,6 +3994,12 @@ "value" : "Yderligere hjælp" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayuda adicional" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3554,6 +4046,12 @@ "value" : "Adresse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dirección" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3594,6 +4092,12 @@ }, "Admin Keys" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Claves de Admin" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3622,6 +4126,12 @@ "value" : "Administration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3668,6 +4178,12 @@ "value" : "Administration aktiveret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administración habilitada" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3702,6 +4218,12 @@ "value" : "Avanceret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avanzado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3748,6 +4270,12 @@ "value" : "Avanceret indbygget GPS" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo GPS Avanzado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3795,6 +4323,12 @@ "value" : "Avancerede GPIO-indstillinger" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones GPIO avanzadas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3835,6 +4369,12 @@ "value" : "Avancerede positionsflag" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flags de posición avanzadas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3888,6 +4428,12 @@ "value" : "Nach" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Después" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3964,6 +4510,24 @@ } } }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Después de %lld Día" + } + }, + "other" : { + "stringUnit" : { + "state" : "new", + "value" : "Después de %lld Días" + } + } + } + } + }, "ja" : { "variations" : { "plural" : { @@ -4046,6 +4610,12 @@ "value" : "Nach dem Ändern der Einstellungen wird das Gerät neu starten." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Después de guardar los valores de configuración el nodo se reseteará." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -4123,6 +4693,12 @@ "value" : "Nachmittag" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarde" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4169,6 +4745,12 @@ "value" : "Airtime" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Airtime" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -4239,6 +4821,12 @@ "value" : "Alarm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviso" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4279,6 +4867,12 @@ "value" : "Udløs GPIO-sirene ved modtagelse af en ASCII-klokke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviso GPIO buzzer cuando se recibe una campana" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4325,6 +4919,12 @@ "value" : "Advarsel GPIO-vibrator ved modtagelse af en besked" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerta GPIO buzzer cuando se recibe un mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4365,6 +4965,12 @@ "value" : "Advarsel GPIO-vibrator ved modtagelse af en ASCII-klokke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerta GPIO vibra motor cuando se recibe una campana" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4411,6 +5017,12 @@ "value" : "Advarsel GPIO-vibrator ved modtagelse af en besked" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerta GPIO vibra motor cuando se recibe un mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4451,6 +5063,12 @@ "value" : "Giv besked ved modtagelse af en ASCII-klokke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerta cuando se recibe una campana" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4497,6 +5115,12 @@ "value" : "Giv besked ved modtagelse af en besked" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerta cuando se recibe un mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4543,6 +5167,12 @@ "value" : "Alle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4589,6 +5219,12 @@ "value" : "Tillad positions-anmodninger" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permitir Peticiones de Posición" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4629,6 +5265,12 @@ "value" : "Højde" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alt" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4675,6 +5317,12 @@ "value" : "Höhe" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altitud" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4721,6 +5369,12 @@ "value" : "Höhe %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altitud %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4761,6 +5415,12 @@ "value" : "Geoidhøjde Adskillelse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Separación Geoidal de Altitud" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4801,6 +5461,12 @@ "value" : "Højde er middelhavsniveau" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altitud es nivel medio del mar (MSL)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4848,6 +5514,12 @@ "value" : "Immer an" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siempre encendido" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -4924,6 +5596,12 @@ "value" : "Immer nach Norden zeigen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siempre apuntar al norte" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4970,6 +5648,12 @@ "value" : "Ambientebeleuchtung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luz ambiente" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5040,6 +5724,12 @@ "value" : "Ambientebeleuchtungskonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración luz ambiente" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5105,6 +5795,12 @@ "value" : "Konfiguration af ambient belysningsmodul modtaget: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo de luz ambiante recibida : %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5181,6 +5877,12 @@ "value" : "Ein quelloffenes, netzunabhängiges, dezentrales Mesh-Netzwerk, das auf kostengünstigen, stromsparenden Funkgeräten läuft." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Una red mesh open source, off-grid, descentralizada, que funciona con radios de bajo coste y de baja potencia." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5227,6 +5929,12 @@ "value" : "Alle missede beskeder vil blive leveret igen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cualquier mensaje perdido será entregado nuevamente." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5280,6 +5988,12 @@ "value" : "Client (Standard) - Mit App verbundener Client." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicación conectada o dispositivo de mensajería aislado." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5356,6 +6070,12 @@ "value" : "App-Daten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos de la aplicación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5402,6 +6122,12 @@ "value" : "App-filer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archivos de la aplicación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5442,6 +6168,12 @@ }, "App Icon" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icono de la App" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -5464,6 +6196,12 @@ "value" : "Mitteilungseinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificaciones de la App" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -5492,6 +6230,12 @@ "value" : "App-Einstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes de la App" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5590,6 +6334,12 @@ "value" : "Ungefährer Standort" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posición Aproximada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5630,6 +6380,12 @@ "value" : "Er du sikker på, at du vil slette denne besked?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Estás seguro de que quieres eliminar este mensaje?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5682,6 +6438,12 @@ "value" : "Bist du sicher dass du den Knoten auf die Werkseinstellungen zurücksetzen willst?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Estás seguro de que quieres borrar el nodo a valores de fábrica?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5734,6 +6496,12 @@ "value" : "Bist Du sicher?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Estás seguro?" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5805,6 +6573,12 @@ "value" : "Australien og New Zealand" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Australia / Nueva Zelanda" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5839,6 +6613,12 @@ }, "Automatically Connect" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conexión Automática" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -5861,6 +6641,12 @@ "value" : "Skifter automatisk til den næste side på skærmen som en karrusel, baseret på det angivne interval." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambia automáticamente a la siguiente página en pantalla, como un carrusel, según el intervalo especificado." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5913,6 +6699,12 @@ "value" : "Verfügbare Modem-Voreinstellungen, Standard ist „Long Range - Fast“." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presets del modem disponibles, por defecto es Long Fast.“" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5965,6 +6757,12 @@ "value" : "Geräte in der Nähe" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radios Disponibles" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6042,6 +6840,12 @@ "value" : "Zurück" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atrás" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6128,6 +6932,12 @@ }, "Backup your private key to your iCloud keychain." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Respalda tu clave privada en el llavero de iCloud." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -6157,6 +6967,12 @@ "value" : "Dårlig" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Incorrecto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6198,6 +7014,12 @@ "value" : "Fejl i forespørgsel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Petición incorrecta" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6274,6 +7096,12 @@ "value" : "Bandbreite" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ancho de banda" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6321,6 +7149,12 @@ "value" : "Søjle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6367,6 +7201,12 @@ "value" : "Søjlediagramserie" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serie Bar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6414,6 +7254,12 @@ "value" : "Barometertryk" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presión Barométrica" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6466,6 +7312,12 @@ "value" : "Batterie" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batería" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -6537,6 +7389,12 @@ "value" : "Batterie Ladung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nivel de Batería" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6614,6 +7472,12 @@ "value" : "Batterie Ladung %" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nivel de Batería %" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6685,6 +7549,12 @@ "value" : "Batterie Ladung %d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nivel de Batería %d" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6755,6 +7625,12 @@ "value" : "Baud" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baudios" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6828,6 +7704,12 @@ "value" : "Biken" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "En bicicleta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6874,6 +7756,12 @@ "value" : "BLE" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLE" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7002,6 +7890,12 @@ "value" : "Bluetooth" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7078,6 +7972,12 @@ "value" : "Bluetooth Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración Bluetooth" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7155,6 +8055,12 @@ "value" : "Bluetooth Konfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración Bluetooth recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7221,6 +8127,12 @@ "comment" : "A heading displayed on a view that guides users to configure Bluetooth connectivity for the app.", "isCommentAutoGenerated" : true, "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectividad Bluetooth" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -7269,6 +8181,12 @@ }, "Broadcast Device Metrics" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métricas de Transmisión del Dispositivo" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -7286,6 +8204,12 @@ "value" : "Broadcast-interval" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de transmisión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7339,6 +8263,12 @@ "value" : "Sendet GPS-Positionspakete mit Priorität." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmitir paquetes de posición GPS con prioridad." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7416,6 +8346,12 @@ "value" : "Sendet den Standort regelmäßig als Nachricht an den Standardkanal, um die Suche nach dem Gerät zu unterstützen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmitir la posición como un mensaje al canal por defecto regularmente para ayudar con la recuperación del dispositivo." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7493,6 +8429,12 @@ "value" : "Sendet Telemetriepakete mit Priorität." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmitir los paquetes de telemetría con prioridad." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7563,6 +8505,12 @@ "value" : "GPIO-knap" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Botón GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7609,6 +8557,12 @@ "value" : "Køb komplette radioer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comprar Radios Completas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7655,6 +8609,12 @@ "value" : "GPIO-vibrator" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zumbador GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7701,6 +8661,12 @@ "value" : "Ved at aktivere denne funktion anerkender du og giver udtrykkeligt samtykke til overførsel af din enheds geolokation i realtid over MQTT-protokollen uden kryptering. Disse positionsdata kan bruges til formål som livekortrapportering, enhedssporing og relaterede telemetriefunktioner." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Al habilitar esta función, aceptas expresamente la transmisión de tu ubicación geográfica en tiempo real de tu dispositivo mediante el protocolo MQTT sin encriptar. Estos datos de ubicación se pueden usar para propósitvos como reporte en mapa en tiempo real, localización de dispositivo y otras funciones de telemetría asociadas." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -7742,6 +8708,12 @@ "value" : "Bytes" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bytes" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7807,6 +8779,12 @@ "Bytes Used" : { "comment" : "VoiceOver value for bytes used", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bytes Usados" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -7841,6 +8819,12 @@ "value" : "Rufzeichen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Señal de llamada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7893,6 +8877,12 @@ "value" : "Das Rufzeichen darf nicht leer sein." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La señal de llamada no debe estar vacía." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7945,6 +8935,12 @@ "value" : "Abbrechen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -8016,6 +9012,12 @@ "value" : "Konfigurationsmodul for standardbesked modtaget: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuraciíon del módulo Canned Message recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -8092,6 +9094,12 @@ "value" : "Canned Messages" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canned Messages" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -8168,6 +9176,12 @@ "value" : "Canned Messages Config" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de Canned Messages" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -8239,6 +9253,12 @@ "value" : "Modtagne beskeder for: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recibidos Canned Messages para : %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -8309,6 +9329,12 @@ "value" : "Karusselinterval" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo del carrusel" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8355,6 +9381,12 @@ "value" : "Kategorien" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Categorías" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8407,6 +9439,12 @@ "value" : "Kategorie" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Categoría" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8447,6 +9485,12 @@ "value" : "Ch1 strøm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corriente Ch1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8487,6 +9531,12 @@ "value" : "Ch1 spænding" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltaje Ch1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8527,6 +9577,12 @@ "value" : "Ch2 strøm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corriente Ch2" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8567,6 +9623,12 @@ "value" : "Ch2 spænding" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltaje Ch2" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8607,6 +9669,12 @@ "value" : "Ch3 strøm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corriente Ch3" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8647,6 +9715,12 @@ "value" : "Ch3 spænding" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltaje Ch3" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8693,6 +9767,12 @@ "value" : "Kanal" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -8763,6 +9843,12 @@ "value" : "Kanal 0 inkluderet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 0 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8809,6 +9895,12 @@ "value" : "Kanal 1" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8849,6 +9941,12 @@ "value" : "Kanal 1 inkluderet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 1 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8895,6 +9993,12 @@ "value" : "Kanal 2" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 2" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8935,6 +10039,12 @@ "value" : "Kanal 2 inkluderet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 2 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8981,6 +10091,12 @@ "value" : "Kanal 3" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 3" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9021,6 +10137,12 @@ "value" : "Kanal 3 inkluderet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 3 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9067,6 +10189,12 @@ "value" : "Kanal 4 inkluderet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 4 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9113,6 +10241,12 @@ "value" : "Kanal 5 inkluderet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 5 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9159,6 +10293,12 @@ "value" : "Kanal 6 inkluderet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 6 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9205,6 +10345,12 @@ "value" : "Kanal 7 inkluderet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 7 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9245,6 +10391,12 @@ }, "Channel Details" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalles del Canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9291,6 +10443,12 @@ "value" : "Kanalnavn" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre del Canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9337,6 +10495,12 @@ "value" : "Kanalnummeret skal være mellem 0 og 7." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El número del canal debe estar entre 0 y 7." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9383,6 +10547,12 @@ "value" : "Kanalrolle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rol del Canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9429,6 +10599,12 @@ "value" : "Kanal-URL" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL del canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9475,6 +10651,12 @@ "value" : "Kanalbelegung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilización del Canal" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -9545,6 +10727,12 @@ "value" : "Kanaludnyttelsesgrad %@%%" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilización del canal %@%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9591,6 +10779,12 @@ "value" : "Kanäle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canales" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -9662,6 +10856,12 @@ "value" : "Kanaler tilføjet fra QR-koden blev ikke gemt. Når kanaler tilføjes, skal navnene være unikke." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los canales añadidos desde el código QR no se guardaron. Cuando se añaden canales los nombres deben ser únicos." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9702,6 +10902,12 @@ }, "Channels Help" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayuda de los Canales" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -9731,6 +10937,12 @@ "value" : "Graf" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gráfico" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9777,6 +10989,12 @@ "value" : "ÆND" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "CHG" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9818,6 +11036,12 @@ "value" : "Kina" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "China" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9875,6 +11099,12 @@ "value" : "Tøm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpiar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9927,6 +11157,12 @@ "value" : "App-Daten löschen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar Datos de Aplicación" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -9997,6 +11233,12 @@ "value" : "Tøm log" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar Log" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10037,6 +11279,12 @@ "value" : "Veraltete Knoten löschen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar nodos obsoletos" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -10066,6 +11314,12 @@ "value" : "Klient" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10108,6 +11362,12 @@ "comment" : "A message displayed in a confirmation dialog when trying to favorite a node as a CLIENT_BASE.", "isCommentAutoGenerated" : true, "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliente Base solo debe tener como favoritos otros nodos bajo tu control. El uso inapropiado dañará tu mesh local." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -10133,6 +11393,12 @@ "value" : "Client - Versteckt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliente Oculto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10179,6 +11445,12 @@ "value" : "Klienthistorik" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historial del cliente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10225,6 +11497,12 @@ "value" : "Klienthistorik-anmodning sendt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Petición de cronología del cliente enviada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10272,6 +11550,12 @@ "value" : "Tavs klient (client mute)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliente Mudo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10318,6 +11602,12 @@ "value" : "Klientindstillinger" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones del cliente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10364,6 +11654,12 @@ "value" : "Med uret roterende hændelse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evento de giro en sentido horario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10416,6 +11712,12 @@ "value" : "Schließen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -10486,6 +11788,12 @@ "value" : "Kodningshastighed" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tasa de codificación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10538,6 +11846,12 @@ "value" : "Farbe" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10584,6 +11898,12 @@ "value" : "Bleibe mit deinen Freunden und deiner Community in Verbindung, auch abseits vom Mobilfunknetz." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comunícate con tus amigos y tu comunidad fuera de la red móvil." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -10606,6 +11926,12 @@ "value" : "Kommunikerer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "En comunicación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10646,6 +11972,12 @@ "value" : "Support fra fællesskabet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Soporte de la comunidad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10680,6 +12012,12 @@ }, "Compass" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brújula" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -10696,6 +12034,12 @@ "value" : "Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10749,6 +12093,12 @@ "value" : "Konfiguration für: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración para: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10789,6 +12139,12 @@ "value" : "Standardkonfigurationer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presets de Configuración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10841,6 +12197,12 @@ "value" : "Konfigurieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10877,6 +12239,12 @@ "comment" : "Button label to guide users to configure Bluetooth connectivity for the app.", "isCommentAutoGenerated" : true, "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurar la conectividad Bluetooth" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -10895,6 +12263,12 @@ "comment" : "Button label to configure local network access permissions.", "isCommentAutoGenerated" : true, "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurar acceso a la red local" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -10917,6 +12291,12 @@ "value" : "Standortberechtigungen konfigurieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurar permisos de ubicación" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -10939,6 +12319,12 @@ "value" : "Mitteilungsberechtigungen konfigurieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurar permisos de notificaciones" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -10961,6 +12347,12 @@ "value" : "Bekræft" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10995,6 +12387,12 @@ }, "Connect" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -11023,6 +12421,12 @@ "value" : "Verbunden mit einem Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar a un nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11063,6 +12467,12 @@ "value" : "Tilslut MQTT over proxy" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar a MQTT via Proxy" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -11097,6 +12507,12 @@ "value" : "Tilslut ny radio" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Conectar a la nueva radio?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11143,6 +12559,12 @@ "value" : "Derzeit verbunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -11219,6 +12641,12 @@ "value" : "Verbunden mit Knoten %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodo conectado %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11305,6 +12733,12 @@ "value" : "Verbinde..." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectando . ." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -11375,6 +12809,12 @@ "value" : "Hvis du tilslutter en ny radio bliver all appens data på telefonen slettet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectarse a una nueva radio borrará todos los datos de la app en el teléfono." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11421,6 +12861,12 @@ "value" : "Verbindungsversuch %lld von 10" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intento de conexión %lld de 10" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11461,6 +12907,12 @@ }, "Connection Name" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre de la conexión" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -11483,6 +12935,12 @@ "value" : "Samtykke til at dele ukrypterede node-data via MQTT" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consentir compartir datos del nodo sin cifrar mediante MQTT " + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -11518,6 +12976,12 @@ "value" : "Kontaktfilter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtros de contacto" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -11534,6 +12998,12 @@ }, "Contact URL" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL del contacto" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -11575,6 +13045,12 @@ "value" : "Kontakte (%@)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contactos (%@)" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -11645,6 +13121,12 @@ "value" : "Kontroltype" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo de control" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11691,6 +13173,12 @@ "value" : "Styrer den blinkende LED på enheden. For de fleste enheder vil dette styre en af de op til 4 LED'er, oplader- og GPS-LED'er kan ikke styres." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controla el parpadeo del LED del dispositivo. Para la mayoría de los dispositivos, esto controlará uno de los 4 LEDs, los LED del cargador y GPS no son controlables." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11743,6 +13231,12 @@ "value" : "Konvexe Hülle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoltura convexa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11789,6 +13283,12 @@ "value" : "Koordinate" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coordenar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11835,6 +13335,12 @@ "value" : "Koordinate %1$@, %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coordenadas %1$@, %2$@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11887,6 +13393,12 @@ "value" : "Koordinaten:" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coordenadas:" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11933,6 +13445,12 @@ "value" : "Kopieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -12009,6 +13527,12 @@ "value" : "Knoten nicht gefunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodo no encontrado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12055,6 +13579,12 @@ "value" : "Mod-uret Rundt Roterende Begivenhed" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evento rotativo antihorario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12101,6 +13631,12 @@ "value" : "Wegpunkt erstellen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crear punto de ruta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12141,6 +13677,12 @@ "value" : "Erstelle deine eigenen Netzwerke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crea tus propias redes" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -12169,6 +13711,12 @@ "value" : "Erstellt: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creado: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12209,6 +13757,12 @@ "value" : "Kritische Hinweise" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alertas críticas" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -12231,6 +13785,12 @@ "value" : "Strøm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actual" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12277,6 +13837,12 @@ "value" : "Aktuelle Firmware Version: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión de firmware actual: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12329,6 +13895,12 @@ "value" : "Aktuelle Firmware Version: %1$@, neuste Firmware Version %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión de firmware actual: %@, última versión de firmware: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12381,6 +13953,12 @@ "value" : "Aktuell: %lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actual: %lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12421,6 +13999,12 @@ "value" : "I øjeblikket er den anbefalede måde at opdatere ESP32-enheder på at bruge web-flasheren på en stationær computer fra en Chrome-baseret browser. Det fungerer ikke på mobile enheder eller over BLE." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualmente, la forma recomendada de actualizar dispositivos ESP32 es utilizar el flash web en una computadora de escritorio desde un navegador basado en Chrome. No funciona en dispositivos móviles ni a través de BLE." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12473,6 +14057,12 @@ "value" : "Datum" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fecha" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12513,6 +14103,12 @@ "value" : "Fejlfinding" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depurar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12565,6 +14161,12 @@ "value" : "Fehlersuchprotokolle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros de depuración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12605,6 +14207,12 @@ "value" : "Fejlfindingslogs %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros de depuración%@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12651,6 +14259,12 @@ "value" : "Standard" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predeterminado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -12722,6 +14336,12 @@ "value" : "Standardskærmlayout på 128x64" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diseño de pantalla predeterminado de 128x64" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12774,6 +14394,12 @@ "value" : "Löschen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -12839,6 +14465,12 @@ "Delete All" : {}, "Delete all config, keys and BLE bonds? " : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar todas las configuraciones, claves y enlaces BLE? " + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12867,6 +14499,12 @@ }, "Delete all config? " : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar todas las configuraciones? " + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12901,6 +14539,12 @@ "value" : "Slet alle enhedens måledata?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar todas las métricas del dispositivo?" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -12971,6 +14615,12 @@ "value" : "Slet alle miljødata?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar todas las métricas del entorno?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13017,6 +14667,12 @@ "value" : "Slet alle persontællingsdata?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar todos los datos de los pasajeros?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13063,6 +14719,12 @@ "value" : "Slet alle positioner?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar todas las posiciones?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13103,6 +14765,12 @@ "value" : "Slet besked" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13143,6 +14811,12 @@ "value" : "Slet beskeder" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar mensajes" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13189,6 +14863,12 @@ "value" : "Knoten löschen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13235,6 +14915,12 @@ "value" : "Knoten löschen?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar nodo?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13275,6 +14961,12 @@ "value" : "Slet alle energiforbrugsdata?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar métricas de potencia?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13321,6 +15013,12 @@ "value" : "Beschreibung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descripción" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13361,6 +15059,12 @@ "value" : "Beskrivelsen skal være under 100 bytes" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La descripción debe tener menos de 100 bytes." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13401,6 +15105,12 @@ }, "Details..." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalles..." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -13423,6 +15133,12 @@ "value" : "Detektion" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detección" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13463,6 +15179,12 @@ "value" : "Detektionshændelse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evento de detección" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13510,6 +15232,12 @@ "value" : "Detection Sensor" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensor de detección" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -13580,6 +15308,12 @@ "value" : "Detektionssensor-indstillinger" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del sensor de detección" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -13644,6 +15378,12 @@ "value" : "Detektionssensor-log" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro del sensor de detección" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13684,6 +15424,12 @@ "value" : "Registreringssensorbeskeder modtages som tekstbeskeder. Hvis du aktiverer meddelelser, vil du modtage en meddelelse for hver registreringsbesked, der modtages, samt et tilsvarende badge for ulæste beskeder." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los mensajes del sensor de detección se reciben como mensajes de texto. Si habilita las notificaciones, recibirá una notificación por cada mensaje de detección recibido y la correspondiente insignia de mensaje no leído." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13731,6 +15477,12 @@ "value" : "Registrering af sensors modulkonfiguration modtaget: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo del sensor de detección recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -13801,6 +15553,12 @@ "value" : "Udviklere" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desarrolladores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13853,6 +15611,12 @@ "value" : "Gerät" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -13929,6 +15693,12 @@ "value" : "Gerätekonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del dispositivo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -14006,6 +15776,12 @@ "value" : "Gerätekonfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del dispositivo recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -14082,6 +15858,12 @@ "value" : "Gerätekonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14140,6 +15922,12 @@ "value" : "Geräte-GPS" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo GPS" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14186,6 +15974,12 @@ "value" : "Enheden administreres af en mesh-administrator, brugeren har ikke adgang til enhedens indstillinger." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El dispositivo es administrado por un administrador de malla, el usuario no puede acceder a ninguna de las configuraciones del dispositivo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14239,6 +16033,12 @@ "value" : "Device Metadata empfangen von: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metadatos del dispositivo recibidos de: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -14310,6 +16110,12 @@ "value" : "Enhedsmåledata" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métricas del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14356,6 +16162,12 @@ "value" : "Enhedsmetriklog" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de métricas del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14408,6 +16220,12 @@ "value" : "Gerätemodell: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelo de dispositivo: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14448,6 +16266,12 @@ }, "Device Options" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones del dispositivo" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -14464,6 +16288,12 @@ "value" : "Enhedsrolle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Función del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14510,6 +16340,12 @@ "value" : "Enhedsskærm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pantalla del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14563,6 +16399,12 @@ "value" : "Gerät, das keine Pakete von anderen Geräten weiterleitet." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo que no reenvía paquetes desde otros dispositivos." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -14640,6 +16482,12 @@ "value" : "Gerät, das nur bei Bedarf sendet, um nicht entdeckt zu werden oder Strom zu sparen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo que solo transmite según sea necesario para sigilo o ahorro de energía." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -14710,6 +16558,12 @@ "value" : "Standard PDOP bruges som udgangspunkt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dilución de precisión (DOP) PDOP utilizado por defecto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14756,6 +16610,12 @@ "value" : "Direkt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "directo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14808,6 +16668,12 @@ "value" : "Hilfe für Direktnachrichten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayuda por mensaje directo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14848,6 +16714,12 @@ }, "Direct Message Key" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tecla de mensaje directo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -14882,6 +16754,12 @@ "value" : "Direktnachrichten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensajes directos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -14952,6 +16830,12 @@ "value" : "Direkte beskeder bruger den nye public key-infrastruktur til kryptering. Kræver firmware-version 2.5 eller nyere" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los mensajes directos utilizan la nueva infraestructura de clave pública para el cifrado. Requiere versión de firmware 2.5 o superior." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14998,6 +16882,12 @@ "value" : "Direkte beskeder bruger den fælles krypteringsnøgle for kanalen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los mensajes directos utilizan la clave compartida del canal." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15050,6 +16940,12 @@ "value" : "Deaktiviert" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discapacitado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15126,6 +17022,12 @@ "value" : "Trennen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15190,6 +17092,12 @@ }, "Disconnect Node" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar nodo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -15212,6 +17120,12 @@ }, "Disconnect the currently connected node" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar el nodo actualmente conectado" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -15246,6 +17160,12 @@ "value" : "Tastatur ausblenden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descartar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15322,6 +17242,12 @@ "value" : "Display (Device Screen)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pantalla" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15392,6 +17318,12 @@ "value" : "Skærmopsætning" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de pantalla" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15469,6 +17401,12 @@ "value" : "Display Konfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de pantalla recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15539,6 +17477,12 @@ "value" : "Vis Fahrenheit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar grados Fahrenheit" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15585,6 +17529,12 @@ "value" : "Display Mode" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo de visualización" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15631,6 +17581,12 @@ "value" : "Darstellung der Entfernung zwischen deinem Handy und anderen Meshtastic-Knoten mit Positionsangabe." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muestra la distancia entre tu teléfono y otros nodos Meshtastic con posiciones." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -15653,6 +17609,12 @@ "value" : "Display Units" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unidades de visualización" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15705,6 +17667,12 @@ "value" : "Distanz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distancia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15751,6 +17719,12 @@ "value" : "Distanzfilter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtros de distancia" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -15773,6 +17747,12 @@ "value" : "Distanzmessungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediciones de distancia" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -15789,6 +17769,12 @@ }, "Distance: %@" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distancia: %@" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -15811,6 +17797,12 @@ "value" : "Dokumentation" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documentación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15851,6 +17843,12 @@ }, "Done" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "hecho" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -15885,6 +17883,12 @@ "value" : "Dobbelttryk som knap" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toque dos veces como botón" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15938,6 +17942,12 @@ "value" : "Runter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abajo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -16008,6 +18018,12 @@ "value" : "Downlink slået til" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enlace descendente habilitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16055,6 +18071,12 @@ "value" : "Træk-og-slip firmwareopdatering" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualización de firmware de arrastrar y soltar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16101,6 +18123,12 @@ "value" : "Træk-og-slip firmwareopdateringsdokumentation" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documentación de actualización de firmware de arrastrar y soltar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16147,6 +18175,12 @@ "value" : "Træk og slip er den anbefalede måde at opdatere firmware til NRF-enheder. Hvis din iPhone eller iPad har USB-C, vil det fungere med dit almindelige USB-C-opladerkabel, for Lightning-enheder har du brug for Apple Lightning til USB-kameraadapter." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrastrar y soltar es la forma recomendada de actualizar el firmware para dispositivos NRF. Si su iPhone o iPad es USB-C, funcionará con su cable de carga USB-C habitual; para dispositivos Lightning, necesita el adaptador de cámara Lightning a USB de Apple." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16200,6 +18234,12 @@ "value" : "Fahren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conducir" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16246,6 +18286,12 @@ "value" : "Placer nål i kort" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Colocar pin en mapas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16286,6 +18332,12 @@ "value" : "Richte einfach private Mesh-Netzwerke für eine sichere und zuverlässige Kommunikation in abgelegenen Gebieten ein." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configure fácilmente redes de malla privadas para una comunicación segura y confiable en áreas remotas." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -16308,6 +18360,12 @@ "value" : "Echo" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "eco" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -16372,6 +18430,12 @@ "value" : "Redigerer viapunkt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar punto de referencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16419,6 +18483,12 @@ "value" : "Achtzehn Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieciocho horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -16495,6 +18565,12 @@ "value" : "Höhenunterschied" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elev. Ganar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16535,6 +18611,12 @@ "value" : "Emoji" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "emojis" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16581,6 +18663,12 @@ "value" : "Tom" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "vacio" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16621,6 +18709,12 @@ }, "Enable broadcasting device metrics to the mesh network. When disabled, metrics are only sent to connected clients." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilite la transmisión de métricas de dispositivos a la red de malla. Cuando está deshabilitado, las métricas solo se envían a los clientes conectados." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -16637,6 +18731,12 @@ "value" : "Aktiver udsendelse af pakker via UDP over det lokale netværk." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilite la transmisión de paquetes a través de UDP a través de la red local." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16677,6 +18777,12 @@ "value" : "Standortfreigabe aktivieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitar compartir ubicación" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -16699,6 +18805,12 @@ "value" : "Tillad notifikationer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitar notificaciones" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16746,6 +18858,12 @@ "value" : "Aktivér denne enhed som en Store and Forward-server. Kræver en ESP32-enhed med PSRAM." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilite este dispositivo como servidor Store and Forward. Requiere un dispositivo ESP32 con PSRAM." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16792,6 +18910,12 @@ "value" : "Aktiviert" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -16869,6 +18993,12 @@ "value" : "Aktiviert automatische TAK-PLI-Übertragungen und verringert die Anzahl der Routineübertragungen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permite transmisiones automáticas de TAK PLI y reduce las transmisiones de rutina." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -16939,6 +19069,12 @@ "value" : "Aktiverer enheder med native I2S-lydudgang til at bruge RTTTL over højttaler som en buzzer. T-Watch S3 og T-Deck har for eksempel denne kapabilitet." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permite que los dispositivos con salida de audio I2S nativa utilicen el RTTTL a través del altavoz como un timbre. T-Watch S3 y T-Deck, por ejemplo, tienen esta capacidad." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16985,6 +19121,12 @@ "value" : "Aktiviert den blauen Standort-Punkt für dein Handy in der Mesh-Karte." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilita el punto de ubicación azul para su teléfono en el mapa de malla." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -17007,6 +19149,12 @@ "value" : "Aktiverer detektionssensormodulet. Det skal være aktiveret både på noden med sensoren og på alle noder, hvor du ønsker at modtage detektionssensor-tekstbeskeder eller se detektionssensorloggen og diagrammet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilita el módulo del sensor de detección; debe estar habilitado tanto en el nodo con el sensor como en cualquier nodo en el que desee recibir mensajes de texto del sensor de detección o ver el registro y el gráfico del sensor de detección." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17053,6 +19201,12 @@ "value" : "Aktiverer butiks- og videresendelsesmodulet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilita el módulo de almacenamiento y reenvío." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17093,6 +19247,12 @@ "value" : "Aktivering af Ethernet vil deaktivere bluetooth-forbindelsen til appen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Al habilitar Ethernet se deshabilitará la conexión bluetooth a la aplicación." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17127,6 +19287,12 @@ "value" : "Aktivering af WiFi vil deaktivere Bluetooth-forbindelsen til appen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitar WiFi deshabilitará la conexión bluetooth a la aplicación." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -17149,6 +19315,12 @@ "value" : "Encoder trykhændelse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evento de prensa del codificador" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17195,6 +19367,12 @@ "value" : "Verschlüsselt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cifrado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -17272,6 +19450,12 @@ "value" : "Verschlüsseltes Senden fehlgeschlagen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error de envío cifrado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17306,6 +19490,12 @@ "value" : "Kryptering aktiveret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cifrado habilitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17358,6 +19548,12 @@ "value" : "DFÜ-Modus aktivieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingrese al modo DFU" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17400,6 +19596,12 @@ "comment" : "A label for a text field where the user can enter a hostname or IP address and optionally a port number.", "isCommentAutoGenerated" : true, "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introduzca el nombre de host[:puerto]" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -17430,6 +19632,12 @@ "value" : "Umgebung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "medio ambiente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17482,6 +19690,12 @@ "value" : "Umgebung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medio ambiente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17529,6 +19743,12 @@ "value" : "Miljødata" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métricas ambientales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17569,6 +19789,12 @@ }, "Environment Metrics Enabled" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métricas de entorno habilitadas" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -17585,6 +19811,12 @@ "value" : "Miljødata-log" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de métricas ambientales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17625,6 +19857,12 @@ }, "Environment Sensor Options" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de sensores ambientales" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -17647,6 +19885,12 @@ "value" : "Alle App-Daten löschen?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Borrar todos los datos de la aplicación?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17699,6 +19943,12 @@ "value" : "Alle Geräte- und App-Daten löschen?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Borrar todos los datos del dispositivo y de las aplicaciones?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17745,6 +19995,12 @@ "value" : "Fejl: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17791,6 +20047,12 @@ "value" : "ESP 32 OTA-opdatering er et igangværende arbejde, klik på knappen nedenfor for at sende din enhed en genstart til ota admin-besked" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La actualización de ESP 32 OTA es un trabajo en progreso, haga clic en el botón a continuación para enviar su dispositivo a un reinicio en el mensaje de administrador de ota." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17837,6 +20099,12 @@ "value" : "ESP32-enhedens firmwareopdatering" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualización del firmware del dispositivo ESP32" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17883,6 +20151,12 @@ "value" : "Ethernet-indstillinger" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de Ethernet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17924,6 +20198,12 @@ "value" : "EU 433 MHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unión Europea 433MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17965,6 +20245,12 @@ "value" : "EU 868 MHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unión Europea 868MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18012,6 +20298,12 @@ "value" : "Abend" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tarde" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18052,6 +20344,12 @@ "value" : "Byt Positioner" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posiciones de intercambio" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18086,6 +20384,12 @@ }, "Exchange User Info" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Información de usuario de Exchange" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -18109,6 +20413,12 @@ "value" : "Ausrufezeichen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "exclamación" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -18173,6 +20483,12 @@ }, "Expiration" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caducidad" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -18207,6 +20523,12 @@ "value" : "Zeitpunkt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caducar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18253,6 +20575,12 @@ "value" : "Automatisches Löschen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vence" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18293,6 +20621,12 @@ "value" : "Udløber: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expira: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18339,6 +20673,12 @@ "value" : "Exportieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18385,6 +20725,12 @@ "value" : "Externe Benachrichtigung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificación externa" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -18461,6 +20807,12 @@ "value" : "Einstellungen der externen Benachrichtigung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de notificación externa" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -18532,6 +20884,12 @@ "value" : "Moduletilkonfiguration for ekstern meddelelse modtaget: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo de notificación externa recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -18608,6 +20966,12 @@ "value" : "Werkseinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecimiento de fábrica" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18642,6 +21006,12 @@ }, "Factory reset will delete device and app data." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El restablecimiento de fábrica eliminará los datos del dispositivo y de la aplicación." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18677,6 +21047,12 @@ "value" : "Kunne ikke kode meddelelsens indhold" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo codificar el contenido del mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18711,6 +21087,12 @@ }, "Failed to exchange user info." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo intercambiar información de usuario." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -18727,6 +21109,12 @@ "value" : "Kunne ikke få en gyldig position til udveksling" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo obtener una posición válida para intercambiar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18767,6 +21155,12 @@ "value" : "Kunne ikke få en gyldig position til at bytte." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo obtener una posición válida para intercambiar." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18814,6 +21208,12 @@ "value" : "Ordentliche Signalstärke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feria" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18860,6 +21260,12 @@ "value" : "Favorit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorito" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18901,6 +21307,12 @@ "value" : "Knoten, die als Favorit markiert oder ignoriert wurden, bleiben immer erhalten. Knoten ohne PKC-Schlüssel werden gemäß dem festgelegten Zeitplan aus der App-Datenbank gelöscht. Knoten mit PKC-Schlüsseln werden nur gelöscht, wenn das Intervall auf 7 Tage oder länger eingestellt ist. Diese Funktion löscht nur Knoten aus der App, die nicht in der Geräteknoten-Datenbank gespeichert sind." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los nodos favoritos e ignorados siempre se conservan. Los nodos sin claves PKC se borran de la base de datos de la aplicación según el cronograma establecido por el usuario, los nodos con claves PKC se borran solo si el intervalo se establece en 7 días o más. Esta función solo elimina los nodos de la aplicación que no están almacenados en la base de datos de nodos del dispositivo." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -18917,6 +21329,12 @@ }, "Favorited and ignored nodes are always retained. Other nodes are cleared from the app database on the schedule set by the user. (Nodes with PKC keys are always retained for at least 7 days.) This feature only purges nodes from the app that are not stored in the device node database." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los nodos favoritos e ignorados siempre se conservan. Otros nodos se borran de la base de datos de la aplicación según el cronograma establecido por el usuario. (Los nodos con claves PKC siempre se conservan durante al menos 7 días). Esta función solo elimina los nodos de la aplicación que no están almacenados en la base de datos de nodos del dispositivo." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -18939,6 +21357,12 @@ "value" : "Favoriten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoritos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18985,6 +21409,12 @@ "value" : "Favoriten und Knoten mit aktuellen Nachrichten werden oben in der Kontaktliste angezeigt." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los favoritos y los nodos con mensajes recientes aparecen en la parte superior de la lista de contactos." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19037,6 +21467,12 @@ "value" : "Letzte Position eines Knotens holen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtener la última posición de un nodo determinado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19077,6 +21513,12 @@ "value" : "Femten minutter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "quince minutos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19124,6 +21566,12 @@ "value" : "Fünfzehn Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "quince segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -19194,6 +21642,12 @@ "value" : "Filopbevaring" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Almacenamiento de archivos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19229,6 +21683,12 @@ "Files Available" : { "comment" : "Data source label when files exist but none are active", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archivos disponibles" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -19251,6 +21711,12 @@ "value" : "Filtere die Knotenliste und die Mesh-Karte nach der Nähe zu deinem Handy." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtre la lista de nodos y el mapa de malla según la proximidad a su teléfono." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -19279,6 +21745,12 @@ "value" : "Kontakt suchen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "encontrar un contacto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19325,6 +21797,12 @@ "value" : "Einen Knoten finden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encuentra un nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19371,6 +21849,12 @@ "value" : "Beenden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "terminar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -19442,6 +21926,12 @@ "value" : "Ziel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "terminar" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -19470,6 +21960,12 @@ "value" : "Firmware" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "firmware" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19516,6 +22012,12 @@ "value" : "Firmware opdateringsdokumenter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documentos de actualización de firmware" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19568,6 +22070,12 @@ "value" : "Firmwareaktualisierungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizaciones de firmware" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19620,6 +22128,12 @@ "value" : "Firmware Version" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión de firmware" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -19690,6 +22204,12 @@ "value" : "Første gang hørt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "escuchado por primera vez" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19737,6 +22257,12 @@ "value" : "Fünf Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cinco horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -19813,6 +22339,12 @@ "value" : "Fünf Minuten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cinco minutos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19860,6 +22392,12 @@ "value" : "Fünf Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cinco segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -19936,6 +22474,12 @@ "value" : "Feste PIN" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pasador fijo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20006,6 +22550,12 @@ "value" : "Fast position" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posición fija" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20046,6 +22596,12 @@ "value" : "Vend Skærm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltear pantalla" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20086,6 +22642,12 @@ "value" : "Vend skærm lodret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltear la pantalla verticalmente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20133,6 +22695,12 @@ "value" : "Folgen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguir" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20210,6 +22778,12 @@ "value" : "Folgen mit Steuerkurs" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguir con encabezado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20280,6 +22854,12 @@ "value" : "For al MQTT-funktionalitet bortset fra kortrapporten skal du også indstille uplink og downlink for hver kanal, du vil forbinde til, over MQTT." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Para todas las funciones de Mqtt además del informe de mapa, también debe configurar el enlace ascendente y descendente para cada canal que desee conectar a través de Mqtt." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20332,6 +22912,12 @@ "value" : "Für alle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Para todos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20378,6 +22964,12 @@ "value" : "Für mich" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "para mi" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20425,6 +23017,12 @@ "value" : "Achtundvierzig Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cuarenta y ocho horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20502,6 +23100,12 @@ "value" : "Fündundvierzig Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cuarenta y cinco segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20579,6 +23183,12 @@ "value" : "Vier Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cuatro horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20656,6 +23266,12 @@ "value" : "Vier Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cuatro segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20732,6 +23348,12 @@ "value" : "Frequenz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frecuencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20772,6 +23394,12 @@ "value" : "Frekvensoverride" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anulación de frecuencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20812,6 +23440,12 @@ "value" : "Frekvensplads" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ranura de frecuencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20852,6 +23486,12 @@ "value" : "Venligt navn" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre amigable" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20898,6 +23538,12 @@ "value" : "Venligt navn, der bruges til at formatere beskeder sendt til mesh. Eksempel: Et navn \"Motion\" ville resultere i en besked \"Motion detected\"" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre descriptivo utilizado para formatear el mensaje enviado a la malla. Ejemplo: un nombre \"Movimiento\" daría como resultado un mensaje \"Movimiento detectado\"." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20938,6 +23584,12 @@ }, "From Radio (RX): %lld" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "De Radio (RX): %lld" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -20960,6 +23612,12 @@ "value" : "Fuld support" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Soporte completo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20995,6 +23653,12 @@ "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genere una nueva clave privada para reemplazar la que está actualmente en uso. La clave pública se regenerará automáticamente a partir de su clave privada." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -21029,6 +23693,12 @@ "value" : "QR Code Erzeugen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generar código QR" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -21099,6 +23769,12 @@ "value" : "Få brugerdefinerede vandtætte sol- og detektionssensorroutere, aluminium desktop-noder og robuste håndsæt." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtenga nodos de enrutador de sensores de detección y solares impermeables personalizados, nodos de escritorio de aluminio y teléfonos resistentes." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21145,6 +23821,12 @@ "value" : "Knotenposition ermitteln" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtener la posición del nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21191,6 +23873,12 @@ "value" : "Hent NRF DFU fra App Store" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtenga NRF DFU en la App Store" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21237,6 +23925,12 @@ "value" : "Los geht's" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "empezar" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -21259,6 +23953,12 @@ "value" : "Hent den nyeste stabile firmware" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtenga el firmware estable más reciente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21299,6 +23999,12 @@ }, "GitHub Repository" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repositorio GitHub" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -21328,6 +24034,12 @@ "value" : "Godt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bueno" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21368,6 +24080,12 @@ "value" : "GPIO" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21414,6 +24132,12 @@ "value" : "GPIO-outputvarighed" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duración de la salida GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21454,6 +24178,12 @@ "value" : "GPIO-pin for drejeenkoder A-port" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin GPIO para el puerto A del codificador rotatorio." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21494,6 +24224,12 @@ "value" : "GPIO-pin til drejeenkoder B-port." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin GPIO para el puerto B del codificador rotatorio." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21534,6 +24270,12 @@ "value" : "GPIO-pin til roterende enkoder Press-port" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin GPIO para codificador rotatorio Puerto de prensa." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21574,6 +24316,12 @@ "value" : "GPIO-pin til overvågning" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin GPIO para monitorear" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21614,6 +24362,12 @@ "value" : "GPS PÅ GPIO" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS Y GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21660,6 +24414,12 @@ "value" : "GPS Indgang GPIO" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recepción GPS GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21706,6 +24466,12 @@ "value" : "GPS Send GPIO" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmisión GPS GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21758,6 +24524,12 @@ "value" : "Gruppennachricht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensaje grupal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21804,6 +24576,12 @@ "value" : "Stød %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ráfagas %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21845,6 +24623,12 @@ "value" : "HaHa" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jaja" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -21891,6 +24675,12 @@ }, "Hard Reset" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecimiento completo" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -21913,6 +24703,12 @@ "value" : "Hardware" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ferretería" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21960,6 +24756,12 @@ "value" : "Farlig" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peligroso" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22000,6 +24802,12 @@ "value" : "Retning" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "rumbo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22046,6 +24854,12 @@ "value" : "Kurs: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Título: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22093,6 +24907,12 @@ "value" : "Gehört" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "escuchado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -22170,6 +24990,12 @@ "value" : "Herz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "corazón" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -22240,6 +25066,12 @@ "value" : "Skjul alarmer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar alertas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22280,6 +25112,12 @@ "value" : "Skjul Alarmer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar alertas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22326,6 +25164,12 @@ "value" : "HOCH" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALTA" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22379,6 +25223,12 @@ "value" : "Wandern" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Senderismo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22425,6 +25275,12 @@ "value" : "Historik Return Max" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historial Retorno Max" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22465,6 +25321,12 @@ "value" : "Vindue for historikreturnering" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ventana de retorno del historial" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22511,6 +25373,12 @@ "value" : "Hops Entfernt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "salta lejos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22557,6 +25425,12 @@ "value" : "Hops Entfernt %d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salta lejos %d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22604,6 +25478,12 @@ "value" : "Hops Entfernt:" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saltos lejos:" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22650,6 +25530,12 @@ "value" : "Hops Entfernt: %d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salta lejos: %d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22696,6 +25582,12 @@ "value" : "Stunde" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hora" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22742,6 +25634,12 @@ "value" : "Driftcyklus pr. time" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciclo de trabajo por hora" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22782,6 +25680,12 @@ "value" : "Hvor lang tid skærmen forbliver tændt, efter brugeren har trykket på knappen, eller meddelelser er modtaget." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuánto tiempo permanece encendida la pantalla después de presionar el botón de usuario o recibir mensajes." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22828,6 +25732,12 @@ "value" : "Hvor ofte enhedens metrik sendes ud over mesh-netværket. Standard er 30 minutter." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con qué frecuencia se envían las métricas del dispositivo a través de la malla. El valor predeterminado es 30 minutos." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22874,6 +25784,12 @@ "value" : "Hvor ofte miljømålinger sendes ud over netværket. Standard er 30 minutter." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con qué frecuencia se envían métricas ambientales a través de la malla. El valor predeterminado es 30 minutos." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22920,6 +25836,12 @@ "value" : "Hvor ofte effektmålinger sendes ud over mesh-netværket. Standardindstillingen er 30 minutter." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con qué frecuencia se envían métricas de potencia a través de la malla. El valor predeterminado es 30 minutos." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22966,6 +25888,12 @@ "value" : "Hvor ofte skal vi forsøge at få en GPS-position" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Con qué frecuencia debemos intentar obtener una posición GPS?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23012,6 +25940,12 @@ "value" : "Hvor ofte tilstanden for detektionssensoren skal sendes til mesh uanset detektion. Standard er Aldrig." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con qué frecuencia enviar el estado del sensor de detección a la malla independientemente de la detección. El valor predeterminado es Nunca." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23064,6 +25998,12 @@ "value" : "How often we can send a message to the mesh when people are detected." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con qué frecuencia podemos enviar un mensaje a la malla cuando se detectan personas." + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -23134,6 +26074,12 @@ "value" : "Wie wird die Firmware aktualisiert" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cómo actualizar el firmware" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23181,6 +26127,12 @@ "value" : "Brum" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tararear" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23227,6 +26179,12 @@ "value" : "Luftfeuchtigkeit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Humedad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23268,6 +26226,12 @@ "value" : "Hybrid" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Híbrido" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -23339,6 +26303,12 @@ "value" : "Hybrid Luftfoto" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paso elevado híbrido" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -23409,6 +26379,12 @@ "value" : "Jeg har læst og forstået ovenstående. Jeg giver frivilligt samtykke til ukrypteret transmission af mine node-data via MQTT." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "He leído y entiendo lo anterior. Doy mi consentimiento voluntariamente para la transmisión sin cifrar de los datos de mi nodo a través de MQTT." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -23443,6 +26419,12 @@ "value" : "IAQ" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23489,6 +26471,12 @@ "value" : "IAQ " } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ " + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23535,6 +26523,12 @@ "value" : "IAQ %lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ %lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23587,6 +26581,12 @@ "value" : "Emoji" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icono" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23627,6 +26627,12 @@ }, "Icons" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iconos" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -23649,6 +26655,12 @@ "value" : "Hvis DOP er indstillet, brug HDOP / VDOP værdier i stedet for PDOP" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si se configura DOP, use valores HDOP/VDOP en lugar de PDOP" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23695,6 +26707,12 @@ "value" : "Hvis aktiveret, vil 'output'-pinden blive trukket aktiv høj, deaktiveret betyder aktiv lav" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si está habilitado, el pin de 'salida' se activará alto, deshabilitado significa activo bajo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23741,6 +26759,12 @@ "value" : "Hvis det er svært at få adgang til din enheds nulstillingsknap, skal du gå ind i DFU-tilstand her." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si le resulta difícil acceder al botón de reinicio de su dispositivo, ingrese al modo DFU aquí." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23787,6 +26811,12 @@ "value" : "Hvis indstillet, vil alle pakker, du sender, blive sendt tilbage til din enhed." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si está configurado, cualquier paquete que envíe se enviará a su dispositivo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23833,6 +26863,12 @@ "value" : "Hvis standardregionsemnet er for travlt, kan du vælge et mere lokalt emne." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si el tema de la región predeterminada está demasiado ocupado, puede elegir un tema más local." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23879,6 +26915,12 @@ "value" : "Ignorer MQTT" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorar MQTT" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23925,6 +26967,12 @@ "value" : "Ignorer node" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorar nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23971,6 +27019,12 @@ "value" : "Ignoreret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ignorado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24018,6 +27072,12 @@ "value" : "Ignorerer observerede meddelelser fra fremmede mesh-netværk ligesom kun lokale, men tager det et skridt videre ved også at ignorere meddelelser fra noder, der ikke allerede er på nodens kendte liste." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignora los mensajes observados de mallas externas como Solo local, pero va un paso más allá al ignorar también los mensajes de nodos que aún no están en la lista conocida del nodo." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -24053,6 +27113,12 @@ "value" : "Ignorerer observerede meddelelser fra fremmede netværk, der er åbne, eller dem, som den ikke kan dekryptere. Genudsender kun meddelelser på noderne lokale primære / sekundære kanaler." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignora los mensajes observados de mallas externas que están abiertas o aquellas que no puede descifrar. Sólo retransmite mensajes en los canales primarios/secundarios locales del nodo." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -24097,6 +27163,12 @@ "value" : "Route importieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de importación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24137,6 +27209,12 @@ }, "In addition to Config, Keys and BLE bonds will be wiped" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Además de la configuración, se borrarán las claves y los enlaces BLE." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -24165,6 +27243,12 @@ "value" : "Include" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "incluir" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -24235,6 +27319,12 @@ "value" : "Eingehende Nachrichten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensajes entrantes" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -24258,6 +27348,12 @@ "value" : "Unvollständig" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Incompleto" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -24305,6 +27401,12 @@ "value" : "Indien" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "India" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -24340,6 +27442,12 @@ "value" : "Indendørs luftkvalitet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calidad del aire interior" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24386,6 +27494,12 @@ "value" : "Indeklimakvalitet (IAQ)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calidad del aire interior (IAQ)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24439,6 +27553,12 @@ "value" : "Router - Mesh Pakete werden bevorzugt über diesen Knoten gerouted. Dieser Knoten wird nicht von einer Client App benutzt. WLAN, Bluetooth und Display sind aus." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodo de infraestructura únicamente en una torre o cima de una montaña. No debe usarse para techos o nodos móviles. Necesita una cobertura excepcional. Visible en la lista de nodos." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -24509,6 +27629,12 @@ "value" : "Input" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entradas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24555,6 +27681,12 @@ "value" : "Ungültiger Dateiinhalt. Bitte überprüfe das Dateiformat." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contenido del archivo no válido. Por favor verifique el formato del archivo." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -24578,6 +27710,12 @@ "value" : "Omvendt topbjælke til 2-farvevisning" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Barra superior invertida para pantalla de 2 colores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24625,6 +27763,12 @@ "value" : "Japan" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Japón" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24665,6 +27809,12 @@ "value" : "JSON aktiveret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON habilitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24711,6 +27861,12 @@ "value" : "JSON-tilstand er en begrænset, ukrypteret MQTT-udgang til lokal integration med Home Assistant" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El modo JSON es una salida MQTT limitada y sin cifrar para la integración local con el asistente doméstico" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24757,6 +27913,12 @@ "value" : "Gå til nutid" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saltar al presente" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -24797,6 +27959,12 @@ "value" : "Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "clave" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24837,6 +28005,12 @@ }, "Key Backup" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copia de seguridad clave" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -24865,6 +28039,12 @@ "value" : "Tastetilknytning" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mapeo de claves" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24911,6 +28091,12 @@ "value" : "Schlüsselgröße" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamaño de clave" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24952,6 +28138,12 @@ "value" : "Korea" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corea" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24998,6 +28190,12 @@ "value" : "Zuletzt gehört" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escuchado por última vez" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25040,6 +28238,12 @@ "comment" : "A label displayed next to the last seen device text in the `DeviceConnectRow`.", "isCommentAutoGenerated" : true, "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo visto por última vez:" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -25050,6 +28254,12 @@ }, "Last seen device: %@" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Último dispositivo visto: %@" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -25072,6 +28282,12 @@ "value" : "Breitengrad" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latitud" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25112,6 +28328,12 @@ }, "Latitude in degrees (e.g., 37.7749)" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latitud en grados (por ejemplo, 37,7749)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -25134,6 +28356,12 @@ }, "Latitude must be between -90 and 90 degrees" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La latitud debe estar entre -90 y 90 grados." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -25162,6 +28390,12 @@ "value" : "LED-hjertebanken" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latido del corazón LED" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25214,6 +28448,12 @@ "value" : "LED Status" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado del LED" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25267,6 +28507,12 @@ "value" : "Links" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Izquierda" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -25343,6 +28589,12 @@ "value" : "Level" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nivel" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -25413,6 +28665,12 @@ "value" : "Licenseret operatør" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Operador Licenciado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25459,6 +28717,12 @@ "value" : "Begræns alle periodiske udsendelsesintervaller, især telemetri og position. Hvis du har brug for at øge antallet af hop, skal du gøre det på noder i kanterne, ikke dem i midten. MQTT anbefales ikke, når du er begrænset af duty cycle, fordi gateway-noden så udfører alt arbejdet." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limite todos los intervalos de transmisión periódica, especialmente la telemetría y la posición. Si necesita aumentar los saltos, hágalo en los nodos de los bordes, no en los del medio. No se recomienda MQTT cuando el ciclo de trabajo está restringido porque el nodo de puerta de enlace está haciendo todo el trabajo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25505,6 +28769,12 @@ "value" : "Linjeserie" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serie de línea" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25545,6 +28815,12 @@ "value" : "Indlæser logfiler. . ." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cargando registros. . ." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25587,6 +28863,12 @@ "comment" : "A label displayed above the options for local network access.", "isCommentAutoGenerated" : true, "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acceso a la red local" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -25615,6 +28897,12 @@ "value" : "Standort:" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ubicación:" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25667,6 +28955,12 @@ "value" : "Gesperrt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bloqueado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25713,6 +29007,12 @@ "value" : "Logniveauer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niveles de registro" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25765,6 +29065,12 @@ "value" : "Logging" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -25835,6 +29141,12 @@ "value" : "Logfiler" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25882,6 +29194,12 @@ "value" : "Logfiler:" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros:" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25934,6 +29252,12 @@ "value" : "Langer Name" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre largo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25986,6 +29310,12 @@ "value" : "Durch langes Gedrückthalten kannst du den Kontakt zu deinen Favoriten hinzufügen, stumm schalten oder eine Unterhaltung löschen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mantenga presionado para marcar como favorito, silenciar el contacto o eliminar una conversación." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26033,6 +29363,12 @@ "value" : "Lang Rækkevidde - Hurtig" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largo alcance - Rápido" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26074,6 +29410,12 @@ "value" : "Lang rækkevidde - Moderat" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largo alcance - Moderado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26115,6 +29457,12 @@ "value" : "Lang rækkevidde - Langsom" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largo alcance - Lento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26161,6 +29509,12 @@ "value" : "Längengrad" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Longitud" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26201,6 +29555,12 @@ }, "Longitude in degrees (e.g., -122.4194)" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Longitud en grados (p. ej., -122,4194)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -26223,6 +29583,12 @@ }, "Longitude must be between -180 and 180 degrees" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La longitud debe estar entre -180 y 180 grados." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -26257,6 +29623,12 @@ "value" : "LoRa" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "lora" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -26333,6 +29705,12 @@ "value" : "LoRa Einstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración LoRa" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -26397,6 +29775,12 @@ }, "LoRa Config Changes:" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambios en la configuración de LoRa:" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -26426,6 +29810,12 @@ "value" : "LoRa config empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de LoRa recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -26503,6 +29893,12 @@ "value" : "Tracker" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objetos perdidos y encontrados" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26549,6 +29945,12 @@ "value" : "LAV" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "BAJO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26589,6 +29991,12 @@ "value" : "Niedriger Akkustand" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batería baja" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -26618,6 +30026,12 @@ "value" : "M5 Stack Card KB / RAK Tastenfeld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarjeta de pila M5 Teclado KB / RAK" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -26671,6 +30085,12 @@ "value" : "Malaysia 433 MHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malasia 433MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26712,6 +30132,12 @@ "value" : "Malaysia 919MHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malasia 919MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26758,6 +30184,12 @@ "value" : "Kanäle verwalten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrar canales" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -26823,6 +30255,12 @@ "Manage custom map overlays" : { "comment" : "Subtitle for map data management", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrar superposiciones de mapas personalizados" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26870,6 +30308,12 @@ "value" : "Kartendaten verwalten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrar datos de mapas" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -26892,6 +30336,12 @@ "value" : "Administreret enhed" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo administrado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26932,6 +30382,12 @@ }, "Manual" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "manuales" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -26961,6 +30417,12 @@ "value" : "Manuelle Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración manual" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -27025,6 +30487,12 @@ }, "Manual connection string" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cadena de conexión manual" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -27041,6 +30509,12 @@ }, "Manual Connections" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conexiones manuales" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -27051,6 +30525,12 @@ }, "Map Data" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos del mapa" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -27079,6 +30559,12 @@ "value" : "Kartenoptionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de mapa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27125,6 +30611,12 @@ "value" : "Karten-Overlays" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Superposiciones de mapas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -27196,6 +30688,12 @@ "value" : "Kortudgivelsesinterval" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de publicación de mapas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27242,6 +30740,12 @@ "value" : "Kortrapport" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informe de mapa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27295,6 +30799,12 @@ "value" : "Maximale Wiederholungen erreicht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retransmisión máxima alcanzada" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -27366,6 +30876,12 @@ "value" : "Mellem rækkevidde - Hurtig" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rango medio - Rápido" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27407,6 +30923,12 @@ "value" : "Mellem rækkevidde - Langsom" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rango medio - Lento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27447,6 +30969,12 @@ "value" : "Opdatering af meshningsaktivitet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualización de actividad de malla" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27493,6 +31021,12 @@ "value" : "Mesh Live Aktivität" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actividad en vivo de malla" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -27569,6 +31103,12 @@ "value" : "Mesh Karte" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mapa de malla" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -27639,6 +31179,12 @@ "value" : "Standort auf der Mesh-Karte" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ubicación del mapa de malla" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -27655,6 +31201,12 @@ }, "Meshtastic" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtástico" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -27671,6 +31223,12 @@ }, "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic no recopila ninguna información personal. Recopilamos de forma anónima datos de uso y fallos para mejorar la aplicación. Puede optar por no participar en la configuración de la aplicación." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -27693,6 +31251,12 @@ "value" : "Meshtastic Knoten %@ hat Kanäle mit dir geteilt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic Node %@ ha compartido canales contigo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27733,6 +31297,12 @@ "value" : "Meshtastic verwendet den Standort deines Handys, um eine Reihe von Funktionen zu ermöglichen. Du kannst deine Standortberechtigungen jederzeit in den Einstellungen aktualisieren." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic utiliza la ubicación de su teléfono para habilitar una serie de funciones. Puede actualizar sus permisos de ubicación en cualquier momento desde la configuración." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -27755,6 +31325,12 @@ "value" : "Meshtastic® er copyright Meshtastic LLC" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic® Copyright Meshtastic LLC" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27801,6 +31377,12 @@ "value" : "Nachricht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27847,6 +31429,12 @@ "value" : "Nachrichteninhalt überschreitet 200 Bytes." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El contenido del mensaje supera los 200 bytes." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27893,6 +31481,12 @@ "value" : "Nachrichtendetails" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalles del mensaje" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -27970,6 +31564,12 @@ "value" : "Nachricht von der Textnachricht-App empfangen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensaje recibido de la aplicación de mensajes de texto." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28035,6 +31635,12 @@ "Message Size" : { "comment" : "VoiceOver label for message size", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamaño del mensaje" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -28063,6 +31669,12 @@ "value" : "Beskedstatusindstillinger" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de estado del mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28109,6 +31721,12 @@ "value" : "Nachrichten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensajes" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -28179,6 +31797,12 @@ "value" : "Nachrichten getrennt mit |" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los mensajes se separan con |" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28213,6 +31837,12 @@ }, "Messaging" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensajería" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -28242,6 +31872,12 @@ "value" : "Metrisk" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métrica" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28289,6 +31925,12 @@ "value" : "Mittag" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mediodía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28335,6 +31977,12 @@ "value" : "Minimum Distanz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distancia mínima" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28382,6 +32030,12 @@ "value" : "Minimum Intervall" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo mínimo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28423,6 +32077,12 @@ "value" : "Minimum tid mellem detektionsudsendelser" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo mínimo entre transmisiones de detección" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28463,6 +32123,12 @@ "value" : "Minimaltid mellem detektion broadcasts. Standard er 45 sekunder." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo mínimo entre transmisiones de detección. El valor predeterminado es 45 segundos." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28509,6 +32175,12 @@ "value" : "Modus" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28579,6 +32251,12 @@ "value" : "Model" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "modelo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28626,6 +32304,12 @@ "value" : "Moderat" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "moderado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28678,6 +32362,12 @@ "value" : "Modul Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28755,6 +32445,12 @@ "value" : "Morgen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mañana" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28801,6 +32497,12 @@ "value" : "Die meisten Daten in deinem Mesh werden über den primären Kanal gesendet. Du kannst sekundäre Kanäle einrichten, um zusätzliche Nachrichtengruppen zu erstellen, die durch ihren eigenen Schlüssel gesichert sind. [Tipps zur Kanalkonfiguration](https://meshtastic.org/docs/configuration/radio/channels/)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La mayoría de los datos de su malla se envían a través del canal principal. Puede configurar canales secundarios para crear grupos de mensajería adicionales protegidos por su propia clave. [Consejos de configuración de canales](https://meshtastic.org/docs/configuration/tips/)" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28871,6 +32573,12 @@ "value" : "MQTT" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28923,6 +32631,12 @@ "value" : "MQTT Client Proxy" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proxy de cliente MQTT" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28999,6 +32713,12 @@ "value" : "MQTT Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración MQTT" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -29076,6 +32796,12 @@ "value" : "MQTT Modulkonfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo MQTT recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -29153,6 +32879,12 @@ "value" : "Multiplier" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplicador" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -29211,6 +32943,12 @@ "value" : "Skal være en enkelt emoji" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debe ser un solo emoji" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29258,6 +32996,12 @@ "value" : "MyInfo empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mi información recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -29329,6 +33073,12 @@ "value" : "Banke-timeout" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo de espera de molestia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29369,6 +33119,12 @@ "value" : "Name" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29421,6 +33177,12 @@ "value" : "Name muss kürzer als 30 Bytes sein" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El nombre debe tener menos de 30 bytes." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29467,6 +33229,12 @@ "value" : "Rutevejvisning til node" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navegar al nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29507,6 +33275,12 @@ "value" : "Nærliggende emner" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temas cercanos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29553,6 +33327,12 @@ "value" : "Netzwerk" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Red" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -29629,6 +33409,12 @@ "value" : "Netzwerkeinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de red" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -29706,6 +33492,12 @@ "value" : "Netzwerkkonfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de red recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -29776,6 +33568,12 @@ "value" : "Netværksstatus Orange" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado de la red naranja" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29822,6 +33620,12 @@ "value" : "Netværksstatus rød" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado de la red Rojo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29869,6 +33673,12 @@ "value" : "Ny node" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuevo nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29910,6 +33720,12 @@ "value" : "Ny node fundet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se ha descubierto un nuevo nodo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29950,6 +33766,12 @@ "value" : "Neue Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuevos nodos" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -29973,6 +33795,12 @@ "value" : "New Zealand 865MHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nueva Zelanda 865MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30019,6 +33847,12 @@ "value" : "Neuere Firmware ist verfügbar" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hay un firmware más nuevo disponible" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30072,6 +33906,12 @@ "value" : "Nacht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noche" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30119,6 +33959,12 @@ "value" : "NMEA Positionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posiciones NMEA" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -30196,6 +34042,12 @@ "value" : "Kein Kanal" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin canal" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -30272,6 +34124,12 @@ "value" : "Kein verbundener Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ningún nodo conectado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30313,6 +34171,12 @@ "value" : "Keine Daten vorhanden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin datos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30365,6 +34229,12 @@ "value" : "Kein Gerät verbunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ningún dispositivo conectado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -30435,6 +34305,12 @@ "value" : "Ingen enhedsdata" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin métricas de dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30475,6 +34351,12 @@ "value" : "Ingen miljødata" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin métricas ambientales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30521,6 +34403,12 @@ "value" : "Keine Dateien hochgeladen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se subieron archivos" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -30550,6 +34438,12 @@ "value" : "Keine Schnittstelle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin interfaz" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -30615,6 +34509,12 @@ "No map data files uploaded" : { "comment" : "Message when no files are uploaded", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se han subido archivos de datos de mapas" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -30637,6 +34537,12 @@ "value" : "Ingen PAX-logfiler" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin registros de contador de PAX" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30690,6 +34596,12 @@ "value" : "Keine PIN (geht einfach)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin PIN (simplemente funciona)" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -30766,6 +34678,12 @@ "value" : "Keine Positionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin posiciones" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30806,6 +34724,12 @@ "value" : "Ingen energidata" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin métricas de energía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30853,6 +34777,12 @@ "value" : "Keine Antwort" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin respuesta" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -30930,6 +34860,12 @@ "value" : "Keine Route" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin ruta" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -31006,6 +34942,12 @@ "value" : "Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31052,6 +34994,12 @@ "value" : "Node Core Data Backup %1$@/%2$@ - %3$@ - %4$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copia de seguridad de datos del núcleo del nodo %@/%@ - %@ - %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31105,6 +35053,12 @@ "value" : "Knoten hat keine Position" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El nodo no tiene posiciones" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31151,6 +35105,12 @@ "value" : "Knoten Historie" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historia del nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31192,6 +35152,12 @@ "value" : "Node Info Broadcast Interval" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de transmisión de información de nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31238,6 +35204,12 @@ "value" : "Knotenkarte" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mapa de nodos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31284,6 +35256,12 @@ "value" : "Knotennummer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Número de nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31330,6 +35308,12 @@ "value" : "Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -31401,6 +35385,12 @@ "value" : "Knoten (%@)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodos (%@)" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -31478,6 +35468,12 @@ "value" : "Keins" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ninguno" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -31548,6 +35544,12 @@ "value" : "Ikke en gyldig rute-fil" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No es un archivo de ruta válido" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31595,6 +35597,12 @@ "value" : "Nicht authorisiert" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No autorizado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -31666,6 +35674,12 @@ "value" : "Ikke til stede" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No presente" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -31736,6 +35750,12 @@ "value" : "Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31776,6 +35796,12 @@ "value" : "Mitteilungen für Kanal- und Direktnachrichten." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificaciones por canal y mensajes directos." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -31798,6 +35824,12 @@ "value" : "Mitteilungen bei niedrigem Akkustand des verbundenen Funkgeräts." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificaciones de alertas de batería baja para el dispositivo conectado." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -31820,6 +35852,12 @@ "value" : "Mitteilungen für neu entdeckte Knoten." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificaciones para nodos recién descubiertos." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -31848,6 +35886,12 @@ "value" : "Anzahl Hops" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Número de saltos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31894,6 +35938,12 @@ "value" : "Anzahl Einträge" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Número de registros" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31940,6 +35990,12 @@ "value" : "Anzahl Satelliten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Número de satélites" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31974,6 +36030,12 @@ }, "Ok" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ok" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -32002,6 +36064,12 @@ "value" : "Ok" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32042,6 +36110,12 @@ "value" : "OK til MQTT" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ok para MQTT" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32094,6 +36168,12 @@ "value" : "OLED Typ" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo OLED" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32147,6 +36227,12 @@ "value" : "Nur beim Starten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sólo en el arranque" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32217,6 +36303,12 @@ "value" : "Onboarding af licenserede operatører kræver firmware 2.0.20 eller nyere. Sørg for at henvise til dine lokale regler og kontakt de lokale amatørfrekvenskoordinatorer med spørgsmål." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La incorporación de operadores con licencia requiere firmware 2.0.20 o superior. Asegúrese de consultar las regulaciones locales y comuníquese con los coordinadores locales de frecuencias de aficionados si tiene preguntas." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32269,6 +36361,12 @@ "value" : "Eine Stunde" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "una hora" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32345,6 +36443,12 @@ "value" : "Eine Minute" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "un minuto" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32422,6 +36526,12 @@ "value" : "Eine Sekunde" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "un segundo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32498,6 +36608,12 @@ "value" : "Online" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "En línea" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32539,6 +36655,12 @@ "value" : "Kun tilladt for SENSOR-, TRACKER- og TAK_TRACKER-roller, dette vil hæmme alle genudsendelser, ikke ulig CLIENT_MUTE-rollen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo permitido para los roles SENSOR, TRACKER y TAK_TRACKER, esto inhibirá todas las retransmisiones, al igual que el rol CLIENT_MUTE." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -32574,6 +36696,12 @@ "value" : "Kun videresender pakker fra kerneportnumre: NodeInfo, Text, Position, Telemetry og Routing." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo retransmite paquetes desde los portnums principales: NodeInfo, Texto, Posición, Telemetría y Enrutamiento." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -32602,6 +36730,12 @@ }, "Open Compass" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir brújula" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -32624,6 +36758,12 @@ "value" : "Einstellungen öffnen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir configuración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32671,6 +36811,12 @@ "value" : "Optimeret til 2-farve skærme" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optimizado para pantallas de 2 colores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32718,6 +36864,12 @@ "value" : "Optimiert für ATAK-Systemkommunikation, verringert die Anzahl der Routineübertragungen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optimizado para la comunicación del sistema ATAK, reduce las transmisiones de rutina." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32788,6 +36940,12 @@ "value" : "Valgfrie felter at inkludere, når positionsmeddelelser samles. Jo flere felter, der inkluderes, jo større bliver meddelelsen - hvilket fører til længere sendetid og en højere risiko for pakkeloss" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Campos opcionales para incluir al ensamblar mensajes de posición. Cuantos más campos se incluyan, más grande será el mensaje, lo que llevará a un mayor tiempo de emisión y a un mayor riesgo de pérdida de paquetes." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32834,6 +36992,12 @@ "value" : "Valgfri GPIO" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO opcional" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32880,6 +37044,12 @@ "value" : "Optionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32950,6 +37120,12 @@ "value" : "OS-logindlægdetaljer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalles de entrada de registro del sistema operativo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32990,6 +37166,12 @@ "value" : "OTA-opdateringer understøttes ikke på denne NRF-enhed." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las actualizaciones OTA no son compatibles con este dispositivo NRF." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33036,6 +37218,12 @@ "value" : "OTA-opdateringer understøttes ikke på din platform." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las actualizaciones OTA no son compatibles con su plataforma." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33082,6 +37270,12 @@ "value" : "Andre datakilder" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otras fuentes de datos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33128,6 +37322,12 @@ "value" : "Ausgabe von Echtzeit-Fehlersuchprotokollen über die serielle Schnittstelle, Anzeige und Export von positionskorrigierten Geräteprotokollen über Bluetooth." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genere registros de depuración en vivo a través de serie, vea y exporte registros de dispositivos redactados en posición a través de Bluetooth." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33168,6 +37368,12 @@ "value" : "Output pin buzzer GPIO" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zumbador de pin de salida GPIO " + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33208,6 +37414,12 @@ "value" : "Output pin GPIO" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin de salida GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33248,6 +37460,12 @@ "value" : "Output pin vibra GPIO" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin de salida vibración GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33289,6 +37507,12 @@ "value" : "Overlanding" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por tierra" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33335,6 +37559,12 @@ "value" : "Tilsidesæt automatisk OLED-skærmdetektion." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anule la detección automática de pantalla OLED." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33369,6 +37599,12 @@ }, "Override default screen layout." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anular el diseño de pantalla predeterminado." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -33385,6 +37621,12 @@ }, "Packet Count" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recuento de paquetes" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -33413,6 +37655,12 @@ "value" : "Pairing Modus" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo de emparejamiento" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -33489,6 +37737,12 @@ "value" : "Passwort" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contraseña" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -33565,6 +37819,12 @@ "value" : "Pause" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pausa" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -33635,6 +37895,12 @@ "value" : "PAX tæller" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contador de pasajeros" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -33699,6 +37965,12 @@ "value" : "PAX-tæller konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del contador PAX" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -33758,6 +38030,12 @@ "value" : "PAX-tæller konfiguration modtaget: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del contador PAX recibida: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33804,6 +38082,12 @@ "value" : "PAX-tællerlog" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de contador de PAX" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33857,6 +38141,12 @@ "value" : "PAX Counter message received for: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensaje del contador de PAX recibido de: %@" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -33921,6 +38211,12 @@ "value" : "paxcounter.log" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "paxcounter.log" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -33961,6 +38257,12 @@ "value" : "Verbundenen Knoten auf Werkseinstellungen zurücksetzen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Realice un restablecimiento de fábrica en el nodo al que está conectado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34002,6 +38304,12 @@ "value" : "Filippinerne 433 MHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filipinas 433MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34043,6 +38351,12 @@ "value" : "Filippinerne 868 MHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filipinas 868MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34084,6 +38398,12 @@ "value" : "Filippinerne 915MHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filipinas 915MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34130,6 +38450,12 @@ "value" : "Telefon GPS" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS del teléfono" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -34200,6 +38526,12 @@ "value" : "Standorteinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ubicación del teléfono" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -34222,6 +38554,12 @@ "value" : "Fastgør %lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin %lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34262,6 +38600,12 @@ "value" : "Fastgør A" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin A" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34302,6 +38646,12 @@ "value" : "Fastgør B" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin B" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34348,6 +38698,12 @@ "value" : "PKI-basierte Knotenadministration, benötigt Firmware Version 2.5+" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administración de nodos basada en PKI, requiere versión de firmware 2.5+" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34394,6 +38750,12 @@ "value" : "Vær opmærksom på, at fordi kortrapporten ikke er krypteret, kan dine data blive gemt og vist permanent af tredjeparter. Meshtastic påtager sig ikke ansvar for lagring, visning eller offentliggørelse af disse data." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tenga en cuenta que debido a que el informe del mapa no está cifrado, terceros pueden almacenar y mostrar sus datos de forma permanente. Meshtastic no asume responsabilidad por dicho almacenamiento, exhibición o divulgación de estos datos." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -34434,6 +38796,12 @@ "value" : "Bitte verbinde dich mit einem Funkgerät, um die Einstellungen zu ändern." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conéctese a una radio para configurar los ajustes." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34487,6 +38855,12 @@ "value" : "Bitte lege eine Region fest" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por favor establece una región" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34527,6 +38901,12 @@ "value" : "Interessante steder" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puntos de interés" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34574,6 +38954,12 @@ "value" : "Kacke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "caca" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -34645,6 +39031,12 @@ "value" : "Placering" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posición" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -34709,6 +39101,12 @@ "value" : "Positionseinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de posición" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -34780,6 +39178,12 @@ "value" : "Positionskonfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de posición recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -34844,6 +39248,12 @@ "value" : "Placering udveksling mislykkedes" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error en el intercambio de posición" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34884,6 +39294,12 @@ "value" : "Positionsudveksling anmodet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intercambio de posición solicitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34924,6 +39340,12 @@ "value" : "Positionsflag" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Banderas de posición" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34964,6 +39386,12 @@ "value" : "Positionslog" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de posición" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35004,6 +39432,12 @@ "value" : "Positionslog %lld punkter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de posición %lld Puntos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35044,6 +39478,12 @@ "value" : "Position pakke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paquete de posición" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35090,6 +39530,12 @@ "value" : "Position gesendet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posición enviada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35130,6 +39576,12 @@ "value" : "Positioner aktiveret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posiciones Habilitadas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35176,6 +39628,12 @@ "value" : "Positioner vil blive angivet af din enheds GPS, hvis du vælger deaktiveret eller ikke til stede, kan du indstille en fast position." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las posiciones serán proporcionadas por el GPS de su dispositivo; si selecciona deshabilitado o no presente, puede establecer una posición fija." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35228,6 +39686,12 @@ "value" : "Strom" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "poder" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -35298,6 +39762,12 @@ "value" : "Stromkonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de energía" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -35363,6 +39833,12 @@ "value" : "Strømkonfiguration modtaget: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de energía recibida: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35410,6 +39886,12 @@ "value" : "Energidata" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métricas de energía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35450,6 +39932,12 @@ "value" : "Energidata-log" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de métricas de energía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35490,6 +39978,12 @@ "value" : "Sluk" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35531,6 +40025,12 @@ "value" : "Strømindstillinger" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de energía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35571,6 +40071,12 @@ "value" : "Stromsparen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ahorro de energía" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -35635,6 +40141,12 @@ "value" : "Strøm Skærm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pantalla de energía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35669,6 +40181,12 @@ }, "Power Sensor Options" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de sensores de potencia" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -35691,6 +40209,12 @@ "value" : "Angeschaltet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desarrollado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35737,6 +40261,12 @@ "value" : "Genaue Position" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ubicación precisa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35783,6 +40313,12 @@ "value" : "Voreinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preajustes" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35829,6 +40365,12 @@ "value" : "Tryk fastgør" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin de prensa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35869,6 +40411,12 @@ "value" : "Tryk" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35915,6 +40463,12 @@ "value" : "Primär" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primaria" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -35991,6 +40545,12 @@ "value" : "Erster Admin-Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave de administrador principal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36037,6 +40597,12 @@ "value" : "Primær GPIO" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO primario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36083,6 +40649,12 @@ "value" : "Privater Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave privada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36136,6 +40708,12 @@ "value" : "Prozess" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proceso" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36206,6 +40784,12 @@ "value" : "Datei wird verarbeitet…" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Procesando archivo..." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36258,6 +40842,12 @@ "value" : "Projektinformationen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Información del proyecto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36311,6 +40901,12 @@ "value" : "Protobufs" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protobufs" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36381,6 +40977,12 @@ "value" : "Teile anonyme Nutzungsstatistiken und Absturzberichte." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proporcione estadísticas de uso anónimas e informes de fallos." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -36397,6 +40999,12 @@ }, "Provide Confirmation" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proporcionar confirmación" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -36425,6 +41033,12 @@ "value" : "Öffentlicher Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave pública" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36471,6 +41085,12 @@ "value" : "Offentlig nøglekryptering" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cifrado de clave pública" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36517,6 +41137,12 @@ "value" : "Offentlig nøgle uoverensstemmelse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La clave pública no coincide" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36563,6 +41189,12 @@ "value" : "PWD" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "PCD" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36610,6 +41242,12 @@ "value" : "Fragezeichen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pregunta" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36680,6 +41318,12 @@ "value" : "Stråling" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radiación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36726,6 +41370,12 @@ "value" : "Geräteeinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de radio" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36803,6 +41453,12 @@ "value" : "RAK Drehimpulsgeber Modul" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Codificador rotatorio RAK" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36879,6 +41535,12 @@ "value" : "Entfernungstest" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prueba de rango" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36955,6 +41617,12 @@ "value" : "Entfernungstest Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de prueba de rango" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37032,6 +41700,12 @@ "value" : "Range Test Modul konfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo de prueba de rango recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37108,6 +41782,12 @@ "value" : "Neustart" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37184,6 +41864,12 @@ "value" : "Knoten neustarten?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Reiniciar el nodo?" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37255,6 +41941,12 @@ "value" : "Genudsend enhver observeret besked, hvis den var på vores private kanal eller fra et andet netværk med de samme lora-parametre." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retransmitir cualquier mensaje observado, si fue en nuestro canal privado o desde otra malla con los mismos parámetros de lora." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -37289,6 +41981,12 @@ "value" : "Genudsendelsestilstand" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo de retransmisión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37335,6 +42033,12 @@ "value" : "Modtage data (rxd) GPIO-pin" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recibir datos (rxd) pin GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37382,6 +42086,12 @@ "value" : "Negative Empfangsbestätigung empfangen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recibí un reconocimiento negativo." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37459,6 +42169,12 @@ "value" : "Empfangsbestätigung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmación recibida" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37517,6 +42233,12 @@ }, "Received Ack: %@" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmación recibida: %@" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -37540,6 +42262,12 @@ "value" : "Recipient Ack" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmación del destinatario" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37598,6 +42326,12 @@ }, "Recipient Ack: %@" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmación del destinatario: %@" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -37620,6 +42354,12 @@ "value" : "Route aufzeichnen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de grabación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37660,6 +42400,12 @@ "value" : "Opdater enhedsmetadata" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizar metadatos del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37694,6 +42440,12 @@ }, "Regenerate Private Key" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regenerar clave privada" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -37728,6 +42480,12 @@ "value" : "Region" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Región" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37781,6 +42539,12 @@ "value" : "Regionale Einschaltdauergrenze erreicht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se alcanzó el límite del ciclo de trabajo regional" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37851,6 +42615,12 @@ "value" : "Relayed by %1$d %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retransmitido por %d %@" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -37867,6 +42637,12 @@ "value" : "Udgivelsesnoter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas de la versión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37908,6 +42684,12 @@ "value" : "Fjernadministration for: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administración remota para: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37948,6 +42730,12 @@ "value" : "Fjern Legacy Admin: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrador remoto heredado: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37988,6 +42776,12 @@ "value" : "Fjern-PKI-Admin: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrador remoto de PKI: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38034,6 +42828,12 @@ "value" : "Entfernen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38080,6 +42880,12 @@ "value" : "Von Favoriten entfernen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitar de favoritos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38120,6 +42926,12 @@ "value" : "Fjern fra ignoreret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitar de ignorado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38173,6 +42985,12 @@ "value" : "Repeater" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "repetidor" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38219,6 +43037,12 @@ "value" : "Erstat kanaler" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reemplazar canales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38265,6 +43089,12 @@ "value" : "Antworten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Responder" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -38335,6 +43165,12 @@ "value" : "Anmod om administrator (gammel): %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solicitar administrador heredado: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38375,6 +43211,12 @@ "value" : "Anmod om PKI Admin: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solicitar administrador de PKI: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38416,6 +43258,12 @@ "value" : "Anmodet modulmeddelelser for færdiglavede meddelelser til node: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensajes del módulo de mensajes predefinidos solicitados para el nodo: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -38486,6 +43334,12 @@ "value" : "Kræver, at der er et accelerometer på din enhed." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requiere que haya un acelerómetro en su dispositivo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38532,6 +43386,12 @@ "value" : "App-Einstellungen zurücksetzen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer la configuración de la aplicación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38584,6 +43444,12 @@ "value" : "Knotendatenbank zurücksetzen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer NodeDB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38631,6 +43497,12 @@ "value" : "Neustarten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38678,6 +43550,12 @@ "value" : "Verbundenen Knoten neustarten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reinicie en el nodo al que está conectado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38712,6 +43590,12 @@ }, "Restore" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurar" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -38746,6 +43630,12 @@ "value" : "Fortsetzen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Currículum" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -38810,6 +43700,12 @@ }, "Retreiving nodes . ." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recuperando nodos. ." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -38827,6 +43723,12 @@ "Retreiving nodes %lld" : { "extractionState" : "stale", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recuperando nodos %lld" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -38843,6 +43745,12 @@ }, "Retrieving nodes" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recuperando nodos" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -38859,6 +43767,12 @@ }, "Retrieving nodes %lld" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recuperando nodos %lld" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -38875,6 +43789,12 @@ }, "Retrying (attempt %lld)" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reintentando (intento %lld)" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -38903,6 +43823,12 @@ "value" : "App bewerten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Revisa la aplicación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38956,6 +43882,12 @@ "value" : "Rechts" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Derecha" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -39032,6 +43964,12 @@ "value" : "Klingelton" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tono de llamada" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -39108,6 +44046,12 @@ "value" : "Klingelton Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de tono de llamada" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -39172,6 +44116,12 @@ "value" : "Sprog til overførsel af ringetoner" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Idioma de transferencia de tono de llamada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39224,6 +44174,12 @@ "value" : "Ringtone Transfer Language (RTTTL) Ringtone String brugt af understøttede buzzere i eksterne meddelelser" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lenguaje de transferencia de tono de llamada (RTTTL) Cadena de tono utilizada por los timbres compatibles en notificaciones externas." + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -39294,6 +44250,12 @@ "value" : "Rolle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rol" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39341,6 +44303,12 @@ "value" : "Rolle: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rol: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39387,6 +44355,12 @@ "value" : "Rollen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Roles" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39427,6 +44401,12 @@ "value" : "Hovedemne" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tema raíz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39467,6 +44447,12 @@ "value" : "Rotary 1" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giratorio 1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39513,6 +44499,12 @@ "value" : "Returrute: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de regreso: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39553,6 +44545,12 @@ "value" : "Ruteliner" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Líneas de ruta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39593,6 +44591,12 @@ "value" : "Routenliste" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lista de rutas" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -39621,6 +44625,12 @@ "value" : "Route aufzeichnen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grabador de ruta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39667,6 +44677,12 @@ "value" : "Routenaufzeichnung pausiert" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grabación de ruta en pausa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39713,6 +44729,12 @@ "value" : "Route: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39760,6 +44782,12 @@ "value" : "Router" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enrutador" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39813,6 +44841,12 @@ "value" : "Router mit Verzögerung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enrutador tarde" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39859,6 +44893,12 @@ "value" : "Routenliste" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rutas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39906,6 +44946,12 @@ "value" : "Routing empfangen für RequestID: %@ Ack Status: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enrutamiento recibido para ID de solicitud: %@ Estado de confirmación: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -39976,6 +45022,12 @@ "value" : "RSSI %@ dBm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %@ dBm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40016,6 +45068,12 @@ "value" : "RSSI %ddB" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %ddB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40057,6 +45115,12 @@ "value" : "RSSI %llddB" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %llddB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40104,6 +45168,12 @@ "value" : "RTTTL Klingeltonkonfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de tono de llamada RTTTL recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -40175,6 +45245,12 @@ "value" : "Rusland" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rusia" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -40209,6 +45285,12 @@ "value" : "Forstærket RX-forstærkning" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ganancia impulsada por RX" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40250,6 +45332,12 @@ "value" : "Samme som adfærd som ALL, men springer pakkedekodning over og genudsender dem blot. Kun tilgængelig i Repeater-rollen. At indstille dette på andre roller vil resultere i ALL-adfærd." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Igual que el comportamiento de ALL, pero omite la decodificación de paquetes y simplemente los retransmite. Sólo disponible en rol de Repetidor. Establecer esto en cualquier otro rol dará como resultado TODOS los comportamientos." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -40291,6 +45379,12 @@ "value" : "Satellit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Satélite" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -40350,6 +45444,12 @@ "value" : "Satellitoverflyvning" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sobrevuelo satelital" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -40426,6 +45526,12 @@ "value" : "Satelliten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sábados" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40472,6 +45578,12 @@ "value" : "Satelliten Schätzung %lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estimación de satélites %lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40518,6 +45630,12 @@ "value" : "Satelliten in Sicht: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sats a la vista: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40564,6 +45682,12 @@ "value" : "Speichern" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -40634,6 +45758,12 @@ "value" : "Gem kanalindstillinger" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar configuración del canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40681,6 +45811,12 @@ "value" : "Speichere Konfiguration für %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar configuración para %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -40757,6 +45893,12 @@ "value" : "Benutzerkonfiguration nach %@ speichern?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Guardar configuración de usuario en %@?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40803,6 +45945,12 @@ "value" : "Gemmer en CSV-fil med detaljer om intervaltestbeskeder, i øjeblikket kun tilgængelig på ESP32-enheder med en webserver" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guarda un CSV con los detalles del mensaje de prueba de rango, actualmente solo disponible en dispositivos ESP32 con un servidor web." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40843,6 +45991,12 @@ }, "Scan this QR code to add %@ to another device." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escanee este código QR para agregar %@ a otro dispositivo." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -40877,6 +46031,12 @@ "value" : "Skærm tændt i " } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pantalla encendida para" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40923,6 +46083,12 @@ "value" : "Suchen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40963,6 +46129,12 @@ "value" : "Sekund" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "segundo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41009,6 +46181,12 @@ "value" : "Sekundär" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secundaria" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -41085,6 +46263,12 @@ "value" : "Zweiter Admin-Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave de administrador secundario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41138,6 +46322,12 @@ "value" : "Sicherheit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguridad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41190,6 +46380,12 @@ "value" : "Sicherheitskonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de seguridad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41242,6 +46438,12 @@ "value" : "Sicherheitskonfigurationseinstellungen erfordern eine Firmware mit Version 2.5 oder höher" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los ajustes de configuración de seguridad requieren una versión de firmware 2.5+" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41295,6 +46497,12 @@ "value" : "Auswählen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccionar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -41371,6 +46579,12 @@ "value" : "Kanal wählen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione un canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41411,6 +46625,12 @@ "value" : "Vælg en samtale" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione una conversación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41451,6 +46671,12 @@ "value" : "Vælg en samtaletype" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione un tipo de conversación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41485,6 +46711,12 @@ }, "Select a Node" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione un nodo" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -41501,6 +46733,12 @@ "value" : "Vælg en node fra listen for at (fjern)administrere enheden." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione un nodo del menú desplegable para administrar dispositivos conectados o remotos." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -41535,6 +46773,12 @@ "value" : "Vælg en rutesporing (Trace route)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione una ruta de seguimiento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41569,6 +46813,12 @@ }, "Select an emoji" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecciona un emoji" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -41585,6 +46835,12 @@ "value" : "Vælg kanal" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccionar canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41625,6 +46881,12 @@ "value" : "Datei auswählen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccionar archivo de mapa" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -41647,6 +46909,12 @@ "value" : "Als kritisch eingestufte Mitteilungen ignorieren den Stummschalter und die 'Nicht stören'-Einstellungen des Benachrichtigungszentrums." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los paquetes seleccionados enviados como críticos ignorarán el interruptor de silencio y la configuración de No molestar en el centro de notificaciones del sistema operativo." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -41675,6 +46943,12 @@ "value" : "Senden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41721,6 +46995,12 @@ "value" : "Sende ${messageContent} an ${channelNumber}" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar ${messageContent} a ${channelNumber}" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41761,6 +47041,12 @@ "value" : "Send Send ${messageContent} til ${nodeNumber}" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar ${messageContent} a ${nodeNumber}" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41801,6 +47087,12 @@ "value" : "Send en direkte besked" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar un mensaje directo" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -41829,6 +47121,12 @@ "value" : "Gruppennachricht senden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar un mensaje grupal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41869,6 +47167,12 @@ "value" : "Send et hjerteslag for at annoncere serverens tilstedeværelse." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envíe un latido para anunciar la presencia del servidor." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41909,6 +47213,12 @@ "value" : "Send en besked til én Meshtastic-kanal" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar un mensaje a un determinado canal meshtastic" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41949,6 +47259,12 @@ "value" : "Send en besked til én Meshtastic-node" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar un mensaje a un determinado nodo meshtastic" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -41971,6 +47287,12 @@ "value" : "Send en position på den primære kanal, når brugerknappen trykkes tre gange." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envíe una posición en el canal principal cuando se haga triple clic en el botón del usuario." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42023,6 +47345,12 @@ "value" : "Herunterfahren an verbundenen Knoten senden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envía un apagado al nodo al que estás conectado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42069,6 +47397,12 @@ "value" : "Wegpunkt senden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar un punto de referencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42109,6 +47443,12 @@ "value" : "Send ASCII-klokke med advarselsbesked. Nyttig til at udløse ekstern notifikation ved bip." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar campana ASCII con mensaje de alerta. Útil para activar notificaciones externas al tocar el timbre." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42161,6 +47501,12 @@ "value" : "Sende Glocke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar campana" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42213,6 +47559,12 @@ "value" : "Herzschlag senden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar latido" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -42283,6 +47635,12 @@ "value" : "Mitteilungen senden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar notificaciones" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -42305,6 +47663,12 @@ "value" : "Send genstart OTA" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar Reiniciar OTA" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42352,6 +47716,12 @@ "value" : "Afsenderinterval" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo del remitente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42405,6 +47775,12 @@ "value" : "Sensor" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sensores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42451,6 +47827,12 @@ "value" : "Sensorindstillinger" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de sensores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42492,6 +47874,12 @@ "value" : "Sensorindstillinger" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de sensores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42527,6 +47915,12 @@ "value" : "Sendte en kanal for: %@ Kanal indeks %d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviado un canal para: %@ Índice de canales %d" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -42604,6 +47998,12 @@ "value" : "LoRa.Config gesendet für: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envió un LoRa.Config para: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -42681,6 +48081,12 @@ "value" : "Position von Apple Gerät an Knoten gesendet: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envió un paquete de posición desde el dispositivo GPS de Apple al nodo: %@@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -42758,6 +48164,12 @@ "value" : "Sende Traceroute Anforderung zu Knoten: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envió una solicitud de ruta de seguimiento al nodo: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -42835,6 +48247,12 @@ "value" : "Wegpunkt gesendet von: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviado un paquete de waypoint desde: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -42912,6 +48330,12 @@ "value" : "Sende Nachricht %@ von %@ an %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensaje enviado %@ de %@ a %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -42988,6 +48412,12 @@ "value" : "Sequenznummer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Número de secuencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43034,6 +48464,12 @@ "value" : "Sequenz: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secuencia: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43074,6 +48510,12 @@ "value" : "Seriel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serie" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -43150,6 +48592,12 @@ "value" : "Serial Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración en serie" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -43226,6 +48674,12 @@ "value" : "Serielle Konsole" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consola serie" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43272,6 +48726,12 @@ "value" : "Serielle Konsole über die Stream-API." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consola serial a través de Stream API." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43319,6 +48779,12 @@ "value" : "Serial Modul Konfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo serie recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -43389,6 +48855,12 @@ "value" : "Serier" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serie" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43435,6 +48907,12 @@ "value" : "Server" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servidor" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43481,6 +48959,12 @@ "value" : "Serveradresse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dirección del servidor" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43522,6 +49006,12 @@ "value" : "Serverindstilling" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opción de servidor" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43563,6 +49053,12 @@ "value" : "Indstil" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conjunto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43609,6 +49105,12 @@ "value" : "Setze LoRa Region" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Establecer región LoRa" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -43679,6 +49181,12 @@ "value" : "Indstil GPIO-bolerne for RXD og TXD." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configure los pines GPIO para RXD y TXD." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43713,6 +49221,12 @@ }, "Set to current location" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Establecer en la ubicación actual" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -43741,6 +49255,12 @@ "value" : "Indstiller det maksimale antal hop, standard er 3. At øge antallet af hop øger også belastningen og bør ske med forsigtighed. O hop-broadcast-beskeder vil ikke modtage ACKs." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Establece el número máximo de saltos; el valor predeterminado es 3. El aumento de saltos también aumenta la congestión y debe usarse con cuidado. Los mensajes de difusión de O hop no recibirán ACK." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -43775,6 +49295,12 @@ }, "Sets the screen clock format to 12-hour." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Establece el formato del reloj de la pantalla en 12 horas." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -43803,6 +49329,12 @@ "value" : "Einstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ajustes" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -43831,6 +49363,12 @@ "value" : "Einstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -43908,6 +49446,12 @@ "value" : "Zweiundsiebzig Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setenta y dos horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -43972,6 +49516,12 @@ }, "Share Contact QR" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir Contacto QR" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -44006,6 +49556,12 @@ "value" : "Standort teilen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir ubicación" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -44034,6 +49590,12 @@ "value" : "Kanal QR Code teilen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir código QR" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -44110,6 +49672,12 @@ "value" : "QR Code & Link teilen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir código QR y enlace" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44150,6 +49718,12 @@ "value" : "Teile deinen Standort in Echtzeit und koordiniere deine Gruppe mithilfe integrierter GPS-Funktionen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comparta su ubicación en tiempo real y mantenga a su grupo coordinado con funciones de GPS integradas." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -44178,6 +49752,12 @@ "value" : "Gemeinsamer Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave compartida" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44224,6 +49804,12 @@ "value" : "Meshtastic Kanäle teilen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir canales Meshtastic" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -44300,6 +49886,12 @@ "value" : "Kurzname" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre corto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44347,6 +49939,12 @@ "value" : "Kort Rækkevidde - Hurtig" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corto alcance - Rápido" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44388,6 +49986,12 @@ "value" : "Kort rækkevidde - Langsom" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corto alcance - Lento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44429,6 +50033,12 @@ "value" : "Kort rækkevidde - Turbo" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corto alcance - Turbo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44463,6 +50073,12 @@ }, "Show a confirmation dialog before performing the factory reset" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar un cuadro de diálogo de confirmación antes de realizar el restablecimiento de fábrica" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -44491,6 +50107,12 @@ "value" : "Zeige Alarme" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar alertas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44537,6 +50159,12 @@ "value" : "Zeige Alarme" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar alertas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44583,6 +50211,12 @@ "value" : "Zeige Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar nodos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44629,6 +50263,12 @@ "value" : "Zeige auf dem Gerätebildschirm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar en la pantalla del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44675,6 +50315,12 @@ "value" : "Zeige auf der Netzwerkkarte." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar en el mapa de malla." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44716,6 +50362,12 @@ "value" : "Zeige Wegpunkte" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar puntos de ruta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44744,6 +50396,12 @@ }, "Shows information for the connected Lora radio. You can swipe left to disconnect the radio and long press to start the live activity." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muestra información de la radio Lora conectada. Puede deslizar hacia la izquierda para desconectar la radio y mantener presionada para iniciar la actividad en vivo." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -44772,6 +50430,12 @@ "value" : "Herunterfahren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44818,6 +50482,12 @@ "value" : "Knoten herunterfahren?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Cerrar el nodo?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44864,6 +50534,12 @@ "value" : "Knoten herunterfahren?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Apagar el nodo?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -44910,6 +50586,12 @@ "value" : "Herunterfahren bei Stromunterbruch" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagado por pérdida de energía" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -44974,6 +50656,12 @@ "value" : "Signal %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Señal %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45021,6 +50709,12 @@ "value" : "Einfach" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sencillo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -45092,6 +50786,12 @@ "value" : "Singapore 923 MHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Singapur 923MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45139,6 +50839,12 @@ "value" : "Sechs Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seis horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -45216,6 +50922,12 @@ "value" : "Skifahren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "esquiar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45262,6 +50974,12 @@ "value" : "Smart Position" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posición inteligente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45302,6 +51020,12 @@ "value" : "SNR" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45342,6 +51066,12 @@ "value" : "SNR %@ dB" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@ dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45382,6 +51112,12 @@ "value" : "SNR %@dB" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45422,6 +51158,12 @@ "value" : "Jordfugtighed" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Humedad del suelo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45462,6 +51204,12 @@ "value" : "Jordtemperatur" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperatura del suelo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45503,6 +51251,12 @@ "value" : "Angiver hvor længe den overvågede GPIO skal udlæse." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Especifica cuánto tiempo debe emitir el GPIO monitoreado." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45543,6 +51297,12 @@ "value" : "Geschwindigkeit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45589,6 +51349,12 @@ "value" : "Geschwindigkeit %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidad %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45635,6 +51401,12 @@ "value" : "Geschwindigkeit: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidad: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45675,6 +51447,12 @@ "value" : "App-Entwicklung unterstützen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desarrollo de aplicaciones para patrocinadores" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -45703,6 +51481,12 @@ "value" : "Spredningsfaktor" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Factor de dispersión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -45749,6 +51533,12 @@ "value" : "SSID" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSID" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -45820,6 +51610,12 @@ "value" : "Standard" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estándar" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -45879,6 +51675,12 @@ "value" : "Standard dæmpet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estándar silenciado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -45955,6 +51757,12 @@ "value" : "Start" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empezar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -46026,6 +51834,12 @@ "value" : "State Broadcast Interval" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de transmisión estatal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46067,6 +51881,12 @@ "value" : "Überall in Verbindung bleiben" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manténgase conectado en cualquier lugar" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -46089,6 +51909,12 @@ "value" : "Gem og videresend" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Almacenar y reenviar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46135,6 +51961,12 @@ "value" : "Konfigurer Opbevaring og Videreformidling" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Almacenar y reenviar configuración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46182,6 +52014,12 @@ "value" : "Store & Forward-modulkonfiguration modtaget: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo Store & Forward recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -46252,6 +52090,12 @@ "value" : "Lagre- og videresendelsesservere kræver en ESP32-enhed med PSRAM eller Linux Native" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los servidores de almacenamiento y reenvío requieren un dispositivo ESP32 con PSRAM o Linux Native." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46292,6 +52136,12 @@ "value" : "Abonneret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suscrito" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -46339,6 +52189,12 @@ "value" : "Undersystem" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subsistema" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46385,6 +52241,12 @@ "value" : "Successfully uploaded '%1$@' with %2$lld overlays" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' subido correctamente con superposiciones %lld" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -46413,6 +52275,12 @@ "value" : "Unterstützt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apoyado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46453,6 +52321,12 @@ "value" : "Understøttede I2C Connected- sensorer bliver automatisk genkendt: BMP280, BME280, BME680, MCP9808, INA219, INA260, LPS22 and SHTC3." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los sensores conectados I2C compatibles se detectarán automáticamente, los sensores son BMP280, BME280, BME680, MCP9808, INA219, INA260, LPS22 y SHTC3." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46500,6 +52374,12 @@ "value" : "Tabel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46541,6 +52421,12 @@ "value" : "Taiwan" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taiwán" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -46581,6 +52467,12 @@ "value" : "TAK" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAK" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46635,6 +52527,12 @@ "value" : "TAK Tracker" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rastreador TAK" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46681,6 +52579,12 @@ "value" : "Tager en Meshtastic-kanal-URL og gemmer kanalindstillingerne" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toma la URL de un canal Meshtastic y guarda la configuración del canal." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -46715,6 +52619,12 @@ }, "Takes a Meshtastic contact URL and saves it to the nodes database" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toma una URL de contacto Meshtastic y la guarda en la base de datos de nodos" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -46743,6 +52653,12 @@ }, "Tap to enter emoji" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca para ingresar emoji" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -46765,6 +52681,12 @@ "value" : "Tapback Antwort" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tapback" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -46841,6 +52763,12 @@ "value" : "Telemetrie (Sensoren)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telemetria" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -46917,6 +52845,12 @@ "value" : "Telemetrie Einstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de telemetría" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -46994,6 +52928,12 @@ "value" : "Telemetrie Modul Konfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo de telemetría recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -47071,6 +53011,12 @@ "value" : "Temp" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "temperatura" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47117,6 +53063,12 @@ "value" : "Temperatur" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperatura" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47163,6 +53115,12 @@ "value" : "Zehn Minuten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "diez minutos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -47240,6 +53198,12 @@ "value" : "Zehn Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diez segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -47316,6 +53280,12 @@ "value" : "Dritter Admin-Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave de administrador terciario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47369,6 +53339,12 @@ "value" : "Textnachricht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensaje de texto" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -47440,6 +53416,12 @@ "value" : "TFT-farvedisplays" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pantallas TFT a todo color" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47481,6 +53463,12 @@ "value" : "Thailand" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailandia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47521,6 +53509,12 @@ "value" : "Den tid vi venter, før vi anser din pakke som færdig." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La cantidad de tiempo que debemos esperar antes de que consideremos que su paquete está listo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47561,6 +53555,12 @@ "value" : "Kompasretningen på skærmen uden for cirklen vil altid pege mod nord." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El rumbo de la brújula en la pantalla fuera del círculo siempre apuntará al norte." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47607,6 +53607,12 @@ "value" : "Der Taupunkt ist gerade %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El punto de rocío es %@ en este momento." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47647,6 +53653,12 @@ "value" : "Den hurtigste hastighed, som positionsopdateringer vil blive sendt med, hvis afstanden er over minimumsafstanden." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lo más rápido que se enviarán las actualizaciones de posición si se ha cumplido la distancia mínima" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47693,6 +53705,12 @@ "value" : "Die letzten 4 Zeichen der MAC-Adresse des Geräts werden an den Kurznamen angehängt, um den BLE-Namen des Geräts festzulegen. Der Kurzname kann bis zu 4 Byte lang sein." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los últimos 4 de la dirección MAC del dispositivo se agregarán al nombre corto para configurar el nombre BLE del dispositivo. El nombre corto puede tener hasta 4 bytes de longitud." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47739,6 +53757,12 @@ "value" : "Det maksimale tidsrum uden at noden sender sin position" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El intervalo máximo que puede transcurrir sin que un nodo transmita una posición." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47779,6 +53803,12 @@ "value" : "Meshtastic Apple-apps understøtter firmwareversion %@ og derover." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las aplicaciones Meshtastic de Apple admiten la versión de firmware %@ y superior." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47825,6 +53855,12 @@ "value" : "Den mindste afstandsændring i meter, der skal overvejes for en smart positionsudsendelse." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El cambio mínimo de distancia en metros a considerar para una transmisión de posición inteligente." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -47878,6 +53914,12 @@ "value" : "Das Paket ist zu groß" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El paquete es demasiado grande." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -47954,6 +53996,12 @@ "value" : "Der erste öffentliche Schlüssel, der berechtigt ist, Admin-Nachrichten an diesen Knoten zu senden." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La clave pública principal autorizada para enviar mensajes de administrador a este nodo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48006,6 +54054,12 @@ "value" : "Die Region, in der du deine Funkgeräte verwenden wirst." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La región donde utilizará sus radios." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48052,6 +54106,12 @@ "value" : "Rodemnet, der skal bruges til MQTT" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El tema raíz que se utilizará para MQTT." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48093,6 +54153,12 @@ "The Router roles are only for high vantage locations like mountaintops and towers with few nearby nodes, not for use in urban areas. Improper use will hurt your local mesh." : { "extractionState" : "stale", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las funciones de enrutador son solo para ubicaciones estratégicas, como cimas de montañas y torres con pocos nodos cercanos, no para uso en áreas urbanas. El uso inadecuado dañará su malla local." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -48121,6 +54187,12 @@ "value" : "Der zweite öffentliche Schlüssel, der berechtigt ist, Admin-Nachrichten an diesen Knoten zu senden." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La clave pública secundaria autorizada para enviar mensajes de administrador a este nodo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48173,6 +54245,12 @@ "value" : "Status der LED (an/aus)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El estado del LED (encendido/apagado)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48219,6 +54297,12 @@ "value" : "Der dritte öffentliche Schlüssel, der berechtigt ist, Admin-Nachrichten an diesen Knoten zu senden." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La clave pública terciaria autorizada para enviar mensajes de administrador a este nodo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48265,6 +54349,12 @@ "value" : "URL'en for kanalindstillingerne" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La URL para la configuración del canal." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48299,6 +54389,12 @@ }, "The URL for the node to add" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La URL del nodo a agregar." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -48327,6 +54423,12 @@ }, "There has been no response to a request for device metadata via PKC admin for this node." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No ha habido respuesta a una solicitud de metadatos del dispositivo a través del administrador de PKC para este nodo." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -48343,6 +54445,12 @@ }, "There is an issue with this contact's public key." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hay un problema con la clave pública de este contacto." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -48361,6 +54469,12 @@ "comment" : "A paragraph below the title that explains what the user is about to do.", "isCommentAutoGenerated" : true, "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estas configuraciones %@" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -48378,6 +54492,12 @@ "value" : "Disse indstillinger vil %@ kanaler. Den nuværende LoRa-konfiguration vil blive erstattet, hvis der er betydelige ændringer i LoRa-konfigurationen, vil enheden genstarte" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estas configuraciones serán los canales %@. La configuración LoRa actual será reemplazada; si hay cambios sustanciales en la configuración LoRa, el dispositivo se reiniciará" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48418,6 +54538,12 @@ "value" : "Dreißig Minuten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "treinta minutos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -48495,6 +54621,12 @@ "value" : "Dreißig Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "treinta segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -48572,6 +54704,12 @@ "value" : "Sechsunddreissig Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "treinta y seis horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -48642,6 +54780,12 @@ "value" : "Denne samtale vil blive slettet." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta conversación será eliminada." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48682,6 +54826,12 @@ "value" : "Dette kan tage et stykke tid. Svaret vil vises i rutesporingsloggen (trace route) for den node, det blev sendt til." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "This could take a while, response will appear in the trace route log for the node it was sent to." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48722,6 +54872,12 @@ "value" : "Denne enhed vil sende rækkeviddetestbeskeder ud med det valgte interval." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este dispositivo enviará mensajes de prueba de alcance en el intervalo seleccionado." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48774,6 +54930,12 @@ "value" : "Diese Nachricht wurde höchstwahrscheinlich nicht übermittelt." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es probable que este mensaje no se haya entregado." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48814,6 +54976,12 @@ "value" : "Noden understøtter ingen konfigurerbare moduler." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este nodo no admite ningún módulo configurable." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48854,6 +55022,12 @@ "value" : "Dette vil deaktivere fast position og fjerne den aktuelt indstillede position" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esto desactivará la posición fija y eliminará la posición establecida actualmente." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48894,6 +55068,12 @@ "value" : "Dette vil sende en nuværende position fra din telefon og aktivere fast position" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esto enviará una posición actual desde su teléfono y habilitará la posición fija." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -48947,6 +55127,12 @@ "value" : "Drei Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tres horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -49024,6 +55210,12 @@ "value" : "Drei Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tres segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -49101,6 +55293,12 @@ "value" : "Daumen runter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulgar hacia abajo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -49178,6 +55376,12 @@ "value" : "Daumen hoch" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulgar arriba" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -49254,6 +55458,12 @@ "value" : "Zeit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiempo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49300,6 +55510,12 @@ "value" : "Zeitstempel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marca de tiempo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49346,6 +55562,12 @@ "value" : "Zeitzone" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zona horaria" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49392,6 +55614,12 @@ "value" : "Zeitzone für Daten auf dem Gerätebildschirm und Log." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zona horaria para fechas en la pantalla del dispositivo y registro." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49438,6 +55666,12 @@ "value" : "Zeitlimit erreicht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo de espera" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -49514,6 +55748,12 @@ "value" : "Zeitstempel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marca de tiempo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -49578,6 +55818,12 @@ }, "Timing and Overrides" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temporización y anulaciones" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -49601,6 +55847,12 @@ "value" : "TLS-kryptering aktiveret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "TLS habilitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49647,6 +55899,12 @@ "value" : "For at overholde privatlivslove som CCPA og GDPR undgår vi at dele præcise lokaliseringsdata. I stedet bruger vi anonymiseret eller omtrentlig (upræcis) lokaliseringsinformation for at beskytte dit privatliv." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Para cumplir con las leyes de privacidad como CCPA y GDPR, evitamos compartir datos de ubicación exacta. En su lugar, utilizamos información de ubicación anónima o aproximada (imprecisa) para proteger su privacidad." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -49675,6 +55933,12 @@ }, "To Radio (TX): %lld" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "A la radio (TX): %lld" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -49698,6 +55962,12 @@ "value" : "Emne: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temas: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49744,6 +56014,12 @@ "value" : "Total" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "totales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49784,6 +56060,12 @@ "value" : "Sum af personer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX TOTALES" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49830,6 +56112,12 @@ "value" : "Rutesporing (trace route)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de seguimiento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49864,6 +56152,12 @@ }, "Trace Route (in %@s)" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de seguimiento (en %@)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -49898,6 +56192,12 @@ "value" : "Rutesporingslog" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de ruta de seguimiento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -49945,6 +56245,12 @@ "value" : "Traceroute Ergebnis: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solicitud de ruta de seguimiento devuelta: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -50015,6 +56321,12 @@ "value" : "Rutesporing igangsat" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de seguimiento enviada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50055,6 +56367,12 @@ "value" : "Rutesporing (trace route) sendt til %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de seguimiento enviada a %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50095,6 +56413,12 @@ "value" : "Rutesporing %@ blev ikke igangsat." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se envió la ruta de seguimiento a %@." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50135,6 +56459,12 @@ "value" : "Rutesporing (trace route) var begrænset af rate. Du kan højst sende en rutesporing én gang hvert halve minut." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trace Route tenía una tarifa limitada. Puede enviar una ruta de rastreo como máximo una vez cada treinta segundos." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50175,6 +56505,12 @@ "value" : "Standorte verfolgen und teilen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguimiento y compartir ubicaciones" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -50198,6 +56534,12 @@ "value" : "Sporingsprogram" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rastreador" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -50244,6 +56586,12 @@ "value" : "Verkehr" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tráfico" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50284,6 +56632,12 @@ "value" : "Transmitter data (txd) GPIO-pin" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmitir datos (txd) pin GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50324,6 +56678,12 @@ "value" : "Overførsel aktiveret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmisión habilitada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50370,6 +56730,12 @@ "value" : "Behandl dobbelttryk på understøttede accelerometre som et brugertastetryk." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Considere el doble toque en los acelerómetros compatibles como si el usuario presionara un botón." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50416,6 +56782,12 @@ "value" : "TriggerType" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo de disparador" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50456,6 +56828,12 @@ "value" : "Triple Klik Ad Hoc Ping" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ping ad hoc de triple clic" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50502,6 +56880,12 @@ "value" : "Erneut versuchen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inténtalo de nuevo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50549,6 +56933,12 @@ "value" : "Zwölf Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doce horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -50626,6 +57016,12 @@ "value" : "Vierundzwanzig Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "veinticuatro horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -50702,6 +57098,12 @@ "value" : "Zwei Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dos horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -50779,6 +57181,12 @@ "value" : "Zwei Minutes" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dos minutos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -50856,6 +57264,12 @@ "value" : "Zwei Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dos segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -50926,6 +57340,12 @@ "value" : "UDP-udsendelse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmisión UDP" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -50967,6 +57387,12 @@ "value" : "Ukraine 433 MHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ucrania 433MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51008,6 +57434,12 @@ "value" : "Ukraine 868 MHz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ucrania 868MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51048,6 +57480,12 @@ "value" : "Fjern foretrukken" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No favorito" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51089,6 +57527,12 @@ "value" : "Usund" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insalubre" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51130,6 +57574,12 @@ "value" : "Usundt for følsomme grupper" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No saludable para grupos sensibles" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51171,6 +57621,12 @@ "value" : "USA" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estados Unidos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51211,6 +57667,12 @@ "value" : "Enheder vist på enhedens skærm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unidades mostradas en la pantalla del dispositivo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51246,6 +57708,12 @@ "unknown" : { "extractionState" : "stale", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "desconocido" + } + }, "it" : { "stringUnit" : { "state" : "needs_review", @@ -51286,6 +57754,12 @@ "value" : "Ukendt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconocido" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -51362,6 +57836,12 @@ "value" : "Unbekanntes alter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edad desconocida" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -51432,6 +57912,12 @@ "value" : "Nicht benachrichtigbar" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "inmensable" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -51454,6 +57940,12 @@ }, "Unmonitored" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No monitoreado" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -51488,6 +57980,12 @@ "value" : "Unset" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desarmado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -51558,6 +58056,12 @@ "value" : "Ikke understøttet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No compatible" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51605,6 +58109,12 @@ "value" : "Hoch" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "arriba" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -51675,6 +58185,12 @@ "value" : "Op Ned 1" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "arriba abajo 1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51721,6 +58237,12 @@ "value" : "Opdateringsinterval" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de actualización" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51767,6 +58289,12 @@ "value" : "Firmware aktualisieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualice su firmware" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -51837,6 +58365,12 @@ "value" : "Opdaterede statistikker for noden." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos de estadísticas de nodos actualizados." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51883,6 +58417,12 @@ "value" : "Aktualisiert: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizado: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51923,6 +58463,12 @@ "value" : "Uplink aktiveret" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enlace ascendente habilitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -51969,6 +58515,12 @@ "value" : "Hochladen fehlgeschlagen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error de carga" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52015,6 +58567,12 @@ "value" : "Lade GeoJSON-Dateien hoch, um eigene Karten-Overlays anzuzeigen. Die Dateien werden lokal gespeichert und dürfen bis zu 10 MB groß sein." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cargue archivos GeoJSON para mostrar superposiciones de mapas personalizados. Los archivos se almacenan localmente y pueden tener hasta 10 MB." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52056,6 +58614,12 @@ "Upload Map Data" : { "comment" : "Title for map data upload screen", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cargar datos del mapa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52103,6 +58667,12 @@ "value" : "Lade Kartendaten hoch, um Overlays zu aktivieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cargar datos de mapas para habilitar superposiciones" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52149,6 +58719,12 @@ "value" : "Kartendaten hochladen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cargar superposiciones de mapas" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -52171,6 +58747,12 @@ "value" : "Hochladen erfolgreich" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subir con éxito" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52217,6 +58799,12 @@ "value" : "Hochgeladene Kartendaten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Superposiciones de mapas cargados" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -52239,6 +58827,12 @@ "value" : "Oppetid" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiempo de actividad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52285,6 +58879,12 @@ "value" : "Telemetriedaten erfassen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos de uso y fallos" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -52307,6 +58907,12 @@ "value" : "Brug en PWM-udgang (som RAK Buzzer) til melodier i stedet for en tænd/sluk-udgang. Dette vil ignorere udgang, udgangsvarighed og aktive indstillinger og bruge enhedens konfigurationsbuzzer-GPIO-option i stedet." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilice una salida PWM (como el RAK Buzzer) para melodías en lugar de una salida de encendido/apagado. Esto ignorará la salida, la duración de la salida y la configuración activa y en su lugar utilizará la opción GPIO del zumbador de configuración del dispositivo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52353,6 +58959,12 @@ "value" : "Brug I2S som buzzer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilice I2S como zumbador" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52393,6 +59005,12 @@ "value" : "Standort verwenden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar mi ubicación" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -52427,6 +59045,12 @@ "value" : "Voreinstellung verwenden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar preajuste" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52473,6 +59097,12 @@ "value" : "Brug PWM-summer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar zumbador PWM" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52519,6 +59149,12 @@ "value" : "Verwende das GPS deines Handys anstelle des GPS deines Funkgeräts." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilice el GPS de su teléfono para enviar ubicaciones a su nodo en lugar de utilizar un GPS de hardware en su nodo." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -52541,6 +59177,12 @@ "value" : "Bruges til at oprette en fælles krypteringsnøgle med en anden enhed." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se utiliza para crear una clave compartida con un dispositivo remoto." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52581,6 +59223,12 @@ "value" : "Wird verwendet, um nicht überwachte oder Infrastrukturknoten zu identifizieren, damit Nachrichten nicht an Knoten gesendet werden, die niemals antworten werden." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se utiliza para identificar nodos de infraestructura o no supervisados, de modo que la mensajería no esté disponible para nodos que nunca responderán." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -52615,6 +59263,12 @@ "value" : "Benutzer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuario" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -52691,6 +59345,12 @@ "value" : "Benutzerkonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de usuario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52743,6 +59403,12 @@ "value" : "Benutzerdaten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalles del usuario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52789,6 +59455,12 @@ "value" : "Bruger-ID" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Identificación de usuario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52829,6 +59501,12 @@ }, "User Info Exchange Failed" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error en el intercambio de información del usuario" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -52839,6 +59517,12 @@ }, "User Info Sent" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Información de usuario enviada" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -52849,6 +59533,12 @@ }, "User Privacy" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacidad del usuario" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -52866,6 +59556,12 @@ "value" : "Daten verfügbar" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuario subido" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -52918,6 +59614,12 @@ "value" : "Benutzername" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre de usuario" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -52988,6 +59690,12 @@ "value" : "Bruger pullup-modstand" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliza resistencia pullup" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53028,6 +59736,12 @@ "value" : "Udnytter netværksforbindelsen på din telefon til at oprette forbindelse til MQTT" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliza la conexión de red de su teléfono para conectarse a MQTT." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53080,6 +59794,12 @@ "value" : "Fahrzeugsteuerkurs" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rumbo del vehículo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53126,6 +59846,12 @@ "value" : "Fahrzeuggeschwindigkeit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidad del vehículo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53160,6 +59886,12 @@ }, "Verify who you are messaging with by comparing public keys in person or over the phone. The most recent public key for this node does not match the previously recorded key. You can delete the node and let it exchange keys again if the key change was due to a factory reset or other intentional action but this also may indicate a more serious security problem." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifique con quién está enviando mensajes comparando claves públicas en persona o por teléfono. The most recent public key for this node does not match the previously recorded key. Puede eliminar el nodo y dejar que intercambie claves nuevamente si el cambio de clave se debió a un restablecimiento de fábrica u otra acción intencional, pero esto también puede indicar un problema de seguridad más grave." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -53188,6 +59920,12 @@ "value" : "Version %1$@ includes substantial network optimizations and extensive changes to devices and client apps. Only nodes version %2$@ and above are supported." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La versión %@ incluye optimizaciones sustanciales de la red y cambios extensos en dispositivos y aplicaciones cliente. Solo se admiten los nodos versión %@ y superiores." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53240,6 +59978,12 @@ "value" : "Version: %1$@ (%2$@)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión: %@ (%@)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -53281,6 +60025,12 @@ "value" : "Version: %1$@ (%2$@) " } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión: %1$@ (%2$@)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53328,6 +60078,12 @@ "value" : "Meget usund" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "muy poco saludable" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53374,6 +60130,12 @@ "value" : "Via Lora" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vía Lora" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53420,6 +60182,12 @@ "value" : "Via Mqtt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vía Mqtt" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53472,6 +60240,12 @@ "value" : "Voltage" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "voltaje" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -53542,6 +60316,12 @@ "value" : "Volt %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltios %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53588,6 +60368,12 @@ "value" : "Warte..." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "esperando" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -53658,6 +60444,12 @@ "value" : "Afventer bekræftelse…" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esperando ser reconocido. . ." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53698,6 +60490,12 @@ "value" : "Væk skærmen ved tryk eller bevægelse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar pantalla con un toque o movimiento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53745,6 +60543,12 @@ "value" : "Gehen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caminando" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53798,6 +60602,12 @@ "value" : "Welle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ola" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53838,6 +60648,12 @@ }, "Waypoint Failed to Send" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El punto de referencia no se pudo enviar" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -53872,6 +60688,12 @@ "value" : "Wegpunktoptionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de punto de referencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -53919,6 +60741,12 @@ "value" : "Wegpunkt von Knoten empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paquete de waypoint recibido del nodo: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -53983,6 +60811,12 @@ }, "Waypoints" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puntos de ruta" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -54005,6 +60839,12 @@ "value" : "Wetterverhältnisse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Condiciones climáticas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54051,6 +60891,12 @@ "value" : "Web Flasher" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intermitente web" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54091,6 +60937,12 @@ "value" : "Websted" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitio web" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54137,6 +60989,12 @@ "value" : "Vægt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peso" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54177,6 +61035,12 @@ "value" : "Willkommen bei" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bienvenido a" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -54205,6 +61069,12 @@ "value" : "Was bedeutet das Schloß?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Qué significa la cerradura?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54257,6 +61127,12 @@ "value" : "Was ist Meshtastic?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Qué es Meshtastic?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54303,6 +61179,12 @@ "value" : "Hvad licenseret operatørtilstand gør:\n* Indstiller nodenavnet til dit kaldesignal \n* Udsender nodeinfo hvert 10. minut \n* Tilsidesætter frekvens, arbejdstidscyklus og sendeeffekt \n* Deaktiverer kryptering" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Qué hace el modo de operador con licencia:\n* Establece el nombre del nodo según su indicativo de llamada \n* Transmite información del nodo cada 10 minutos \n* Anula la frecuencia, el ciclo de trabajo y la potencia de transmisión. \n* Desactiva el cifrado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54349,6 +61231,12 @@ "value" : "Når det er aktiveret, tæller PAX Counter modulet antallet af personer, der passerer ved at bruge WiFi og Bluetooth. Både WiFi og Bluetooth skal være deaktiveret for at PAX counter kan fungere." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuando está habilitado, el módulo Contador de PAX cuenta el número de personas que pasan mediante WiFi y Bluetooth. Tanto WiFI como Bluetooth deben estar desactivados para que funcione el contador de PAX." + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -54407,6 +61295,12 @@ "value" : "Når du bruger i GPIO-tilstand, hold outputten tændt i så lang tid." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuando lo use en modo GPIO, mantenga la salida encendida durante este tiempo. " + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54453,6 +61347,12 @@ "value" : "Om INPUT_PULLUP-tilstand skal bruges til GPIO-pin. Kun relevant hvis kortet bruger pull-up modstande på pinnen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si se utiliza o no el modo INPUT_PULLUP para el pin GPIO. Only applicable if the board uses pull-up resistors on the pin" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54493,6 +61393,12 @@ "value" : "WiFi" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wi-Fi" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54545,6 +61451,12 @@ "value" : "WiFi Optionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones WiFi" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54585,6 +61497,12 @@ "value" : "Vil sove alt så meget som muligt, for tracker- og sensorrollen vil dette også omfatte lora-radioen. Brug ikke denne indstilling, hvis du vil bruge din enhed med telefonapps eller bruger en enhed uden en brugerknap." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dormirá todo lo más posible, para la función de rastreador y sensor esto también incluirá la radio lora. No use esta configuración si desea usar su dispositivo con las aplicaciones del teléfono o si está usando un dispositivo sin un botón de usuario." + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -54649,6 +61567,12 @@ "value" : "Vind" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "viento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54696,6 +61620,12 @@ "value" : "Windrichtung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dirección del viento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54743,6 +61673,12 @@ "value" : "Windgeschwindigkeit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidad del viento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54790,6 +61726,12 @@ "value" : "Innerhalb %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dentro de %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54836,6 +61778,12 @@ "value" : "x" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "x" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54882,6 +61830,12 @@ "value" : "X: %1$@, Y: %2$d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54935,6 +61889,12 @@ "value" : "X: %1$@, Y: %2$f" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$f" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -54988,6 +61948,12 @@ "value" : "X: %1$@, Y: %2$lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55035,6 +62001,12 @@ "value" : "j" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "y" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55077,6 +62049,12 @@ "comment" : "A button label that appears in a confirmation sheet when favoriting a node as a CLIENT_BASE.", "isCommentAutoGenerated" : true, "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sí, controlo este nodo." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -55099,6 +62077,12 @@ "value" : "Gestern" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ayer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55139,6 +62123,12 @@ "value" : "Du kan også opdatere din Meshtastic-enhed over bluetooth ved hjælp af Nordic DFU-appen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "También puede actualizar su dispositivo Meshtastic a través de bluetooth utilizando la aplicación Nordic DFU." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55191,6 +62181,12 @@ "value" : "Du kannst Kanalnachrichten (Gruppenchats) und Direktnachrichten senden und empfangen. Bei jeder Nachricht kannst du lange drücken, um verfügbare Aktionen wie Kopieren, Antworten, Tapback und Löschen sowie Zustelldetails anzuzeigen." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puedes enviar y recibir canales (chats grupales) y mensajes directos. Desde cualquier mensaje, puede mantener presionado para ver las acciones disponibles como copiar, responder, retroceder y eliminar, así como los detalles de entrega." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -55261,6 +62257,12 @@ "value" : "Din nuværende placering vil blive sat som den faste position og udsendt over nettet på positionsintervallet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su ubicación actual se establecerá como posición fija y se transmitirá sobre la malla en el intervalo de posición." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55313,6 +62315,12 @@ "value" : "Deine Firmware ist aktuell" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su firmware está actualizado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55359,6 +62367,12 @@ "value" : "Din MQTT-server skal understøtte TLS" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su servidor MQTT debe admitir TLS." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55399,6 +62413,12 @@ "value" : "Din node vil med jævne mellemrum sende en ukrypteret kortrapportpakke til den konfigurerede MQTT-server, dette inkluderer id, kort og langt navn, omtrentlig placering, hardwaremodel, rolle, firmwareversion, LoRa-region, modemindstilling og primærkanalnavn." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su nodo enviará periódicamente un paquete de informe de mapa sin cifrar al servidor MQTT configurado, esto incluye identificación, nombre corto y largo, ubicación aproximada, modelo de hardware, función, versión de firmware, región LoRa, configuración predeterminada del módem y nombre del canal principal." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -55433,6 +62453,12 @@ "value" : "Din nodes driftsfrekvens beregnes baseret på regionen, modemforindstillingen og dette felt. Når det er 0, beregnes slot automatisk baseret på det primære kanals navn." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La frecuencia operativa de su nodo se calcula en función de la región, la configuración predeterminada del módem y este campo. Cuando es 0, la ranura se calcula automáticamente en función del nombre del canal principal." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55473,6 +62499,12 @@ "value" : "Din position er blevet sendt med en anmodning om svar med deres position. Du vil modtage en besked, når en position er returneret." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su posición ha sido enviada con una solicitud de respuesta con su posición. Recibirá una notificación cuando se devuelva una posición." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55513,6 +62545,12 @@ }, "Your public key is generated from your private key and sent to other nodes on the mesh so they can compute a shared secret key with you." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su clave pública se genera a partir de su clave privada y se envía a otros nodos de la malla para que puedan calcular una clave secreta compartida con usted." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -55529,6 +62567,12 @@ "value" : "Din region har en %lld%% driftcyklus. MQTT anbefales ikke, når du er driftcyklusbegrænset, den ekstra trafik vil hurtigt overvælde dit LoRa-mesh." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su región tiene un ciclo de trabajo %lld%%. No se recomienda MQTT cuando tiene un ciclo de trabajo restringido, el tráfico adicional abrumará rápidamente su malla LoRa." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55575,6 +62619,12 @@ "value" : "Din region har en %lld%% timebaseret driftscyklus, din radio vil stoppe med at sende pakker, når det når grænsen pr. time." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su región tiene un ciclo de trabajo por hora %lld%%, su radio dejará de enviar paquetes cuando alcance el límite por hora." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55615,6 +62665,12 @@ "value" : "Din rute-fil skal have både breddegrad og længdegrad kolonner og overskrifter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su archivo de ruta debe tener columnas y encabezados de Latitud y Longitud." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -55649,6 +62705,12 @@ }, "Your user info has been sent with a request for a response with their user info." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su información de usuario se envió con una solicitud de respuesta con su información de usuario." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -55656,6 +62718,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" From 9ceb34f1d5ca92e737993185ef7a1d6e44345e74 Mon Sep 17 00:00:00 2001 From: axunes Date: Thu, 2 Apr 2026 11:05:23 -0400 Subject: [PATCH 10/20] fix typo in hop limit option description (#1631) O hop -> 0 hop --- Localizable.xcstrings | 2 +- Meshtastic/Views/Settings/Config/LoRaConfig.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 1083039f..948176b5 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -49247,7 +49247,7 @@ } } }, - "Sets the maximum number of hops, default is 3. Increasing hops also increases congestion and should be used carefully. O hop broadcast messages will not get ACKs." : { + "Sets the maximum number of hops, default is 3. Increasing hops also increases congestion and should be used carefully. 0 hop broadcast messages will not get ACKs." : { "localizations" : { "da" : { "stringUnit" : { diff --git a/Meshtastic/Views/Settings/Config/LoRaConfig.swift b/Meshtastic/Views/Settings/Config/LoRaConfig.swift index 87562617..17e18dc7 100644 --- a/Meshtastic/Views/Settings/Config/LoRaConfig.swift +++ b/Meshtastic/Views/Settings/Config/LoRaConfig.swift @@ -142,7 +142,7 @@ struct LoRaConfig: View { .tag($0) } } - Text("Sets the maximum number of hops, default is 3. Increasing hops also increases congestion and should be used carefully. O hop broadcast messages will not get ACKs.") + Text("Sets the maximum number of hops, default is 3. Increasing hops also increases congestion and should be used carefully. 0 hop broadcast messages will not get ACKs.") .foregroundColor(.gray) .font(.callout) } From 2cabd9e575538696a448f93930f031bf34f55106 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:34:01 -0700 Subject: [PATCH 11/20] Tak server improvements (#1603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- Localizable.xcstrings | 171 ++++ .../Accessory Manager/AccessoryManager.swift | 38 + Meshtastic/Helpers/TAK/CoTMessage.swift | 6 +- .../Helpers/TAK/MeshToCoTConverter.swift | 271 +++++ .../Helpers/TAK/TAKMeshtasticBridge.swift | 940 +++++++++++++++++- Meshtastic/Helpers/TAK/TAKServerManager.swift | 304 +++++- .../Views/Settings/TAKServerConfig.swift | 141 +++ 7 files changed, 1827 insertions(+), 44 deletions(-) create mode 100644 Meshtastic/Helpers/TAK/MeshToCoTConverter.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 948176b5..30e6245e 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -59,6 +59,90 @@ }, "shouldTranslate" : false }, + " : %@" : { + "extractionState" : "stale", + "localizations" : { + "da" : { + "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" : { + "extractionState" : "stale", + "localizations" : { + "da" : { + "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 + }, " %@" : { "localizations" : { "da" : { @@ -149,6 +233,12 @@ "value" : " : %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -190,6 +280,12 @@ "value" : " : %d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6611,6 +6707,10 @@ } } }, + "Auto-Fix Channel" : { + "comment" : "A button label that initiates the process of automatically fixing the TAK server's primary communication channel.", + "isCommentAutoGenerated" : true + }, "Automatically Connect" : { "localizations" : { "es" : { @@ -8179,6 +8279,10 @@ } } }, + "Bridge Meshtastic positions, nodes, waypoints, and messages to TAK/CoT format" : { + "comment" : "A description of the Mesh to CoT Converter feature.", + "isCommentAutoGenerated" : true + }, "Broadcast Device Metrics" : { "localizations" : { "es" : { @@ -10435,6 +10539,10 @@ } } }, + "Channel Fixed!" : { + "comment" : "A message displayed when the primary channel is successfully fixed.", + "isCommentAutoGenerated" : true + }, "Channel Name" : { "localizations" : { "da" : { @@ -16332,6 +16440,10 @@ } } }, + "Device role is \"%@\". Consider setting to TAK or TAK Tracker for optimal operation." : { + "comment" : "A warning about a device's role on the TAK network. The argument is the name of the device role.", + "isCommentAutoGenerated" : true + }, "Device Screen" : { "localizations" : { "da" : { @@ -19619,6 +19731,7 @@ "Enter P12 Password" : {}, "Enter the password for the PKCS#12 file" : {}, "environment" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -22460,6 +22573,14 @@ } } }, + "Fix Channel" : { + "comment" : "The text on a button that, when pressed, will attempt to fix the primary LoRa channel.", + "isCommentAutoGenerated" : true + }, + "Fix Primary Channel?" : { + "comment" : "A confirmation alert title.", + "isCommentAutoGenerated" : true + }, "Fixed Pin" : { "localizations" : { "da" : { @@ -28268,6 +28389,10 @@ } } }, + "Later" : { + "comment" : "A button that dismisses an alert without taking any action.", + "isCommentAutoGenerated" : true + }, "Latitude" : { "localizations" : { "da" : { @@ -31199,6 +31324,10 @@ } } }, + "Mesh to CoT Converter" : { + "comment" : "A feature that bridges Meshtastic positions, nodes, waypoints, and messages to TAK/CoT format.", + "isCommentAutoGenerated" : true + }, "Meshtastic" : { "localizations" : { "es" : { @@ -31221,6 +31350,10 @@ } } }, + "Meshtastic -> TAK works, TAK -> Meshtastic blocked" : { + "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. You can opt out under app settings." : { "localizations" : { "es" : { @@ -37112,6 +37245,10 @@ } } }, + "Or fix it yourself in Channels settings, then return here." : { + "comment" : "A message explaining that the user can fix the primary channel settings manually and then return to the current view.", + "isCommentAutoGenerated" : true + }, "OS Log Entry Details" : { "localizations" : { "da" : { @@ -41768,6 +41905,10 @@ } } }, + "Read-Only Mode" : { + "comment" : "A toggle that allows the user to enable or disable read-only mode for the TAK server.", + "isCommentAutoGenerated" : true + }, "Reboot" : { "localizations" : { "da" : { @@ -46308,6 +46449,11 @@ } }, "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 + }, "Security" : { "localizations" : { "da" : { @@ -52510,6 +52656,7 @@ } } } + }, "TAK Server" : {}, "TAK Tracker" : { @@ -55014,6 +55161,10 @@ } } }, + "This will change your primary channel to:\n• Name: TAK\n• Encryption: New 256-bit AES key\n• LoRa preset: Short Fast (recommended for TAK)\n\nThis is required for TAK Server to work properly. Any existing channel sharing links will become invalid." : { + "comment" : "The message shown in the \"Fix Primary Channel?\" alert.", + "isCommentAutoGenerated" : true + }, "This will disable fixed position and remove the currently set position." : { "localizations" : { "da" : { @@ -58899,6 +59050,10 @@ } } }, + "Use a 256-bit encryption key" : { + "comment" : "A bullet point describing the importance of using a 256-bit encryption key for the primary channel.", + "isCommentAutoGenerated" : true + }, "Use a PWM output (like the RAK Buzzer) for tunes instead of an on/off output. This will ignore the output, output duration and active settings and use the device config buzzer GPIO option instead." : { "localizations" : { "da" : { @@ -60587,6 +60742,10 @@ } } }, + "Warning" : { + "comment" : "The header text for the \"Warning\" section in the TAKServerConfig view.", + "isCommentAutoGenerated" : true + }, "Wave" : { "extractionState" : "stale", "localizations" : { @@ -62167,6 +62326,10 @@ } } }, + "You can fix this yourself by changing your primary channel:" : { + "comment" : "A description of how to fix the primary channel in the TAK Server configuration view.", + "isCommentAutoGenerated" : true + }, "You can send and receive channel (group chats) and direct messages. From any message you can long press to see available actions like copy, reply, tapback and delete as well as delivery details." : { "localizations" : { "da" : { @@ -62249,6 +62412,10 @@ } } }, + "Your channel has been configured for TAK. To share the QR code: go to Settings > Share QR Code" : { + "comment" : "A message displayed when a user successfully configures their primary channel for TAK. It instructs the user to share the QR code to invite TAK buddies.", + "isCommentAutoGenerated" : true + }, "Your current location will be set as the fixed position and broadcast over the mesh on the position interval." : { "localizations" : { "da" : { @@ -62543,6 +62710,10 @@ } } }, + "Your primary channel is using the default settings (no name or default encryption key). TAK Server is running in read-only mode." : { + "comment" : "A description of a situation where the user's primary channel is not configured with a name or encryption key, and TAK Server is running in read-only mode.", + "isCommentAutoGenerated" : true + }, "Your public key is generated from your private key and sent to other nodes on the mesh so they can compute a shared secret key with you." : { "localizations" : { "es" : { diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index cff4ab5a..5e1a46bd 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -512,12 +512,50 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { switch data.portnum { case .textMessageApp, .detectionSensorApp, .alertApp: await handleTextMessageAppPacket(packet) + // Broadcast text message to TAK clients + if let text = String(bytes: data.payload, encoding: .utf8) { + Logger.tak.debug("Text message received, calling broadcast") + let server = TAKServerManager.shared + if server.ensureBridgeReadyForMeshToCot() { + await server.bridge?.broadcastMeshTextMessageToTAK(text: text, from: packet.from, channel: packet.channel, to: packet.to) + } + } case .remoteHardwareApp: Logger.mesh.info("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") case .positionApp: await MeshPackets.shared.upsertPositionPacket(packet: packet) + // Broadcast position to TAK clients + if let position = try? Position(serializedBytes: data.payload) { + Logger.tak.debug("Position received, calling broadcast") + let server = TAKServerManager.shared + if server.ensureBridgeReadyForMeshToCot() { + await server.bridge?.broadcastMeshPositionToTAK(position: position, from: packet.from) + } + } case .waypointApp: + Logger.tak.info("WAYPOINT APP CASE REACHED") await MeshPackets.shared.waypointPacket(packet: packet) + // Broadcast waypoint to TAK clients + if let waypoint = try? Waypoint(serializedBytes: data.payload) { + Logger.tak.info("WAYPOINT PARSED: \(waypoint.name)") + // Ensure bridge is initialized before calling (not optional chaining, or lazy init won't run) + let server = TAKServerManager.shared + if server.meshToCotEnabled && server.isRunning && !server.connectedClients.isEmpty { + // Force bridge initialization if needed + if server.bridge == nil { + Logger.tak.info("Initializing bridge on demand") + let bridge = TAKMeshtasticBridge( + accessoryManager: AccessoryManager.shared, + takServerManager: server + ) + bridge.context = AccessoryManager.shared.context + server.bridge = bridge + } + await server.bridge?.broadcastMeshWaypointToTAK(waypoint: waypoint, from: packet.from) + } else { + Logger.tak.info("Waypoint broadcast skipped: server not ready or no clients") + } + } case .nodeinfoApp: guard let connectedNodeNum = self.activeDeviceNum else { Logger.mesh.error("🕸️ Unable to determine connectedNodeNum for node info upsert.") diff --git a/Meshtastic/Helpers/TAK/CoTMessage.swift b/Meshtastic/Helpers/TAK/CoTMessage.swift index 12aff014..68a6b063 100644 --- a/Meshtastic/Helpers/TAK/CoTMessage.swift +++ b/Meshtastic/Helpers/TAK/CoTMessage.swift @@ -131,7 +131,8 @@ struct CoTMessage: Identifiable, Sendable { team: String = "Cyan", role: String = "Team Member", battery: Int = 100, - staleMinutes: Int = 10 + staleMinutes: Int = 10, + remarks: String? = nil ) -> CoTMessage { let now = Date() return CoTMessage( @@ -149,7 +150,8 @@ struct CoTMessage: Identifiable, Sendable { contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"), group: CoTGroup(name: team, role: role), status: CoTStatus(battery: battery), - track: CoTTrack(speed: speed, course: course) + track: CoTTrack(speed: speed, course: course), + remarks: remarks ) } diff --git a/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift b/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift new file mode 100644 index 00000000..6c9f9029 --- /dev/null +++ b/Meshtastic/Helpers/TAK/MeshToCoTConverter.swift @@ -0,0 +1,271 @@ +// +// MeshToCoTConverter.swift +// Meshtastic +// +// Converts Meshtastic packets to CoT format for TAK Server +// + +import Foundation +import MeshtasticProtobufs +import CoreLocation +import OSLog +import Combine + +/// Converts Meshtastic packets to CoT format for bridging to TAK Server +final class MeshToCoTConverter: ObservableObject { + + static let shared = MeshToCoTConverter() + + private let logger = Logger(subsystem: "Meshtastic", category: "MeshToCoT") + + private init() {} + + // MARK: - Position // MARK: Packet to CoT + + /// Convert a Meshtastic position packet to CoT message + func convertPosition(_ position: Position, from node: NodeInfoEntity) -> CoTMessage? { + guard let user = node.user else { + logger.warning("Cannot convert position: node has no user info") + return nil + } + + let callsign = user.longName ?? user.shortName ?? "Unknown" + let uid = "MESHTASTIC-\(node.num.toHex())" + + let latitude = Double(position.latitudeI) / 1e7 + let longitude = Double(position.longitudeI) / 1e7 + let altitude = Double(position.altitude) + + var speed: Double = 0 + var course: Double = 0 + if position.speed != 0 { + speed = Double(position.speed) * 0.194384 // Convert to knots + } + if position.heading != 0 { + course = Double(position.heading) + } + + let battery = Int(position.batteryLevel) + + return CoTMessage.pli( + uid: uid, + callsign: callsign, + latitude: latitude, + longitude: longitude, + altitude: altitude, + speed: speed, + course: course, + team: "Meshtastic", + role: "Team Member", + battery: battery > 0 ? battery : 100, + staleMinutes: 10 + ) + } + + // MARK: - Node Info to CoT + + /// Convert node info to CoT message (for node presence updates) + func convertNodeInfo(_ node: NodeInfoEntity) -> CoTMessage? { + guard let user = node.user else { + logger.warning("Cannot convert node info: node has no user info") + return nil + } + + let callsign = user.longName ?? user.shortName ?? "Unknown" + let uid = "MESHTASTIC-\(node.num.toHex())" + + var latitude = 0.0 + var longitude = 0.0 + var altitude = 9999999.0 + + if let position = node.position { + latitude = Double(position.latitudeI) / 1e7 + longitude = Double(position.longitudeI) / 1e7 + if position.altitude != 0 { + altitude = Double(position.altitude) + } + } + + // Determine CoT type based on device role + let cotType = getCoTTypeForRole(user.role) + + let now = Date() + return CoTMessage( + uid: uid, + type: cotType, + time: now, + start: now, + stale: now.addingTimeInterval(3600), // 1 hour stale for node info + how: "m-g", + latitude: latitude, + longitude: longitude, + hae: altitude, + ce: 9999999.0, + le: 9999999.0, + contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"), + group: CoTGroup(name: "Meshtastic", role: getRoleNameForDeviceRole(user.role)), + remarks: "Meshtastic Node: \(callsign)" + ) + } + + // MARK: - Waypoint to CoT + + /// Convert a Meshtastic waypoint to CoT message + func convertWaypoint(_ waypoint: Waypoint, from node: NodeInfoEntity?) -> CoTMessage? { + let uid = "WAYPOINT-\(waypoint.id)" + + let latitude = Double(waypoint.latitudeI) / 1e7 + let longitude = Double(waypoint.longitudeI) / 1e7 + let altitude = waypoint.altitude > 0 ? Double(waypoint.altitude) : 9999999.0 + + let name = waypoint.name.isEmpty ? "Unnamed Waypoint" : waypoint.name + let description = waypoint.description_p.isEmpty ? "Meshtastic Waypoint" : waypoint.description_p + + // Get emoji based on waypoint icon/expire time + let iconEmoji = getEmojiForWaypoint(waypoint) + + // Handle expiry - if expire is 0, never expire. Otherwise use the expire time as Unix timestamp + let stale: Date + if waypoint.expire == 0 { + // Never expire - set to 1 year from now + stale = Date().addingTimeInterval(365 * 24 * 60 * 60) + } else { + // expire is Unix timestamp when waypoint expires + let expireDate = Date(timeIntervalSince1970: TimeInterval(waypoint.expire)) + if expireDate > Date() { + stale = expireDate + } else { + // Already expired, don't broadcast + return nil + } + } + + return CoTMessage( + uid: uid, + type: "b-ttf-ff", // Point feature friend - standard CoT type for waypoints/markers + time: Date(), + start: Date(), + stale: stale, + how: "m-g", + latitude: latitude, + longitude: longitude, + hae: altitude, + ce: 100.0, + le: 100.0, + contact: CoTContact(callsign: "\(iconEmoji) \(name)", endpoint: "0.0.0.0:4242:tcp"), + remarks: "\(description)\nCreated by: \(node?.user?.longName ?? "Unknown")" + ) + } + + // MARK: - Text Message to CoT + + /// Convert a Meshtastic text message to CoT chat message + func convertTextMessage(_ message: MessageEntity, from sender: NodeInfoEntity) -> CoTMessage? { + guard let user = sender.user, + let text = message.text else { + return nil + } + + let senderName = user.longName ?? user.shortName ?? "Unknown" + let senderUid = "MESHTASTIC-\(sender.num.toHex())" + let messageId = "MSG-\(message.id)" + + return CoTMessage.chat( + senderUid: senderUid, + senderCallsign: senderName, + message: text, + chatroom: "Primary" + ) + } + + // MARK: - Helper Methods + + /// Get CoT type based on device role + private func getCoTTypeForRole(_ role: UInt32) -> String { + switch DeviceRoles(rawValue: Int(role)) { + case .router, .routerLate: + return "a-f-G-E" // Group entity (router) + case .tracker: + return "a-f-G-T-C" // Ground unit tracker + case .tak: + return "a-f-G-U-C" // TAK client + case .takTracker: + return "a-f-G-T-C" // TAK tracker + case .sensor: + return "a-f-G-s" // Sensor with friendly affiliation + case .client, .clientMute, .clientHidden, .lostAndFound: + return "a-f-G-U-C" // Friendly ground unit + default: + return "a-f-G-U-C" // Default to friendly unit + } + } + + /// Get role name for device role + private func getRoleNameForDeviceRole(_ role: UInt32) -> String { + switch DeviceRoles(rawValue: Int(role)) { + case .router, .routerLate: + return "Router" + case .tracker: + return "Tracker" + case .tak: + return "TAK" + case .takTracker: + return "TAK Tracker" + case .sensor: + return "Sensor" + case .client: + return "Client" + case .clientMute: + return "Muted" + case .clientHidden: + return "Hidden" + default: + return "User" + } + } + + /// Get emoji for waypoint based on icon + private func getEmojiForWaypoint(_ waypoint: Waypoint) -> String { + // Use icon field if available, otherwise use expire time to guess + if waypoint.icon != 0 { + switch waypoint.icon { + case 1: return "📍" // Marker + case 2: return "🚗" // Car + case 3: return "🚶" // Person + case 4: return "🏠" // Home + case 5: return "⛺" // Camp + case 6: return "⚠️" // Warning + case 7: return "🏁" // Flag + case 8: return "🔍" // Search + case 9: return "🏥" // Medical + case 10: return "🔥" // Fire + case 11: return "🚁" // Helicopter + case 12: return "⛵" // Boat + case 13: return "🛸" // UFO + default: return "📍" + } + } + + // Fallback based on name + let name = waypoint.name.lowercased() + if name.contains("help") || name.contains("emergency") { + return "🆘" + } else if name.contains("medical") || name.contains("hospital") { + return "🏥" + } else if name.contains("danger") || name.contains("warning") { + return "⚠️" + } else if name.contains("camp") { + return "⛺" + } else if name.contains("home") || name.contains("house") { + return "🏠" + } else if name.contains("car") || name.contains("vehicle") { + return "🚗" + } else if name.contains("flag") { + return "🏁" + } else if name.contains("person") || name.contains("me") { + return "🚶" + } else { + return "📍" + } + } +} diff --git a/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift index 23a08afe..8985accf 100644 --- a/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift +++ b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift @@ -137,6 +137,16 @@ final class TAKMeshtasticBridge { /// Send a CoT message received from TAK to the Meshtastic mesh func sendToMesh(_ cotMessage: CoTMessage) async { + guard let takServerManager else { + Logger.tak.warning("Cannot send to mesh: TAKServerManager not available") + return + } + + guard !takServerManager.userReadOnlyMode else { + Logger.tak.info("TAK Server in read-only mode: Ignoring message from TAK client") + return + } + guard let accessoryManager else { Logger.tak.warning("Cannot send to mesh: AccessoryManager not available") return @@ -452,11 +462,37 @@ final class TAKMeshtasticBridge { } let uid = "MESHTASTIC-\(String(format: "%08X", node.num))" - let callsign = node.user?.shortName ?? node.user?.longName ?? "MESH-\(node.num)" - - // Get battery level from device metrics - let battery = Int(node.latestDeviceMetrics?.batteryLevel ?? 100) + // Format: "SHORT - Long Name" or just "ShortName" if no long name + let callsign: String + if let shortName = node.user?.shortName, let longName = node.user?.longName, !longName.isEmpty { + callsign = "\(shortName) - \(longName)" + } else { + callsign = node.user?.shortName ?? node.user?.longName ?? "MESH-\(node.num)" + } + // Get telemetry from device metrics + let deviceMetrics = node.latestDeviceMetrics + let battery = Int(deviceMetrics?.batteryLevel ?? 100) + let voltage = deviceMetrics?.voltage ?? 0 + let channelUtil = deviceMetrics?.channelUtilization ?? 0 + let rssi = deviceMetrics?.rssi ?? 0 + let snr = deviceMetrics?.snr ?? 0 + + // Build remarks with telemetry info + var remarks = "Battery: \(battery)%" + if voltage > 0 { + remarks += " | Voltage: \(String(format: "%.2f", voltage))V" + } + if channelUtil > 0 { + remarks += " | Chan Util: \(String(format: "%.1f", channelUtil))%" + } + if rssi != 0 { + remarks += " | RSSI: \(rssi) dBm" + } + if snr != 0 { + remarks += " | SNR: \(String(format: "%.1f", snr)) dB" + } + return CoTMessage.pli( uid: uid, callsign: callsign, @@ -468,7 +504,8 @@ final class TAKMeshtasticBridge { team: "Green", // Meshtastic nodes shown as green by default role: "Team Member", battery: battery, - staleMinutes: 15 // Meshtastic positions can be older + staleMinutes: 15, // Meshtastic positions can be older + remarks: remarks ) } @@ -476,24 +513,78 @@ final class TAKMeshtasticBridge { /// Send all known mesh node positions to TAK clients /// Useful when a new TAK client connects + /// Only sends nodes with positions updated within the last 2 hours + /// Excludes the node we're currently connected to func broadcastAllNodesToTAK() async { guard let takServerManager, takServerManager.isRunning else { return } - guard let context else { return } - + + // Get context - try the bridge's context first, then fall back to PersistenceController + let context = self.context ?? PersistenceController.shared.container.viewContext + + let twoHoursAgo = Date().addingTimeInterval(-7200) + + // Get the connected node number to exclude it + let connectedNodeNum = AccessoryManager.shared.activeDeviceNum ?? 0 + + Logger.tak.info("Starting broadcast of all mesh nodes to TAK (excluding node \(connectedNodeNum))") + + // Fetch all nodes - be more lenient, include any node that's been heard from + // We'll check positions when creating CoT messages let fetchRequest: NSFetchRequest = NodeInfoEntity.fetchRequest() - // Only nodes with valid positions - fetchRequest.predicate = NSPredicate(format: "latestPosition != nil") - + fetchRequest.predicate = NSPredicate( + format: "user != nil" + ) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "lastHeard", ascending: false)] + do { let nodes = try context.fetch(fetchRequest) - + Logger.tak.info("Found \(nodes.count) total nodes with user info, connected node: \(connectedNodeNum)") + + var broadcastCount = 0 + var skippedNoPosition = 0 + var skippedConnected = 0 + var skippedInvalidPosition = 0 + var skippedTooOld = 0 + for node in nodes { + // Skip the connected node - it's our own device + // Use the same pattern as other parts of the codebase: node.num == accessoryManager.activeDeviceNum + if node.num == connectedNodeNum { + Logger.tak.info("Skipping connected node \(node.num)") + skippedConnected += 1 + continue + } + + // Get position - use the extension's latestPosition computed property + guard let position = node.latestPosition, + let latitude = position.latitude, + let longitude = position.longitude else { + skippedNoPosition += 1 + continue + } + + // Skip nodes with invalid positions (0,0) + guard latitude != 0 || longitude != 0 else { + skippedInvalidPosition += 1 + continue + } + + // Check if node has been heard from recently (within last 2 hours) + if let lastHeard = node.lastHeard, lastHeard < twoHoursAgo { + skippedTooOld += 1 + continue + } + if let cotMessage = createCoTFromNode(node) { await takServerManager.broadcast(cotMessage) + broadcastCount += 1 + + // Small delay to avoid flooding the client + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms } } - Logger.tak.info("Broadcast \(nodes.count) mesh node positions to TAK clients") + Logger.tak.info("Broadcast complete: \(broadcastCount) nodes sent, \(skippedConnected) skipped (connected), \(skippedNoPosition) skipped (no position), \(skippedInvalidPosition) skipped (invalid position), \(skippedTooOld) skipped (too old)") } catch { Logger.tak.error("Failed to fetch nodes for TAK broadcast: \(error.localizedDescription)") } @@ -502,10 +593,12 @@ final class TAKMeshtasticBridge { // MARK: - Helper Methods private func lookupNodeInfo(nodeNum: UInt32) -> NodeInfoEntity? { - guard let context else { return nil } + // Use PersistenceController's viewContext directly to ensure we can find nodes + let context = PersistenceController.shared.container.viewContext + // Use the same format as MeshPackets - num is Int64 let fetchRequest: NSFetchRequest = NodeInfoEntity.fetchRequest() - fetchRequest.predicate = NSPredicate(format: "num == %d", Int64(nodeNum)) + fetchRequest.predicate = NSPredicate(format: "num == %lld", Int64(nodeNum)) fetchRequest.fetchLimit = 1 do { @@ -515,4 +608,823 @@ final class TAKMeshtasticBridge { return nil } } + + // MARK: - Mesh to CoT Broadcasting + + /// Broadcast a Meshtastic position packet to connected TAK clients + /// Called when a new position is received from the mesh + func broadcastMeshPositionToTAK(position: Position, from nodeNum: UInt32) async { + // Lazy initialization of bridge if needed + if TAKServerManager.shared.bridge == nil { + Logger.tak.info("Initializing bridge lazily for position broadcast") + let bridge = TAKMeshtasticBridge( + accessoryManager: AccessoryManager.shared, + takServerManager: TAKServerManager.shared + ) + bridge.context = AccessoryManager.shared.context + TAKServerManager.shared.bridge = bridge + } + + let server = TAKServerManager.shared + guard server.meshToCotEnabled, server.isRunning else { return } + guard server.connectedClients.isEmpty == false else { return } + + guard let node = lookupNodeInfo(nodeNum: nodeNum) else { return } + + if let cotMessage = createCoTFromNode(node) { + await server.broadcast(cotMessage) + Logger.tak.info("Broadcast mesh position to TAK: \(node.user?.longName ?? "Unknown")") + } + } + + /// Broadcast a Meshtastic text message to connected TAK clients + /// Called when a text message is received from the mesh + /// - Parameters: + /// - text: The message text + /// - from: The sender node number + /// - channel: The channel index + /// - to: The destination node number (UInt32.max for broadcast) + func broadcastMeshTextMessageToTAK(text: String, from nodeNum: UInt32, channel: UInt32, to destination: UInt32) async { + // Lazy initialization of bridge if needed + if TAKServerManager.shared.bridge == nil { + Logger.tak.info("Initializing bridge lazily for text message broadcast") + let bridge = TAKMeshtasticBridge( + accessoryManager: AccessoryManager.shared, + takServerManager: TAKServerManager.shared + ) + bridge.context = AccessoryManager.shared.context + TAKServerManager.shared.bridge = bridge + } + + let server = TAKServerManager.shared + guard server.meshToCotEnabled, server.isRunning else { return } + guard server.connectedClients.isEmpty == false else { return } + + guard let node = lookupNodeInfo(nodeNum: nodeNum), + let user = node.user else { return } + + let senderName = user.longName ?? user.shortName ?? "Unknown" + let uid = "MSG-\(nodeNum)-\(Int(Date().timeIntervalSince1970))" + + // Determine if this is a DM or broadcast + let isDirectMessage = destination != UInt32.max + + // For now, send all messages to general chat but mark DMs in the message + let chatroom = "All Chat Rooms" + + Logger.tak.info("Text message: isDM=\(isDirectMessage), chatroom=\(chatroom), from=\(senderName)") + + let senderUid = "MESHTASTIC-\(String(format: "%08X", nodeNum))" + + // Prefix DM messages with "DM:" so users know it's a direct message + let messageText = isDirectMessage ? "DM: \(text)" : text + + let cotMessage = CoTMessage( + uid: "GeoChat.\(senderUid).\(chatroom.replacingOccurrences(of: " ", with: "_")).\(uid)", + type: "b-t-f", + time: Date(), + start: Date(), + stale: Date().addingTimeInterval(86400), + how: "h-g-i-g-o", + latitude: 0, + longitude: 0, + hae: 9999999.0, + ce: 9999999.0, + le: 9999999.0, + contact: CoTContact(callsign: senderName, endpoint: "0.0.0.0:4242:tcp"), + chat: CoTChat( + message: messageText, + senderCallsign: senderName, + chatroom: chatroom + ), + remarks: messageText + ) + + await server.broadcast(cotMessage) + Logger.tak.info("Broadcast mesh text message to TAK: \(senderName) to \(chatroom)") + } + + /// Broadcast a Meshtastic waypoint to connected TAK clients + /// Called when a waypoints is received from the mesh + func broadcastMeshWaypointToTAK(waypoint: Waypoint, from nodeNum: UInt32) async { + // Lazy initialization of bridge if needed - set on singleton + if TAKServerManager.shared.bridge == nil { + Logger.tak.info("Initializing bridge lazily on singleton") + let bridge = TAKMeshtasticBridge( + accessoryManager: AccessoryManager.shared, + takServerManager: TAKServerManager.shared + ) + bridge.context = AccessoryManager.shared.context + TAKServerManager.shared.bridge = bridge + } + + let server = TAKServerManager.shared + Logger.tak.info("Waypoint broadcast check: meshToCot=\(server.meshToCotEnabled), isRunning=\(server.isRunning), clients=\(server.connectedClients.count)") + + guard server.meshToCotEnabled, server.isRunning else { + Logger.tak.warning("Waypoint broadcast skipped: server not ready") + return + } + guard let context, server.connectedClients.isEmpty == false else { + Logger.tak.warning("Waypoint broadcast skipped: no clients") + return + } + + let node = lookupNodeInfo(nodeNum: nodeNum) + Logger.tak.info("Node lookup for \(nodeNum) (0x\(String(format: "%08X", nodeNum))): \(node != nil ? "found" : "NOT FOUND")") + if let node = node { + Logger.tak.info(" Node user: \(node.user?.longName ?? "nil"), shortName: \(node.user?.shortName ?? "nil")") + } + let senderName = node?.user?.longName ?? node?.user?.shortName ?? "Unknown Node" + + let uid = "WAYPOINT-\(waypoint.id)" + let latitude = Double(waypoint.latitudeI) / 1e7 + let longitude = Double(waypoint.longitudeI) / 1e7 + + let name = waypoint.name.isEmpty ? "Dropped Pin" : waypoint.name + let description = waypoint.description_p.isEmpty ? "Meshtastic Waypoint" : waypoint.description_p + + Logger.tak.info("Broadcasting waypoint: \(name) at \(latitude), \(longitude), sender: \(senderName)") + + // Map Meshtastic emoji icon to appropriate TAK icon + let (cotType, iconPath, colorArgb) = getTakIconForWaypoint(waypoint: waypoint) + let userIconXML = "" + Logger.tak.info("Waypoint icon: emoji=0x\(String(format: "%08X", waypoint.icon)) -> \(iconPath)") + + // Handle expiry - if expire is 0, never expire. Otherwise use the expire time + let stale: Date + if waypoint.expire == 0 { + // Never expire - set to 1 year from now + stale = Date().addingTimeInterval(365 * 24 * 60 * 60) + Logger.tak.info("Waypoint set to never expire") + } else { + // expire is Unix timestamp when waypoint expires + let expireDate = Date(timeIntervalSince1970: TimeInterval(waypoint.expire)) + if expireDate > Date() { + stale = expireDate + } else { + // Already expired, don't broadcast + Logger.tak.warning("Waypoint already expired, skipping broadcast") + return + } + } + + // Include the usericon in the detail (no color to avoid background in TAKware) + let rawDetail = "\(userIconXML)" + + let cotMessage = CoTMessage( + uid: uid, + type: cotType, + time: Date(), + start: Date(), + stale: stale, + how: "m-g", + latitude: latitude, + longitude: longitude, + hae: 0, + ce: 10.0, + le: 10.0, + contact: CoTContact(callsign: "\(name) - \(senderName)", endpoint: "0.0.0.0:4242:tcp"), + remarks: "\(description)\nFrom: \(senderName) [\(String(format: "%08X", nodeNum))]", + rawDetailXML: rawDetail + ) + + await server.broadcast(cotMessage) + Logger.tak.info("Broadcast mesh waypoint to TAK: \(name) from \(senderName)") + } + + /// Map Meshtastic waypoint emoji to TAK icon + /// Returns (cotType, iconPath, colorArgb) + /// Icon paths use format: UUID/Category/icon.png + /// Priority: Google > Generic Icons (fallback) + private func getTakIconForWaypoint(waypoint: Waypoint) -> (String, String, String) { + let icon = waypoint.icon + + // Icon set UUIDs + let googleUUID = "f7f71666-8b28-4b57-9fbb-e38e61d33b79" + let genericUUID = "ad78aafb-83a6-4c07-b2b9-a897a8b6a38f" + + switch icon { + // 📍 📌 Pushpin - RED pushpin (default) + case 0x1F4CD, 0x1F4CC, 1: // 📍 📌 + return ("a-u-G", "\(genericUUID)/Tacks/red-pushpin.png", "-16776961") + + // === EMERGENCY === + // 🔥 Fire - Google firedept + case 0x1F525, 10: // 🔥 + return ("a-u-G", "\(googleUUID)/Google/firedept.png", "-16776961") + // 🚨 Siren - Google caution + case 0x1F6A8, 6: // 🚨 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-256") + // 🏥 Hospital - Google hospitals + case 0x1F3E5, 0x2695, 9: // 🏥 ➕ + return ("a-u-G", "\(googleUUID)/Google/hospitals.png", "-16776961") + // 🚑 Ambulance - Google hospitals (no ambulance in Google) + case 0x1F691: // 🚑 + return ("a-u-G", "\(googleUUID)/Google/hospitals.png", "-16776961") + // ⚠️ Warning - Google caution + case 0x26A0: // ⚠️ + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-256") + // 🚓 Police - Google police + case 0x1F693: // 🚓 + return ("a-u-G", "\(googleUUID)/Google/police.png", "-16776961") + // 🏃 Runner - Google man + case 0x1F3C3: // 🏃 + return ("a-u-G", "\(googleUUID)/Google/man.png", "-16711936") + // 💀 Skull - Google caution + case 0x1F480: // 💀 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-1") + // 💣 Bomb - Google caution + case 0x1F4A3: // 💣 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + + // === TRANSPORT === + // 🚗 Car - Google bus (closest) + case 0x1F697, 0x1F695, 2: // 🚗 🚕 + return ("a-u-G", "\(googleUUID)/Google/bus.png", "-256") + // 🚁 Helicopter - Google heliport + case 0x1F681, 11: // 🚁 + return ("a-u-G", "\(googleUUID)/Google/heliport.png", "-16776961") + // ⛵ Boat - Google marina + case 0x26F5, 12: // ⛵ + return ("a-u-G", "\(googleUUID)/Google/marina.png", "-16776961") + // 🚢 Ship - Google marina + case 0x1F6A2: // 🚢 + return ("a-u-G", "\(googleUUID)/Google/marina.png", "-16776961") + // 🚀 Rocket - Google target + case 0x1F680: // 🚀 + return ("a-u-G", "\(googleUUID)/Google/target.png", "-16776961") + // 🛸 UFO - Generic purple pushpin + case 0x1F6B8, 13: // 🛸 + return ("a-u-G", "\(genericUUID)/Tacks/purple-pushpin.png", "-65281") + // 🚲 Bicycle - Google cycling + case 0x1F6B2: // 🚲 + return ("a-u-G", "\(googleUUID)/Google/cycling.png", "-16711936") + // 🚆 Train - Google rail + case 0x1F686: // 🚆 + return ("a-u-G", "\(googleUUID)/Google/rail.png", "-16711936") + // ✈️ Plane - Google airports + case 0x2708: // ✈️ + return ("a-u-G", "\(googleUUID)/Google/airports.png", "-16776961") + // 🚛 Truck - Google bus + case 0x1F69A: // 🚛 + return ("a-u-G", "\(googleUUID)/Google/bus.png", "-16711936") + // 🚌 Bus - Google bus + case 0x1F68C: // 🚌 + return ("a-u-G", "\(googleUUID)/Google/bus.png", "-256") + + // === PLACES === + // 🏨 Hotel - Google lodging + case 0x1F3E8: // 🏨 + return ("a-u-G", "\(googleUUID)/Google/lodging.png", "-16776961") + // 🏪 Store - Google convenience + case 0x1F3EA: // 🏪 + return ("a-u-G", "\(googleUUID)/Google/convenience.png", "-16711936") + // ⛽ Gas - Google gas_stations + case 0x1F6FD: // ⛽ + return ("a-u-G", "\(googleUUID)/Google/gas_stations.png", "-16776961") + // 🏰 Castle - Google info + case 0x1F3F0: // 🏰 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🏛️ Government - Google info + case 0x1F3DB: // 🏛️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // ⛲ Fountain - Generic fountain (use info) + case 0x1F6F1: // ⛲ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🏞️ Park - Google parks + case 0x1F3DE: // 🏞️ + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16711936") + + // === PEOPLE === + // 🚶 Person - Google hiker + case 0x1F464, 0x1F465, 3: // 👤 👥 + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16711936") + + // === STRUCTURES === + // 🏠 House - Google homegardenbusiness + case 0x1F3E0, 0x1F3E1, 4: // 🏠 🏡 + return ("a-u-G", "\(googleUUID)/Google/homegardenbusiness.png", "-16711936") + // ⛺ Tent - Google campground + case 0x26FA, 0x1F3D5, 5: // ⛺ 🏕 + return ("a-u-G", "\(googleUUID)/Google/campground.png", "-256") + // 🏚️ Abandoned - Google info + case 0x1F6DA: // 🏚️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🏗️ Construction - Google caution + case 0x1F6D7: // 🏗️ + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // 🏭 Factory - Google info + case 0x1F3ED: // 🏭 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + + // === NATURE / TERRAIN === + // 🌲 Tree - Google parks + case 0x1F332: // 🌲 + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16711936") + // 🌳 Tree - Google parks + case 0x1F333: // 🌳 + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16711936") + // 🏔️ Mountain - Google cross-hairs + case 0x1F3D4: // 🏔️ + return ("a-u-G", "\(googleUUID)/Google/cross-hairs.png", "-1") + // ⛰️ Mountain - Google cross-hairs + case 0x26F0: // ⛰️ + return ("a-u-G", "\(googleUUID)/Google/cross-hairs.png", "-1") + // 💧 Water - Google water + case 0x1F4A7: // 💧 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // 🌊 Wave - Google water + case 0x1F30A: // 🌊 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // ☁️ Cloud - Google partly_cloudy + case 0x2601, 0x2602: // ☁ ☂ + return ("a-u-G", "\(googleUUID)/Google/partly_cloudy.png", "-1") + // 🌙 Moon - Google star + case 0x1F319: // 🌙 + return ("a-u-G", "\(googleUUID)/Google/star.png", "-16776961") + // ⚓ Anchor - Google marina + case 0x2693: // ⚓ + return ("a-u-G", "\(googleUUID)/Google/marina.png", "-16776961") + // ⭐ Star - Google star + case 0x2B50, 0x1F31F: // ⭐ 🌟 + return ("a-u-G", "\(googleUUID)/Google/star.png", "-256") + // 🌞 Sun - Google sunny + case 0x1F31E: // 🌞 + return ("a-u-G", "\(googleUUID)/Google/sunny.png", "-256") + + // === FLAGS/MARKERS === + // 🚩 Flag - Google flag + case 0x1F6A9: // 🚩 + return ("a-u-G", "\(googleUUID)/Google/flag.png", "-16776961") + // 🏁 Checkered flag - Google flag + case 0x1F3C1, 7: // 🏁 + return ("a-u-G", "\(googleUUID)/Google/flag.png", "-1") + // 🎌 Flags - Google flag + case 0x1F38C: // 🎌 + return ("a-u-G", "\(googleUUID)/Google/flag.png", "-16776961") + + // === OBJECTS === + // 📷 Camera - Google camera + case 0x1F4F7: // 📷 + return ("a-u-G", "\(googleUUID)/Google/camera.png", "-16711936") + // 🔒 Lock - Google info + case 0x1F512: // 🔒 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 🔑 Key - Google info + case 0x1F511: // 🔑 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 📦 Package - Google shopping + case 0x1F4E6: // 📦 + return ("a-u-G", "\(googleUUID)/Google/shopping.png", "-16711936") + // 🚧 Construction - Google caution + case 0x1F6A7: // 🚧 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-256") + // 🎯 Target - Google target + case 0x1F3AF: // 🎯 + return ("a-u-G", "\(googleUUID)/Google/target.png", "-16776961") + // 🏹 Sports bow - Google target + case 0x1F3F9: // 🏹 + return ("a-u-G", "\(googleUUID)/Google/target.png", "-16776961") + // 🔧 Wrench - Google mechanic + case 0x1F527: // 🔧 + return ("a-u-G", "\(googleUUID)/Google/mechanic.png", "-16711936") + // 🛠️ Tools - Google mechanic + case 0x1F6E0: // 🛠️ + return ("a-u-G", "\(googleUUID)/Google/mechanic.png", "-16711936") + // 📮 Post box - Google post_office + case 0x1F4EE: // 📮 + return ("a-u-G", "\(googleUUID)/Google/post_office.png", "-16776961") + // 💎 Gem - Google star + case 0x1F48E: // 💎 + return ("a-u-G", "\(googleUUID)/Google/star.png", "-16776961") + // 🔔 Bell - Google info + case 0x1F514: // 🔔 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-256") + // 🎁 Gift - Google shopping + case 0x1F381: // 🎁 + return ("a-u-G", "\(googleUUID)/Google/shopping.png", "-16776961") + // ❄️ Snowflake - Google snowflake_simple + case 0x2744: // ❄ + return ("a-u-G", "\(googleUUID)/Google/snowflake_simple.png", "-1") + // ☂️ Umbrella - Google sunny + case 0x26F1: // ⛱ + return ("a-u-G", "\(googleUUID)/Google/sunny.png", "-16776961") + // 💡 Light - Google info-i + case 0x1F4A1: // 💡 + return ("a-u-G", "\(googleUUID)/Google/info-i.png", "-256") + // 🔋 Battery - Google bars + case 0x1F50B: // 🔋 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-16711936") + // 📻 Radio - Google radio + case 0x1F4FB: // 📻 + return ("a-u-G", "\(googleUUID)/Google/radio.png", "-16711936") + // 📞 Phone - Google phone + case 0x1F4DE, 0x1F4F1: // 📞 📱 + return ("a-u-G", "\(googleUUID)/Google/phone.png", "-16711936") + // 💥 Collision - Google caution + case 0x1F4A5: // 💥 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // 🔦 Flashlight - Google sunny + case 0x1F526: // 🔦 + return ("a-u-G", "\(googleUUID)/Google/sunny.png", "-16711936") + // 🕯️ Candle - Google sunny + case 0x1F56F: // 🕯️ + return ("a-u-G", "\(googleUUID)/Google/sunny.png", "-16776961") + // 📺 TV - Google camera + case 0x1F4FA: // 📺 + return ("a-u-G", "\(googleUUID)/Google/camera.png", "-16711936") + // 💾 Disk - Google info + case 0x1F4BE: // 💾 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 📀 DVD - Google info + case 0x1F4C0: // 📀 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🖥️ Computer - Google info + case 0x1F5A5: // 🖥️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // ⌨️ Keyboard - Google info + case 0x1F5A8: // ⌨️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 🖱️ Mouse - Google info + case 0x1F5B1: // 🖱️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + + // === SYMBOLS === + // ❤️ Heart - Google flag + case 0x2764, 0x1F493, 0x1F49A, 0x1F499: // ❤️ 💓 💚 💙 + return ("a-u-G", "\(googleUUID)/Google/flag.png", "-16776961") + // ✅ Check - Google star + case 0x2705, 0x1F7E2: // ✅ 🟢 + return ("a-u-G", "\(googleUUID)/Google/star.png", "-16711936") + // ❌ X - Google caution + case 0x274C, 0x1F6AB: // ❌ 🚫 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // ➰ Curly loop - Google trail + case 0x1F0: // ➰ + return ("a-u-G", "\(googleUUID)/Google/trail.png", "-16776961") + // ➿ Double curly loop - Google trail + case 0x1F1F: // ➿ + return ("a-u-G", "\(googleUUID)/Google/trail.png", "-16776961") + + // === WEATHER === + // 🌤️ Sun behind cloud - Google partly_cloudy + case 0x1F324: // 🌤️ + return ("a-u-G", "\(googleUUID)/Google/partly_cloudy.png", "-256") + // 🌧️ Rain - Google rainy + case 0x1F327: // 🌧️ + return ("a-u-G", "\(googleUUID)/Google/rainy.png", "-16776961") + // 🌨️ Snow - Google snowflake_simple + case 0x1F328: // 🌨️ + return ("a-u-G", "\(googleUUID)/Google/snowflake_simple.png", "-1") + // 🌩️ Lightning - Google caution + case 0x1F329: // 🌩 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-256") + // 🌀 Cyclone - Google sunny + case 0x1F300: // 🌀 + return ("a-u-G", "\(googleUUID)/Google/sunny.png", "-16776961") + // 🌈 Rainbow - Google star + case 0x1F308: // 🌈 + return ("a-u-G", "\(googleUUID)/Google/star.png", "-16776961") + // 🌪️ Tornado - Google caution + case 0x1F32A: // 🌪️ + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-1") + // 🌋 Volcano - Google volcano + case 0x1F30B: // 🌋 + return ("a-u-G", "\(googleUUID)/Google/volcano.png", "-16776961") + // 🏜️ Desert - Google parks + case 0x1F3DC: // 🏜️ + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16776961") + // 🌫️ Fog - Google partly_cloudy + case 0x1F32B: // 🌫️ + return ("a-u-G", "\(googleUUID)/Google/partly_cloudy.png", "-16776961") + // 🌬️ Wind - Google partly_cloudy + case 0x1F32C: // 🌬️ + return ("a-u-G", "\(googleUUID)/Google/partly_cloudy.png", "-16711936") + + // === GLOBE === + // 🌍 Globe - Generic placemark_circle + case 0x1F30D, 0x1F30E, 0x1F30F, 0x1F310: // 🌍 🌎 🌏 🌐 + return ("a-u-G", "\(genericUUID)/Shapes/placemark_circle.png", "-16776961") + // 🗺️ Map - Generic placemark_square + case 0x1F5FA: // 🗺 + return ("a-u-G", "\(genericUUID)/Shapes/placemark_square.png", "-16776961") + // 🧭 Compass - Generic compass (use trail) + case 0x1F6AD: // 🧭 + return ("a-u-G", "\(googleUUID)/Google/trail.png", "-16776961") + + // === FOOD === + // 🍔 Burger - Google dining + case 0x1F354: // 🍔 + return ("a-u-G", "\(googleUUID)/Google/dining.png", "-256") + // 🍕 Pizza - Google dining + case 0x1F355: // 🍕 + return ("a-u-G", "\(googleUUID)/Google/dining.png", "-256") + // ☕ Coffee - Google coffee + case 0x2615: // ☕ + return ("a-u-G", "\(googleUUID)/Google/coffee.png", "-256") + // 🍺 Beer - Google bars + case 0x1F37A: // 🍺 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-256") + // 🍷 Wine - Google bars + case 0x1F377: // 🍷 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-65281") + // 🥗 Salad - Google dining + case 0x1F957: // 🥗 + return ("a-u-G", "\(googleUUID)/Google/dining.png", "-16711936") + // 🍿 Popcorn - Google movies + case 0x1F37F: // 🍿 + return ("a-u-G", "\(googleUUID)/Google/movies.png", "-16776961") + // 🍩 Donut - Google donut + case 0x1F369: // 🍩 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🍪 Cookie - Google donut + case 0x1F36A: // 🍪 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🍫 Chocolate - Google donut + case 0x1F36B: // 🍫 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🍬 Candy - Google donut + case 0x1F36C: // 🍬 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🍭 Lollipop - Google donut + case 0x1F36D: // 🍭 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🍦 Ice Cream - Google donut + case 0x1F368: // 🍦 + return ("a-u-G", "\(googleUUID)/Google/donut.png", "-16776961") + // 🥤 Cup - Google coffee + case 0x1F964: // 🥤 + return ("a-u-G", "\(googleUUID)/Google/coffee.png", "-16776961") + // 🍵 Tea - Google coffee + case 0x1F375: // 🍵 + return ("a-u-G", "\(googleUUID)/Google/coffee.png", "-16711936") + // 🥃 Whiskey - Google bars + case 0x1F943: // 🥃 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-16776961") + // 🥂 Cheers - Google bars + case 0x1F942: // 🥂 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-16776961") + // 🍾 Bottle - Google bars + case 0x1F37E: // 🍾 + return ("a-u-G", "\(googleUUID)/Google/bars.png", "-16776961") + + // === RECREATION === + // 🎣 Fishing - Google fishing + case 0x1F3A3: // 🎣 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // ⛳ Golf - Google golf + case 0x1F3CC: // ⛳ + return ("a-u-G", "\(googleUUID)/Google/golf.png", "-16711936") + // ⛷️ Ski - Google ski + case 0x1F3BF: // ⛷️ + return ("a-u-G", "\(googleUUID)/Google/ski.png", "-16711936") + // 🏊 Swimming - Google swimming + case 0x1F3CA: // 🏊 + return ("a-u-G", "\(googleUUID)/Google/swimming.png", "-16776961") + // 🏄 Surfing - Google swimming + case 0x1F3C4: // 🏄 + return ("a-u-G", "\(googleUUID)/Google/swimming.png", "-16776961") + // 🐟 Fish - Google fishing + case 0x1F41F: // 🐟 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🌾 Farm - Google parks + case 0x1F33E: // 🌾 + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16711936") + // 🐄 Farm Animal - Google parks + case 0x1F404: // 🐄 + return ("a-u-G", "\(googleUUID)/Google/parks.png", "-16711936") + // 🐕 Dog - Google hiker + case 0x1F415: // 🐕 + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16711936") + // 🐈 Cat - Google hiker + case 0x1F431: // 🐈 + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16711936") + // 🐓 Rooster - Google info + case 0x1F413: // 🐓 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦅 Eagle - Google info + case 0x1F425: // 🦅 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦋 Butterfly - Google info + case 0x1F98B: // 🦋 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐝 Bee - Google info + case 0x1F41D: // 🐝 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐞 Beetle - Google info + case 0x1F41E: // 🐞 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦀 Crab - Google fishing + case 0x1F980: // 🦀 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🦞 Lobster - Google fishing + case 0x1F99E: // 🦞 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🐚 Shell - Google fishing + case 0x1F41A: // 🐚 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🐙 Octopus - Google fishing + case 0x1F419: // 🐙 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🦑 Squid - Google fishing + case 0x1F991: // 🦑 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🦎 Lizard - Google info + case 0x1F98E: // 🦎 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐍 Snake - Google info + case 0x1F40D: // 🐍 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦖 T-Rex - Google info + case 0x1F996: // 🦖 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦕 Sauropod - Google info + case 0x1F995: // 🦕 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦈 Shark - Google fishing + case 0x1F988: // 🦈 + return ("a-u-G", "\(googleUUID)/Google/fishing.png", "-16776961") + // 🐳 Whale - Google water + case 0x1F433: // 🐳 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // 🐬 Dolphin - Google water + case 0x1F42C: // 🐬 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // 🐊 Crocodile - Google water + case 0x1F40A: // 🐊 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // 🐆 Leopard - Google info + case 0x1F406: // 🐆 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐅 Tiger - Google info + case 0x1F405: // 🐅 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐃 Buffalo - Google info + case 0x1F403: // 🐃 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐂 Ox - Google info + case 0x1F402: // 🐂 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐎 Horse - Google info + case 0x1F434: // 🐎 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐏 Ram - Google info + case 0x1F40F: // 🐏 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐑 Sheep - Google info + case 0x1F411: // 🐑 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐐 Goat - Google info + case 0x1F410: // 🐐 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦙 Llama - Google info + case 0x1F999: // 🦙 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐕‍🦺 Service Dog - Google hiker + case 0x1F9BA: // 🐕‍🦺 + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16776961") + // 🐩 Poodle - Google hiker + case 0x1F429: // 🐩 + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16776961") + // 🐈‍⬛ Black Cat - Google hiker + case 0x1F408: // 🐈‍⬛ + return ("a-u-G", "\(googleUUID)/Google/hiker.png", "-16776961") + // 🦝 Raccoon - Google info + case 0x1F99D: // 🦝 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦊 Fox - Google info + case 0x1F98A: // 🦊 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐻 Bear - Google info + case 0x1F43B: // 🐻 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐼 Panda - Google info + case 0x1F43C: // 🐼 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐨 Koala - Google info + case 0x1F428: // 🐨 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐯 Tiger - Google info + case 0x1F42F: // 🐯 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦁 Lion - Google info + case 0x1F981: // 🦁 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐮 Cow - Google info + case 0x1F42E: // 🐮 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐷 Pig - Google info + case 0x1F437: // 🐷 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐖 Pig (big) - Google info + case 0x1F416: // 🐖 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐗 Boar - Google info + case 0x1F417: // 🐗 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🐘 Elephant - Google info + case 0x1F418: // 🐘 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦏 Rhino - Google info + case 0x1F98F: // 🦏 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦛 Hippo - Google info + case 0x1F99B: // 🦛 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦒 Giraffe - Google info + case 0x1F992: // 🦒 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦬 Bison - Google info + case 0x1F9AC: // 🦬 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦣 Mammoth - Google info + case 0x1F9A3: // 🦣 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦌 Deer - Google info + case 0x1F98C: // 🦌 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🦌 Moose - Google info + case 0x1F98D: // 🦌 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + + // === INFRASTRUCTURE === + // 🚩 Checkpoint - Google flag + case 0x1F6A6: // 🚩 + return ("a-u-G", "\(googleUUID)/Google/flag.png", "-16776961") + // ⛔ No Entry - Google caution + case 0x26D4: // ⛔ + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // 🛑 Stop - Google caution + case 0x1F6D1: // 🛑 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // 🏢 Office Building - Google homegardenbusiness + case 0x1F3E2: // 🏢 + return ("a-u-G", "\(googleUUID)/Google/homegardenbusiness.png", "-16776961") + // 🏬 Bank - Google info + case 0x1F3E6: // 🏬 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🏩 Love Hotel - Google lodging + case 0x1F3E9: // 🏩 + return ("a-u-G", "\(googleUUID)/Google/lodging.png", "-16776961") + // 🛤️ Railway - Google rail + case 0x1F6E2: // 🛤️ + return ("a-u-G", "\(googleUUID)/Google/rail.png", "-16711936") + // 🛣️ Motorway - Google info + case 0x1F6E3: // 🛣️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🚎 Trolleybus - Google bus + case 0x1F68E: // 🚎 + return ("a-u-G", "\(googleUUID)/Google/bus.png", "-16776961") + // 🚈 Metro - Google rail + case 0x1F688: // 🚈 + return ("a-u-G", "\(googleUUID)/Google/rail.png", "-16711936") + // 🚊 Tram - Google tram + case 0x1F68A: // 🚊 + return ("a-u-G", "\(googleUUID)/Google/tram.png", "-16776961") + // 🚉 Station - Google rail + case 0x1F689: // 🚉 + return ("a-u-G", "\(googleUUID)/Google/rail.png", "-16776961") + // 🛃 Custom - Google info + case 0x1F6C3: // 🛃 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🛂 Passport control - Google info + case 0x1F6C2: // 🛂 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🚮 Litter - Google info + case 0x1F6AE: // 🚮 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 🚰 Water - Google water + case 0x1F6B0: // 🚰 + return ("a-u-G", "\(googleUUID)/Google/water.png", "-16776961") + // 🚱 Non-potable - Google caution + case 0x1F6B1: // 🚱 + return ("a-u-G", "\(googleUUID)/Google/caution.png", "-16776961") + // ♿ Wheelchair - Google wheel_chair_accessible + case 0x267F: // ♿ + return ("a-u-G", "\(googleUUID)/Google/wheel_chair_accessible.png", "-16711936") + // 🚻 Bathroom - Google info + case 0x1F6BB: // 🚻 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + // 🚹 Men's - Google info + case 0x1F6B9: // 🚹 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🚺 Women's - Google info + case 0x1F6BA: // 🚺 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🚼 Baby - Google info + case 0x1F6BC: // 🚼 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🚾 Loo - Google info + case 0x1F6BE: // 🚾 + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16776961") + // 🅿️ Parking - Google info + case 0x1F17F: // 🅿️ + return ("a-u-G", "\(googleUUID)/Google/info.png", "-16711936") + + // === Default - RED pushpin === + default: + return ("a-u-G", "\(genericUUID)/Tacks/red-pushpin.png", "-16776961") + } + } } diff --git a/Meshtastic/Helpers/TAK/TAKServerManager.swift b/Meshtastic/Helpers/TAK/TAKServerManager.swift index 182e47bb..b619af98 100644 --- a/Meshtastic/Helpers/TAK/TAKServerManager.swift +++ b/Meshtastic/Helpers/TAK/TAKServerManager.swift @@ -10,6 +10,44 @@ import Network import OSLog import Combine import SwiftUI +import CoreData +import MeshtasticProtobufs + +enum TAKServerError: LocalizedError { + case noServerCertificate + case noClientCACertificate + case tlsConfigurationFailed + case listenerFailed(String) + case clientNotFound + case notRunning + case primaryChannelInvalid(String) + + var errorDescription: String? { + switch self { + case .noServerCertificate: + return "No server certificate configured. Import a .p12 file with the server certificate and private key." + case .noClientCACertificate: + return "No client CA certificate configured. Import the CA certificate (.pem) used to sign client certificates." + case .tlsConfigurationFailed: + return "Failed to configure TLS settings." + case .listenerFailed(let reason): + return "Failed to start listener: \(reason)" + case .clientNotFound: + return "Client not found" + case .notRunning: + return "TAK Server is not running" + case .primaryChannelInvalid(let reason): + return reason + } + } +} + +struct PrimaryChannelIssue: Identifiable { + let id = UUID() + let title: String + let description: String + let canAutoFix: Bool +} /// Manages the TAK Server lifecycle, TLS connections, and client management /// Runs on MainActor for thread safety, following the AccessoryManager pattern @@ -23,6 +61,14 @@ final class TAKServerManager: ObservableObject { @Published private(set) var isRunning = false @Published private(set) var connectedClients: [TAKClientInfo] = [] @Published var lastError: String? + @Published private(set) var primaryChannelIssues: [PrimaryChannelIssue] = [] + @Published private(set) var readOnlyMode = false + + /// User toggle for read-only mode - locked to true if channel has issues + @AppStorage("takServerReadOnly") var userReadOnlyMode = false + + /// Enable Mesh to CoT converter - bridges Meshtastic packets to TAK format + @AppStorage("takServerMeshToCot") var meshToCotEnabled = false // MARK: - Configuration (persisted via AppStorage) @@ -89,6 +135,103 @@ final class TAKServerManager: ObservableObject { } } + // MARK: - Primary Channel Validation + + /// Check the primary channel for validity + /// Returns true if the primary channel is valid for TAK server operation + func checkPrimaryChannelValidity() { + let context = PersistenceController.shared.container.viewContext + let fetchRequest = MyInfoEntity.fetchRequest() + + var issues: [PrimaryChannelIssue] = [] + var isValid = true + + do { + let myInfos = try context.fetch(fetchRequest) + guard let myInfo = myInfos.first, + let channels = myInfo.channels?.array as? [ChannelEntity], + let primaryChannel = channels.first(where: { $0.index == 0 || $0.role == 1 }) else { + issues.append(PrimaryChannelIssue( + title: "No Primary Channel", + description: "No primary channel found on device", + canAutoFix: false + )) + isValid = false + updateChannelStatus(issues: issues, isValid: isValid) + return + } + + let channelName = primaryChannel.name ?? "" + let channelPsk = primaryChannel.psk ?? Data() + let pskBase64 = channelPsk.base64EncodedString() + + if channelName.isEmpty { + issues.append(PrimaryChannelIssue( + title: "Unnamed Primary Channel", + description: "TAK Server requires a private channel. Please set up a dedicated TAK channel (name 'TAK' recommended). Tap the button below to auto-configure.", + canAutoFix: true + )) + isValid = false + } + + // Use byte length for encryption strength checks (not Base64 string length) + let pskBytes = channelPsk.count + if pskBytes == 0 { + issues.append(PrimaryChannelIssue( + title: "Public Channel Not Supported", + description: "TAK Server requires a private channel with encryption. Public channels expose your location and messages. Tap the button below to set up a private TAK channel.", + canAutoFix: true + )) + isValid = false + } else if channelPsk == Data([0x01]) { + // Default key is single byte 0x01 + issues.append(PrimaryChannelIssue( + title: "Default Encryption Key", + description: "TAK Server requires a unique private channel key. The default key is not secure. Tap the button below to set up a proper private TAK channel.", + canAutoFix: true + )) + isValid = false + } else if pskBytes < 16 { + // Less than 128-bit (16 bytes) + issues.append(PrimaryChannelIssue( + title: "Weak Encryption Key", + description: "TAK Server requires at least 128-bit encryption for your privacy. Tap the button below to set up a secure private TAK channel.", + canAutoFix: true + )) + isValid = false + } + + updateChannelStatus(issues: issues, isValid: isValid) + + } catch { + Logger.tak.error("Failed to fetch MyInfo for channel validation: \(error.localizedDescription)") + issues.append(PrimaryChannelIssue( + title: "Error Checking Channel", + description: "Could not verify primary channel settings", + canAutoFix: false + )) + updateChannelStatus(issues: issues, isValid: false) + } + } + + private func updateChannelStatus(issues: [PrimaryChannelIssue], isValid: Bool) { + primaryChannelIssues = issues + readOnlyMode = !isValid + + if !isValid { + userReadOnlyMode = true + } + + if !isValid && isRunning { + Logger.tak.warning("TAK Server running in read-only mode due to primary channel issues") + } + } + + /// Check if TAK client messages should be forwarded to mesh + var shouldForwardTAKToMesh: Bool { + return !userReadOnlyMode + } + // MARK: - Server Lifecycle /// Start the TAK server (TLS or TCP based on configuration) @@ -98,6 +241,8 @@ final class TAKServerManager: ObservableObject { return } + checkPrimaryChannelValidity() + let mode = useTLS ? "TLS" : "TCP" Logger.tak.info("Starting TAK Server on port \(self.port) (\(mode) mode)") @@ -333,6 +478,11 @@ final class TAKServerManager: ObservableObject { case .connected(let clientInfo): connectedClients.append(clientInfo) Logger.tak.info("TAK client connected: \(clientInfo.displayName)") + + // Send all mesh node positions to the newly connected client + if meshToCotEnabled { + await bridge?.broadcastAllNodesToTAK() + } case .clientInfoUpdated(let clientInfo): // Update the client info in our list @@ -382,6 +532,25 @@ final class TAKServerManager: ObservableObject { } } } + + /// Ensure bridge is initialized and ready for mesh-to-CoT broadcasting + /// Returns true if broadcasting is possible (meshToCotEnabled, server running, clients connected) + /// Call this before any mesh-to-CoT broadcast operations + func ensureBridgeReadyForMeshToCot() -> Bool { + guard meshToCotEnabled, isRunning, !connectedClients.isEmpty else { return false } + + if bridge == nil { + Logger.tak.info("Initializing bridge for mesh-to-CoT broadcast") + let accessoryManager = AccessoryManager.shared + let newBridge = TAKMeshtasticBridge( + accessoryManager: accessoryManager, + takServerManager: self + ) + newBridge.context = accessoryManager.context + bridge = newBridge + } + return true + } /// Send a CoT message to a specific client func send(_ cotMessage: CoTMessage, to clientId: UUID) async throws { @@ -400,6 +569,113 @@ final class TAKServerManager: ObservableObject { throw TAKServerError.clientNotFound } + // MARK: - Auto-fix Primary Channel + + /// Automatically fix the primary channel to TAK-compatible settings + /// Sets: Name="TAK", 256-bit AES key, preserves existing LoRa channel + /// Returns true if successful + func autoFixPrimaryChannel() async -> Bool { + let accessoryManager = AccessoryManager.shared + + guard accessoryManager.isConnected else { + Logger.tak.error("Cannot fix channel: Not connected to device") + return false + } + + Logger.tak.info("Auto-fixing primary channel for TAK compatibility") + + let context = PersistenceController.shared.container.viewContext + + guard let connectedNodeNum = accessoryManager.activeDeviceNum else { + Logger.tak.error("Cannot fix channel: No active device number") + return false + } + + guard let connectedNode = getNodeInfo(id: connectedNodeNum, context: context), + let user = connectedNode.user else { + Logger.tak.error("Cannot fix channel: No connected node or user found") + return false + } + + let fetchRequest = MyInfoEntity.fetchRequest() + + do { + let myInfos = try context.fetch(fetchRequest) + guard let myInfo = myInfos.first, + let channels = myInfo.channels?.array as? [ChannelEntity], + let primaryChannel = channels.first(where: { $0.index == 0 || $0.role == 1 }) else { + Logger.tak.error("Cannot fix channel: No primary channel found") + return false + } + + let newKey = generateChannelKey(size: 32) + guard let newPsk = Data(base64Encoded: newKey) else { + Logger.tak.error("Failed to decode generated channel key; aborting primary channel fix") + return false + } + + primaryChannel.name = "TAK" + primaryChannel.psk = newPsk + primaryChannel.role = 1 + primaryChannel.index = 0 + + if let mutableChannels = myInfo.channels?.mutableCopy() as? NSMutableOrderedSet { + if mutableChannels.contains(primaryChannel) { + mutableChannels.remove(primaryChannel) + mutableChannels.insert(primaryChannel, at: 0) + myInfo.channels = mutableChannels.copy() as? NSOrderedSet + } + } + + try context.save() + + var channel = Channel() + channel.index = 0 + channel.role = .primary + channel.settings.name = "TAK" + channel.settings.psk = newPsk + channel.settings.uplinkEnabled = primaryChannel.uplinkEnabled + channel.settings.downlinkEnabled = primaryChannel.downlinkEnabled + channel.settings.moduleSettings.positionPrecision = UInt32(primaryChannel.positionPrecision) + + try await accessoryManager.saveChannel(channel: channel, fromUser: user, toUser: user) + + Logger.tak.info("Successfully fixed primary channel: name=TAK, key=256-bit") + + // Also set LoRa modem preset to shortFast for optimal TAK performance + var loraConfig = Config.LoRaConfig() + loraConfig.modemPreset = .shortFast + loraConfig.usePreset = true + loraConfig.txEnabled = true + loraConfig.hopLimit = 3 + + // Get current LoRa config to preserve other settings + if let currentLoRa = connectedNode.loRaConfig { + loraConfig.region = Config.LoRaConfig.RegionCode(rawValue: Int(currentLoRa.regionCode)) ?? .unset + loraConfig.channelNum = UInt32(currentLoRa.channelNum) + loraConfig.txPower = Int32(currentLoRa.txPower) + loraConfig.bandwidth = UInt32(currentLoRa.bandwidth) + loraConfig.codingRate = UInt32(currentLoRa.codingRate) + loraConfig.spreadFactor = UInt32(currentLoRa.spreadFactor) + } + + do { + try await accessoryManager.saveLoRaConfig(config: loraConfig, fromUser: user, toUser: user) + Logger.tak.info("Successfully set LoRa modem preset to shortFast") + } catch { + Logger.tak.warning("Failed to set LoRa modem preset: \(error.localizedDescription)") + } + + checkPrimaryChannelValidity() + + return true + + } catch { + Logger.tak.error("Failed to fix primary channel: \(error.localizedDescription)") + return false + } + } + // MARK: - Status /// Get server status description @@ -414,31 +690,3 @@ final class TAKServerManager: ObservableObject { } } } - -// MARK: - Server Errors - -enum TAKServerError: LocalizedError { - case noServerCertificate - case noClientCACertificate - case tlsConfigurationFailed - case listenerFailed(String) - case clientNotFound - case notRunning - - var errorDescription: String? { - switch self { - case .noServerCertificate: - return "No server certificate configured. Import a .p12 file with the server certificate and private key." - case .noClientCACertificate: - return "No client CA certificate configured. Import the CA certificate (.pem) used to sign client certificates." - case .tlsConfigurationFailed: - return "Failed to configure TLS settings." - case .listenerFailed(let reason): - return "Failed to start listener: \(reason)" - case .clientNotFound: - return "Client not found" - case .notRunning: - return "TAK Server is not running" - } - } -} diff --git a/Meshtastic/Views/Settings/TAKServerConfig.swift b/Meshtastic/Views/Settings/TAKServerConfig.swift index 09d9d60d..cb8e3666 100644 --- a/Meshtastic/Views/Settings/TAKServerConfig.swift +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -26,6 +26,7 @@ struct TAKServerConfig: View { ) private var channels: FetchedResults @StateObject private var takServer = TAKServerManager.shared + @Environment(\.dismiss) private var dismiss @State private var showingFileImporter = false @State private var importType: CertificateImportType = .p12 @State private var p12Password = "" @@ -35,17 +36,40 @@ struct TAKServerConfig: View { @State private var showingImportError = false @State private var showingFileExporter = false @State private var dataPackageURL: URL? + @State private var showingFixWarning = false + @State private var isFixingChannel = false + @State private var showShareChannels = false + @State private var showShareChannelsAlert = false + @State private var connectedNode: NodeInfoEntity? + @State private var isWarningExpanded = true private let certManager = TAKCertificateManager.shared var body: some View { Form { + if !takServer.primaryChannelIssues.isEmpty { + primaryChannelWarningSection + } serverStatusSection serverConfigSection certificatesSection dataPackageSection } .navigationTitle("TAK Server") + .onAppear { + takServer.checkPrimaryChannelValidity() + if let nodeNum = accessoryManager.activeDeviceNum { + connectedNode = getNodeInfo(id: nodeNum, context: context) + } + } + .alert("Fix Primary Channel?", isPresented: $showingFixWarning) { + Button("Cancel", role: .cancel) {} + Button("Fix Channel", role: .destructive) { + fixPrimaryChannel() + } + } message: { + Text("This will change your primary channel to:\n• Name: TAK\n• Encryption: New 256-bit AES key\n• LoRa preset: Short Fast (recommended for TAK)\n\nThis is required for TAK Server to work properly. Any existing channel sharing links will become invalid.") + } .fileImporter( isPresented: $showingFileImporter, allowedContentTypes: importType == .p12 ? [UTType(filenameExtension: "p12") ?? .pkcs12, .pkcs12] : [UTType(filenameExtension: "pem") ?? .plainText], @@ -75,6 +99,14 @@ struct TAKServerConfig: View { } message: { Text(importError ?? "Unknown error") } + .alert("Channel Fixed!", isPresented: $showShareChannelsAlert) { + Button("Share with TAK Buddies") { + showShareChannels = true + } + Button("Later", role: .cancel) {} + } message: { + Text("Your channel has been configured for TAK. To share the QR code: go to Settings > Share QR Code") + } .fileExporter( isPresented: $showingFileExporter, document: dataPackageURL.map { ZipDocument(url: $0) }, @@ -94,6 +126,65 @@ struct TAKServerConfig: View { } dataPackageURL = nil } + .navigationDestination(isPresented: $showShareChannels) { + if let node = connectedNode { + ShareChannels(node: node) + } + } + } + + // MARK: - Primary Channel Warning Section + + private var primaryChannelWarningSection: some View { + Section { + DisclosureGroup(isExpanded: $isWarningExpanded) { + VStack(alignment: .leading, spacing: 12) { + if takServer.readOnlyMode { + Text("Your primary channel is using the default settings (no name or default encryption key). TAK Server is running in read-only mode.") + .font(.subheadline) + .foregroundColor(.secondary) + } + + Text("You can fix this yourself by changing your primary channel:") + .font(.subheadline) + + VStack(alignment: .leading, spacing: 4) { + Label("Set a channel name", systemImage: "1.circle.fill") + Label("Use a 256-bit encryption key", systemImage: "2.circle.fill") + } + .font(.caption) + .foregroundColor(.secondary) + + Divider() + + Button { + showingFixWarning = true + } label: { + Label("Auto-Fix Channel", systemImage: "wand.and.stars") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isFixingChannel) + + Text("Or fix it yourself in Channels settings, then return here.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + .padding(.vertical, 8) + } label: { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("TAK Cannot Be Used on Public Channel") + .font(.headline) + } + } + } header: { + Text("Warning") + } } // MARK: - Server Status Section @@ -122,6 +213,19 @@ struct TAKServerConfig: View { .foregroundColor(.orange) } } + + if let node = connectedNode, + let role = node.user?.role, + let deviceRole = DeviceRoles(rawValue: Int(role)), + deviceRole != .tak && deviceRole != .takTracker { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Device role is \"\(deviceRole.name)\". Consider setting to TAK or TAK Tracker for optimal operation.") + .font(.caption) + .foregroundColor(.orange) + } + } } header: { Text("Server Status") } @@ -150,6 +254,26 @@ struct TAKServerConfig: View { .foregroundColor(.secondary) } + Toggle(isOn: $takServer.userReadOnlyMode) { + VStack(alignment: .leading, spacing: 2) { + Text("Read-Only Mode") + Text("Meshtastic -> TAK works, TAK -> Meshtastic blocked") + .font(.caption) + .foregroundColor(.secondary) + } + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .disabled(takServer.readOnlyMode) + + Toggle(isOn: $takServer.meshToCotEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("Mesh to CoT Converter") + Text("Bridge Meshtastic positions, nodes, waypoints, and messages to TAK/CoT format") + .font(.caption) + .foregroundColor(.secondary) + } + } + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) if !channels.isEmpty { Picker(selection: $takServer.channel) { ForEach(channels, id: \.index) { channel in @@ -387,6 +511,23 @@ struct TAKServerConfig: View { } } + private func fixPrimaryChannel() { + isFixingChannel = true + Task { + let success = await takServer.autoFixPrimaryChannel() + await MainActor.run { + isFixingChannel = false + if success { + takServer.userReadOnlyMode = false + showShareChannelsAlert = true + } else { + importError = "Failed to fix primary channel. Make sure you are connected to a device." + showingImportError = true + } + } + } + } + // MARK: - Data Package Generation private func generateAndShareDataPackage() { From 24a7270e50937b284ed6d1bc5fa5d00c7246bca1 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:59:11 -0700 Subject: [PATCH 12/20] 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> --- README.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) 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 46b5cf77b2db959c1a408b43aa621ea28a5ca1d8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:03:12 -0700 Subject: [PATCH 13/20] feat: automated bug report analyzer via GitHub Models API (#1666) * feat: add automated bug report analyzer GitHub Action Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4db827aa-1b4c-4b6a-a820-3ecbd3908602 Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * feat: add automated bug report analyzer GitHub Action Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4db827aa-1b4c-4b6a-a820-3ecbd3908602 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> --- .github/workflows/bug-report-analysis.yml | 312 ++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 .github/workflows/bug-report-analysis.yml diff --git a/.github/workflows/bug-report-analysis.yml b/.github/workflows/bug-report-analysis.yml new file mode 100644 index 00000000..eabf4844 --- /dev/null +++ b/.github/workflows/bug-report-analysis.yml @@ -0,0 +1,312 @@ +name: 🐞 Bug Report Analyzer + +on: + issues: + types: [opened, labeled] + +permissions: + issues: write + contents: read + models: read + +jobs: + analyze-bug-report: + name: Analyze Bug Report + runs-on: ubuntu-latest + # Run when a bug or triage label is present on the issue, or was just applied + if: | + contains(github.event.issue.labels.*.name, 'bug') || + contains(github.event.issue.labels.*.name, 'triage') || + github.event.label.name == 'bug' || + github.event.label.name == 'triage' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Analyze bug report and post findings + uses: actions/github-script@v7 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + script: | + const BOT_COMMENT_MARKER = ''; + const MODELS_API_URL = 'https://models.inference.ai.azure.com/chat/completions'; + + // ── tuneable constants ──────────────────────────────────────────── + // Minimum character count for a field to be considered non-blank. + const MIN_FIELD_LENGTH = 10; + // Steps-to-reproduce needs more detail than a one-liner to be useful. + const MIN_STEPS_LENGTH = 30; + // Cap how many tokens the model may return per response. + const MAX_RESPONSE_TOKENS = 1200; + // Low temperature → deterministic, factual answers (not creative). + const MODEL_TEMPERATURE = 0.2; + // How deep to recurse when scanning the repo for Swift files. + const MAX_SEARCH_DEPTH = 4; + // Max number of file paths sent to the model for relevance ranking. + const MAX_FILES_TO_LIST = 300; + // Max number of files whose contents are actually read and included. + const MAX_FILES_TO_READ = 5; + // Ask the model to return a slightly larger set so that if some paths + // don't exist we still have MAX_FILES_TO_READ valid candidates to read. + const FILE_SELECTION_BUFFER = 3; + // Max lines read from each source file to stay within token budget. + const MAX_LINES_PER_FILE = 250; + + // ── helpers ────────────────────────────────────────────────────── + + async function callModelsAPI(systemMessage, userMessage) { + const response = await fetch(MODELS_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: systemMessage }, + { role: 'user', content: userMessage }, + ], + max_tokens: MAX_RESPONSE_TOKENS, + temperature: MODEL_TEMPERATURE, + }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Models API ${response.status}: ${text}`); + } + const data = await response.json(); + return data.choices[0].message.content.trim(); + } + + function extractSection(body, heading) { + // Matches GitHub issue form sections: ### Heading\ncontent + const re = new RegExp( + `###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=\\n###|$)`, + 'i' + ); + const m = body.match(re); + if (!m) return ''; + const value = m[1].trim(); + return value === '_No response_' ? '' : value; + } + + function isBlank(s) { + return !s || s.length < MIN_FIELD_LENGTH; + } + + // ── main ───────────────────────────────────────────────────────── + + const issue = context.payload.issue; + const body = issue.body || ''; + const title = issue.title || ''; + + // Skip if we have already left an analysis comment on this issue. + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + per_page: 100, + }); + if (comments.some(c => c.body.includes(BOT_COMMENT_MARKER))) { + core.info('Already analyzed this issue – skipping.'); + return; + } + + // ── parse template fields ───────────────────────────────────────── + + const firmwareVersion = extractSection(body, 'Firmware Version'); + const stepsToReproduce = extractSection(body, 'What did you do\\?'); + const expectedBehavior = extractSection(body, 'Expected Behavior'); + const currentBehavior = extractSection(body, 'Current Behavior'); + const additionalComments = extractSection(body, 'Additional comments'); + + // ── completeness check ──────────────────────────────────────────── + + const missing = []; + if (isBlank(firmwareVersion)) + missing.push( + '- **Firmware Version** – please provide the exact version string ' + + '(e.g. `2.3.14.abcdef1`). You can find it under *Settings → Firmware* ' + + 'in the app or on the node screen.' + ); + if (isBlank(stepsToReproduce) || stepsToReproduce.length < MIN_STEPS_LENGTH) + missing.push( + '- **Steps to Reproduce** – please list numbered, minimal steps that ' + + 'consistently trigger the issue. Include your iOS/iPadOS version and ' + + 'device model.' + ); + if (isBlank(expectedBehavior)) + missing.push( + '- **Expected Behavior** – describe what you expected to happen.' + ); + if (isBlank(currentBehavior)) + missing.push( + '- **Current Behavior** – describe what actually happens instead.' + ); + + if (missing.length > 0) { + const commentBody = `${BOT_COMMENT_MARKER} +## 🤖 Additional Information Needed + +Thank you for filing this bug report! To help us isolate the root cause we need a bit more detail: + +${missing.join('\n')} + +### Helpful extras (if applicable) +- iOS / iPadOS version and device model +- Whether this is a **regression** – did it work in an earlier version? +- Console logs or a crash report from the app's [Debug Log](https://meshtastic.org/docs/software/apple/ios-debug/) feature +- Screenshots or a screen recording if the issue is visual + +Please update the issue with the missing information and we'll take another look. Thank you! 🙏`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: commentBody, + }); + core.info('Posted "needs more info" comment.'); + return; + } + + // ── code analysis ───────────────────────────────────────────────── + + const SYSTEM_MESSAGE = + 'You are an expert iOS/macOS Swift developer helping to triage bug ' + + 'reports for the Meshtastic Apple app – a SwiftUI mesh-radio ' + + 'communication app that uses Bluetooth LE and a Core Data stack. ' + + 'Be concise, specific, and reference real code paths when possible.'; + + try { + const fs = require('fs'); + const path = require('path'); + + // Collect all Swift source file paths (max depth 4, skip generated dirs). + const SKIP_DIRS = new Set([ + 'node_modules', '.git', 'DerivedData', 'build', + 'MeshtasticProtobufs', + ]); + + function collectSwiftFiles(dir, depth) { + if (depth > MAX_SEARCH_DEPTH) return []; + const results = []; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch (_) { return results; } + for (const e of entries) { + if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) { + results.push(...collectSwiftFiles(full, depth + 1)); + } else if (e.name.endsWith('.swift')) { + results.push(full); + } + } + return results; + } + + const root = process.cwd(); + const allFiles = collectSwiftFiles(root, 0); + const fileList = allFiles + .map(f => path.relative(root, f)) + .slice(0, MAX_FILES_TO_LIST) + .join('\n'); + + // Ask the model which files are most relevant. + const fileSelectionPrompt = + `Bug title: ${title}\n` + + `Steps to reproduce: ${stepsToReproduce}\n` + + `Expected: ${expectedBehavior}\n` + + `Current: ${currentBehavior}\n` + + (additionalComments ? `Additional: ${additionalComments}\n` : '') + + `\nAvailable Swift source files:\n${fileList}\n\n` + + 'Return ONLY a JSON array (no markdown, no explanation) of the ' + + `${MAX_FILES_TO_READ}–${MAX_FILES_TO_READ + FILE_SELECTION_BUFFER} ` + + 'file paths most likely to contain the bug.'; + + let relevantFiles = []; + try { + const raw = await callModelsAPI(SYSTEM_MESSAGE, fileSelectionPrompt); + // Strip potential markdown fences before parsing. + const cleaned = raw.replace(/```[a-z]*\n?/g, '').trim(); + relevantFiles = JSON.parse(cleaned); + } catch (e) { + core.warning(`File selection failed: ${e.message}`); + } + + // Read up to MAX_FILES_TO_READ files, capping each at MAX_LINES_PER_FILE lines to stay within token budget. + let codeContext = ''; + for (const relPath of relevantFiles.slice(0, MAX_FILES_TO_READ)) { + const absPath = path.join(root, relPath); + if (!fs.existsSync(absPath)) continue; + try { + const content = fs.readFileSync(absPath, 'utf8'); + const snippet = content.split('\n').slice(0, MAX_LINES_PER_FILE).join('\n'); + codeContext += `\n\n### ${relPath}\n\`\`\`swift\n${snippet}\n\`\`\``; + } catch (_) {} + } + + const analysisPrompt = + `Bug title: ${title}\n` + + `Firmware Version: ${firmwareVersion}\n` + + `Steps to reproduce: ${stepsToReproduce}\n` + + `Expected: ${expectedBehavior}\n` + + `Current: ${currentBehavior}\n` + + (additionalComments ? `Additional: ${additionalComments}\n` : '') + + (codeContext + ? `\nRelevant source code:${codeContext}\n` + : '\n(No source files matched – reason from code structure)\n') + + '\nPlease provide:\n' + + '1. **Likely root cause** – a concise hypothesis with references to ' + + 'specific files, types, or functions.\n' + + '2. **Relevant code areas** – file paths and line ranges worth ' + + 'investigating.\n' + + '3. **Clarifying questions** – any details that would confirm or rule ' + + 'out the hypothesis.\n' + + '4. **Suggested investigation steps** – what a developer should do ' + + 'next.\n'; + + const analysis = await callModelsAPI(SYSTEM_MESSAGE, analysisPrompt); + + const commentBody = `${BOT_COMMENT_MARKER} +## 🤖 Automated Bug Report Analysis + +Thank you for the detailed report! Here is an automated analysis to help the maintainers investigate: + +${analysis} + +--- +*This analysis was generated automatically from the issue description and the repository source. A human maintainer will review and follow up shortly.*`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: commentBody, + }); + core.info('Posted analysis comment.'); + + } catch (error) { + core.warning(`AI analysis failed (${error.message}). Posting fallback acknowledgement.`); + + const fallback = `${BOT_COMMENT_MARKER} +## 🤖 Bug Report Received + +Thank you for this detailed bug report! A maintainer will review it and investigate the root cause. + +If you can provide any of the following it will speed up the investigation: +- Device logs from the [Debug Log](https://meshtastic.org/docs/software/apple/ios-debug/) feature +- Whether this is a regression (last known-good firmware version) +- A minimal set of steps that consistently reproduce the issue`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: fallback, + }); + } From b9e6fa9106cf71b8f786f5fde30fd0c5a60f9bec Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:54:50 -0700 Subject: [PATCH 14/20] fix: remove invalid ASSETCATALOG_OTHER_FLAGS causing dSYM upload CI failures (#1669) The flag '--enable-icon-stack-fallback-generation=disabled' is not recognized by actool on Xcode 16.x, causing every run of the 'Upload dSYM Files' workflow to fail at the build step with exit code 65. Remove it from both the Debug and Release build configurations. Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/fa589034-4017-44ea-9130-123a396d2abd Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --- Meshtastic.xcodeproj/project.pbxproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index e3504190..6cebec83 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -2157,7 +2157,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - ASSETCATALOG_OTHER_FLAGS = "--enable-icon-stack-fallback-generation=disabled"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Meshtastic/Meshtastic.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; @@ -2196,7 +2195,6 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - ASSETCATALOG_OTHER_FLAGS = "--enable-icon-stack-fallback-generation=disabled"; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Meshtastic/Meshtastic.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; From e07c4db6be3dbe8d5afe923527dfe8528c6b526a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:57:05 -0700 Subject: [PATCH 15/20] feat: add workflow_dispatch trigger to bug-report-analysis workflow (#1670) * feat: add workflow_dispatch trigger to bug-report-analysis workflow Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/98aed224-7836-4026-aebb-e6d3fd0c7d9f Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> * fix: add input validation and error handling for workflow_dispatch issue fetch Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/98aed224-7836-4026-aebb-e6d3fd0c7d9f 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> --- .github/workflows/bug-report-analysis.yml | 33 +++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bug-report-analysis.yml b/.github/workflows/bug-report-analysis.yml index eabf4844..25b263fd 100644 --- a/.github/workflows/bug-report-analysis.yml +++ b/.github/workflows/bug-report-analysis.yml @@ -3,6 +3,12 @@ name: 🐞 Bug Report Analyzer on: issues: types: [opened, labeled] + workflow_dispatch: + inputs: + issue_number: + description: 'Issue number to analyze' + required: true + type: number permissions: issues: write @@ -13,8 +19,9 @@ jobs: analyze-bug-report: name: Analyze Bug Report runs-on: ubuntu-latest - # Run when a bug or triage label is present on the issue, or was just applied + # Run when a bug or triage label is present on the issue, was just applied, or triggered manually if: | + github.event_name == 'workflow_dispatch' || contains(github.event.issue.labels.*.name, 'bug') || contains(github.event.issue.labels.*.name, 'triage') || github.event.label.name == 'bug' || @@ -99,7 +106,29 @@ jobs: // ── main ───────────────────────────────────────────────────────── - const issue = context.payload.issue; + // Support manual workflow_dispatch by fetching the issue when triggered that way. + let issue; + if (context.eventName === 'workflow_dispatch') { + const issueNumber = parseInt(context.payload.inputs.issue_number, 10); + if (!Number.isInteger(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue_number: "${context.payload.inputs.issue_number}". Must be a positive integer.`); + return; + } + try { + const { data } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + issue = data; + } catch (err) { + core.setFailed(`Could not fetch issue #${issueNumber}: ${err.message}`); + return; + } + } else { + issue = context.payload.issue; + } + const body = issue.body || ''; const title = issue.title || ''; From 546210be4ef92756042a3c5a266de3284dfedc9b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:04:55 -0700 Subject: [PATCH 16/20] Add `run-name` to bug-report-analysis workflow (#1671) * Initial plan * Add run-name to bug-report-analysis workflow Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/07120f74-0e40-4631-b636-062a5b39d021 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> --- .github/workflows/bug-report-analysis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/bug-report-analysis.yml b/.github/workflows/bug-report-analysis.yml index 25b263fd..3aa42f55 100644 --- a/.github/workflows/bug-report-analysis.yml +++ b/.github/workflows/bug-report-analysis.yml @@ -1,4 +1,5 @@ name: 🐞 Bug Report Analyzer +run-name: "🐞 Bug Analysis for Issue #${{ github.event.issue.number || inputs.issue_number }}" on: issues: From f2b8b7afd79345de3a03daeb6c0116ca616a01ff Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Thu, 16 Apr 2026 21:11:20 -0700 Subject: [PATCH 17/20] Delete .github/workflows/bug-report-analysis.yml --- .github/workflows/bug-report-analysis.yml | 342 ---------------------- 1 file changed, 342 deletions(-) delete mode 100644 .github/workflows/bug-report-analysis.yml diff --git a/.github/workflows/bug-report-analysis.yml b/.github/workflows/bug-report-analysis.yml deleted file mode 100644 index 3aa42f55..00000000 --- a/.github/workflows/bug-report-analysis.yml +++ /dev/null @@ -1,342 +0,0 @@ -name: 🐞 Bug Report Analyzer -run-name: "🐞 Bug Analysis for Issue #${{ github.event.issue.number || inputs.issue_number }}" - -on: - issues: - types: [opened, labeled] - workflow_dispatch: - inputs: - issue_number: - description: 'Issue number to analyze' - required: true - type: number - -permissions: - issues: write - contents: read - models: read - -jobs: - analyze-bug-report: - name: Analyze Bug Report - runs-on: ubuntu-latest - # Run when a bug or triage label is present on the issue, was just applied, or triggered manually - if: | - github.event_name == 'workflow_dispatch' || - contains(github.event.issue.labels.*.name, 'bug') || - contains(github.event.issue.labels.*.name, 'triage') || - github.event.label.name == 'bug' || - github.event.label.name == 'triage' - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Analyze bug report and post findings - uses: actions/github-script@v7 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - script: | - const BOT_COMMENT_MARKER = ''; - const MODELS_API_URL = 'https://models.inference.ai.azure.com/chat/completions'; - - // ── tuneable constants ──────────────────────────────────────────── - // Minimum character count for a field to be considered non-blank. - const MIN_FIELD_LENGTH = 10; - // Steps-to-reproduce needs more detail than a one-liner to be useful. - const MIN_STEPS_LENGTH = 30; - // Cap how many tokens the model may return per response. - const MAX_RESPONSE_TOKENS = 1200; - // Low temperature → deterministic, factual answers (not creative). - const MODEL_TEMPERATURE = 0.2; - // How deep to recurse when scanning the repo for Swift files. - const MAX_SEARCH_DEPTH = 4; - // Max number of file paths sent to the model for relevance ranking. - const MAX_FILES_TO_LIST = 300; - // Max number of files whose contents are actually read and included. - const MAX_FILES_TO_READ = 5; - // Ask the model to return a slightly larger set so that if some paths - // don't exist we still have MAX_FILES_TO_READ valid candidates to read. - const FILE_SELECTION_BUFFER = 3; - // Max lines read from each source file to stay within token budget. - const MAX_LINES_PER_FILE = 250; - - // ── helpers ────────────────────────────────────────────────────── - - async function callModelsAPI(systemMessage, userMessage) { - const response = await fetch(MODELS_API_URL, { - method: 'POST', - headers: { - Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: 'gpt-4o-mini', - messages: [ - { role: 'system', content: systemMessage }, - { role: 'user', content: userMessage }, - ], - max_tokens: MAX_RESPONSE_TOKENS, - temperature: MODEL_TEMPERATURE, - }), - }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`Models API ${response.status}: ${text}`); - } - const data = await response.json(); - return data.choices[0].message.content.trim(); - } - - function extractSection(body, heading) { - // Matches GitHub issue form sections: ### Heading\ncontent - const re = new RegExp( - `###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=\\n###|$)`, - 'i' - ); - const m = body.match(re); - if (!m) return ''; - const value = m[1].trim(); - return value === '_No response_' ? '' : value; - } - - function isBlank(s) { - return !s || s.length < MIN_FIELD_LENGTH; - } - - // ── main ───────────────────────────────────────────────────────── - - // Support manual workflow_dispatch by fetching the issue when triggered that way. - let issue; - if (context.eventName === 'workflow_dispatch') { - const issueNumber = parseInt(context.payload.inputs.issue_number, 10); - if (!Number.isInteger(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue_number: "${context.payload.inputs.issue_number}". Must be a positive integer.`); - return; - } - try { - const { data } = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - }); - issue = data; - } catch (err) { - core.setFailed(`Could not fetch issue #${issueNumber}: ${err.message}`); - return; - } - } else { - issue = context.payload.issue; - } - - const body = issue.body || ''; - const title = issue.title || ''; - - // Skip if we have already left an analysis comment on this issue. - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - per_page: 100, - }); - if (comments.some(c => c.body.includes(BOT_COMMENT_MARKER))) { - core.info('Already analyzed this issue – skipping.'); - return; - } - - // ── parse template fields ───────────────────────────────────────── - - const firmwareVersion = extractSection(body, 'Firmware Version'); - const stepsToReproduce = extractSection(body, 'What did you do\\?'); - const expectedBehavior = extractSection(body, 'Expected Behavior'); - const currentBehavior = extractSection(body, 'Current Behavior'); - const additionalComments = extractSection(body, 'Additional comments'); - - // ── completeness check ──────────────────────────────────────────── - - const missing = []; - if (isBlank(firmwareVersion)) - missing.push( - '- **Firmware Version** – please provide the exact version string ' + - '(e.g. `2.3.14.abcdef1`). You can find it under *Settings → Firmware* ' + - 'in the app or on the node screen.' - ); - if (isBlank(stepsToReproduce) || stepsToReproduce.length < MIN_STEPS_LENGTH) - missing.push( - '- **Steps to Reproduce** – please list numbered, minimal steps that ' + - 'consistently trigger the issue. Include your iOS/iPadOS version and ' + - 'device model.' - ); - if (isBlank(expectedBehavior)) - missing.push( - '- **Expected Behavior** – describe what you expected to happen.' - ); - if (isBlank(currentBehavior)) - missing.push( - '- **Current Behavior** – describe what actually happens instead.' - ); - - if (missing.length > 0) { - const commentBody = `${BOT_COMMENT_MARKER} -## 🤖 Additional Information Needed - -Thank you for filing this bug report! To help us isolate the root cause we need a bit more detail: - -${missing.join('\n')} - -### Helpful extras (if applicable) -- iOS / iPadOS version and device model -- Whether this is a **regression** – did it work in an earlier version? -- Console logs or a crash report from the app's [Debug Log](https://meshtastic.org/docs/software/apple/ios-debug/) feature -- Screenshots or a screen recording if the issue is visual - -Please update the issue with the missing information and we'll take another look. Thank you! 🙏`; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: commentBody, - }); - core.info('Posted "needs more info" comment.'); - return; - } - - // ── code analysis ───────────────────────────────────────────────── - - const SYSTEM_MESSAGE = - 'You are an expert iOS/macOS Swift developer helping to triage bug ' + - 'reports for the Meshtastic Apple app – a SwiftUI mesh-radio ' + - 'communication app that uses Bluetooth LE and a Core Data stack. ' + - 'Be concise, specific, and reference real code paths when possible.'; - - try { - const fs = require('fs'); - const path = require('path'); - - // Collect all Swift source file paths (max depth 4, skip generated dirs). - const SKIP_DIRS = new Set([ - 'node_modules', '.git', 'DerivedData', 'build', - 'MeshtasticProtobufs', - ]); - - function collectSwiftFiles(dir, depth) { - if (depth > MAX_SEARCH_DEPTH) return []; - const results = []; - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch (_) { return results; } - for (const e of entries) { - if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue; - const full = path.join(dir, e.name); - if (e.isDirectory()) { - results.push(...collectSwiftFiles(full, depth + 1)); - } else if (e.name.endsWith('.swift')) { - results.push(full); - } - } - return results; - } - - const root = process.cwd(); - const allFiles = collectSwiftFiles(root, 0); - const fileList = allFiles - .map(f => path.relative(root, f)) - .slice(0, MAX_FILES_TO_LIST) - .join('\n'); - - // Ask the model which files are most relevant. - const fileSelectionPrompt = - `Bug title: ${title}\n` + - `Steps to reproduce: ${stepsToReproduce}\n` + - `Expected: ${expectedBehavior}\n` + - `Current: ${currentBehavior}\n` + - (additionalComments ? `Additional: ${additionalComments}\n` : '') + - `\nAvailable Swift source files:\n${fileList}\n\n` + - 'Return ONLY a JSON array (no markdown, no explanation) of the ' + - `${MAX_FILES_TO_READ}–${MAX_FILES_TO_READ + FILE_SELECTION_BUFFER} ` + - 'file paths most likely to contain the bug.'; - - let relevantFiles = []; - try { - const raw = await callModelsAPI(SYSTEM_MESSAGE, fileSelectionPrompt); - // Strip potential markdown fences before parsing. - const cleaned = raw.replace(/```[a-z]*\n?/g, '').trim(); - relevantFiles = JSON.parse(cleaned); - } catch (e) { - core.warning(`File selection failed: ${e.message}`); - } - - // Read up to MAX_FILES_TO_READ files, capping each at MAX_LINES_PER_FILE lines to stay within token budget. - let codeContext = ''; - for (const relPath of relevantFiles.slice(0, MAX_FILES_TO_READ)) { - const absPath = path.join(root, relPath); - if (!fs.existsSync(absPath)) continue; - try { - const content = fs.readFileSync(absPath, 'utf8'); - const snippet = content.split('\n').slice(0, MAX_LINES_PER_FILE).join('\n'); - codeContext += `\n\n### ${relPath}\n\`\`\`swift\n${snippet}\n\`\`\``; - } catch (_) {} - } - - const analysisPrompt = - `Bug title: ${title}\n` + - `Firmware Version: ${firmwareVersion}\n` + - `Steps to reproduce: ${stepsToReproduce}\n` + - `Expected: ${expectedBehavior}\n` + - `Current: ${currentBehavior}\n` + - (additionalComments ? `Additional: ${additionalComments}\n` : '') + - (codeContext - ? `\nRelevant source code:${codeContext}\n` - : '\n(No source files matched – reason from code structure)\n') + - '\nPlease provide:\n' + - '1. **Likely root cause** – a concise hypothesis with references to ' + - 'specific files, types, or functions.\n' + - '2. **Relevant code areas** – file paths and line ranges worth ' + - 'investigating.\n' + - '3. **Clarifying questions** – any details that would confirm or rule ' + - 'out the hypothesis.\n' + - '4. **Suggested investigation steps** – what a developer should do ' + - 'next.\n'; - - const analysis = await callModelsAPI(SYSTEM_MESSAGE, analysisPrompt); - - const commentBody = `${BOT_COMMENT_MARKER} -## 🤖 Automated Bug Report Analysis - -Thank you for the detailed report! Here is an automated analysis to help the maintainers investigate: - -${analysis} - ---- -*This analysis was generated automatically from the issue description and the repository source. A human maintainer will review and follow up shortly.*`; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: commentBody, - }); - core.info('Posted analysis comment.'); - - } catch (error) { - core.warning(`AI analysis failed (${error.message}). Posting fallback acknowledgement.`); - - const fallback = `${BOT_COMMENT_MARKER} -## 🤖 Bug Report Received - -Thank you for this detailed bug report! A maintainer will review it and investigate the root cause. - -If you can provide any of the following it will speed up the investigation: -- Device logs from the [Debug Log](https://meshtastic.org/docs/software/apple/ios-debug/) feature -- Whether this is a regression (last known-good firmware version) -- A minimal set of steps that consistently reproduce the issue`; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: fallback, - }); - } From adb6960c1ba8e9c84a2ed033b6c84df98b6d709a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:13:11 -0700 Subject: [PATCH 18/20] Add Bug Report Analyzer GitHub Actions workflow (#1672) Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/18d7e6a9-e22d-470b-b475-a7a30c1daaa2 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --- .github/workflows/bug-report-analyzer.yml | 341 ++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 .github/workflows/bug-report-analyzer.yml diff --git a/.github/workflows/bug-report-analyzer.yml b/.github/workflows/bug-report-analyzer.yml new file mode 100644 index 00000000..49850552 --- /dev/null +++ b/.github/workflows/bug-report-analyzer.yml @@ -0,0 +1,341 @@ +name: Bug Report Analyzer + +on: + issues: + types: [opened, labeled] + workflow_dispatch: + inputs: + issue_number: + description: 'Issue number to analyze' + required: true + type: number + +permissions: + issues: write + contents: read + models: read + +jobs: + analyze-bug-report: + name: Analyze Bug Report + runs-on: ubuntu-latest + # Run when a bug or triage label is present on the issue, was just applied, or triggered manually + if: | + github.event_name == 'workflow_dispatch' || + contains(github.event.issue.labels.*.name, 'bug') || + contains(github.event.issue.labels.*.name, 'triage') || + github.event.label.name == 'bug' || + github.event.label.name == 'triage' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Analyze bug report and post findings + uses: actions/github-script@v7 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + script: | + const BOT_COMMENT_MARKER = ''; + const MODELS_API_URL = 'https://models.inference.ai.azure.com/chat/completions'; + + // ── tuneable constants ──────────────────────────────────────────── + // Minimum character count for a field to be considered non-blank. + const MIN_FIELD_LENGTH = 10; + // Steps-to-reproduce needs more detail than a one-liner to be useful. + const MIN_STEPS_LENGTH = 30; + // Cap how many tokens the model may return per response. + const MAX_RESPONSE_TOKENS = 1200; + // Low temperature → deterministic, factual answers (not creative). + const MODEL_TEMPERATURE = 0.2; + // How deep to recurse when scanning the repo for Swift files. + const MAX_SEARCH_DEPTH = 4; + // Max number of file paths sent to the model for relevance ranking. + const MAX_FILES_TO_LIST = 300; + // Max number of files whose contents are actually read and included. + const MAX_FILES_TO_READ = 5; + // Ask the model to return a slightly larger set so that if some paths + // don't exist we still have MAX_FILES_TO_READ valid candidates to read. + const FILE_SELECTION_BUFFER = 3; + // Max lines read from each source file to stay within token budget. + const MAX_LINES_PER_FILE = 250; + + // ── helpers ────────────────────────────────────────────────────── + + async function callModelsAPI(systemMessage, userMessage) { + const response = await fetch(MODELS_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: systemMessage }, + { role: 'user', content: userMessage }, + ], + max_tokens: MAX_RESPONSE_TOKENS, + temperature: MODEL_TEMPERATURE, + }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Models API ${response.status}: ${text}`); + } + const data = await response.json(); + return data.choices[0].message.content.trim(); + } + + function extractSection(body, heading) { + // Matches GitHub issue form sections: ### Heading\ncontent + const re = new RegExp( + `###\\s*${heading}\\s*\\n([\\s\\S]*?)(?=\\n###|$)`, + 'i' + ); + const m = body.match(re); + if (!m) return ''; + const value = m[1].trim(); + return value === '_No response_' ? '' : value; + } + + function isBlank(s) { + return !s || s.length < MIN_FIELD_LENGTH; + } + + // ── main ───────────────────────────────────────────────────────── + + // Support manual workflow_dispatch by fetching the issue when triggered that way. + let issue; + if (context.eventName === 'workflow_dispatch') { + const issueNumber = parseInt(context.payload.inputs.issue_number, 10); + if (!Number.isInteger(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue_number: "${context.payload.inputs.issue_number}". Must be a positive integer.`); + return; + } + try { + const { data } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + issue = data; + } catch (err) { + core.setFailed(`Could not fetch issue #${issueNumber}: ${err.message}`); + return; + } + } else { + issue = context.payload.issue; + } + + const body = issue.body || ''; + const title = issue.title || ''; + + // Skip if we have already left an analysis comment on this issue. + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + per_page: 100, + }); + if (comments.some(c => c.body.includes(BOT_COMMENT_MARKER))) { + core.info('Already analyzed this issue – skipping.'); + return; + } + + // ── parse template fields ───────────────────────────────────────── + + const firmwareVersion = extractSection(body, 'Firmware Version'); + const stepsToReproduce = extractSection(body, 'What did you do\\?'); + const expectedBehavior = extractSection(body, 'Expected Behavior'); + const currentBehavior = extractSection(body, 'Current Behavior'); + const additionalComments = extractSection(body, 'Additional comments'); + + // ── completeness check ──────────────────────────────────────────── + + const missing = []; + if (isBlank(firmwareVersion)) + missing.push( + '- **Firmware Version** – please provide the exact version string ' + + '(e.g. `2.3.14.abcdef1`). You can find it under *Settings → Firmware* ' + + 'in the app or on the node screen.' + ); + if (isBlank(stepsToReproduce) || stepsToReproduce.length < MIN_STEPS_LENGTH) + missing.push( + '- **Steps to Reproduce** – please list numbered, minimal steps that ' + + 'consistently trigger the issue. Include your iOS/iPadOS version and ' + + 'device model.' + ); + if (isBlank(expectedBehavior)) + missing.push( + '- **Expected Behavior** – describe what you expected to happen.' + ); + if (isBlank(currentBehavior)) + missing.push( + '- **Current Behavior** – describe what actually happens instead.' + ); + + if (missing.length > 0) { + const commentBody = `${BOT_COMMENT_MARKER} + ## 🤖 Additional Information Needed + + Thank you for filing this bug report! To help us isolate the root cause we need a bit more detail: + + ${missing.join('\n')} + + ### Helpful extras (if applicable) + - iOS / iPadOS version and device model + - Whether this is a **regression** – did it work in an earlier version? + - Console logs or a crash report from the app's [Debug Log](https://meshtastic.org/docs/software/apple/ios-debug/) feature + - Screenshots or a screen recording if the issue is visual + + Please update the issue with the missing information and we'll take another look. Thank you! 🙏`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: commentBody, + }); + core.info('Posted "needs more info" comment.'); + return; + } + + // ── code analysis ───────────────────────────────────────────────── + + const SYSTEM_MESSAGE = + 'You are an expert iOS/macOS Swift developer helping to triage bug ' + + 'reports for the Meshtastic Apple app – a SwiftUI mesh-radio ' + + 'communication app that uses Bluetooth LE and a Core Data stack. ' + + 'Be concise, specific, and reference real code paths when possible.'; + + try { + const fs = require('fs'); + const path = require('path'); + + // Collect all Swift source file paths (max depth 4, skip generated dirs). + const SKIP_DIRS = new Set([ + 'node_modules', '.git', 'DerivedData', 'build', + 'MeshtasticProtobufs', + ]); + + function collectSwiftFiles(dir, depth) { + if (depth > MAX_SEARCH_DEPTH) return []; + const results = []; + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch (_) { return results; } + for (const e of entries) { + if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) { + results.push(...collectSwiftFiles(full, depth + 1)); + } else if (e.name.endsWith('.swift')) { + results.push(full); + } + } + return results; + } + + const root = process.cwd(); + const allFiles = collectSwiftFiles(root, 0); + const fileList = allFiles + .map(f => path.relative(root, f)) + .slice(0, MAX_FILES_TO_LIST) + .join('\n'); + + // Ask the model which files are most relevant. + const fileSelectionPrompt = + `Bug title: ${title}\n` + + `Steps to reproduce: ${stepsToReproduce}\n` + + `Expected: ${expectedBehavior}\n` + + `Current: ${currentBehavior}\n` + + (additionalComments ? `Additional: ${additionalComments}\n` : '') + + `\nAvailable Swift source files:\n${fileList}\n\n` + + 'Return ONLY a JSON array (no markdown, no explanation) of the ' + + `${MAX_FILES_TO_READ}–${MAX_FILES_TO_READ + FILE_SELECTION_BUFFER} ` + + 'file paths most likely to contain the bug.'; + + let relevantFiles = []; + try { + const raw = await callModelsAPI(SYSTEM_MESSAGE, fileSelectionPrompt); + // Strip potential markdown fences before parsing. + const cleaned = raw.replace(/```[a-z]*\n?/g, '').trim(); + relevantFiles = JSON.parse(cleaned); + } catch (e) { + core.warning(`File selection failed: ${e.message}`); + } + + // Read up to MAX_FILES_TO_READ files, capping each at MAX_LINES_PER_FILE lines to stay within token budget. + let codeContext = ''; + for (const relPath of relevantFiles.slice(0, MAX_FILES_TO_READ)) { + const absPath = path.join(root, relPath); + if (!fs.existsSync(absPath)) continue; + try { + const content = fs.readFileSync(absPath, 'utf8'); + const snippet = content.split('\n').slice(0, MAX_LINES_PER_FILE).join('\n'); + codeContext += `\n\n### ${relPath}\n\`\`\`swift\n${snippet}\n\`\`\``; + } catch (_) {} + } + + const analysisPrompt = + `Bug title: ${title}\n` + + `Firmware Version: ${firmwareVersion}\n` + + `Steps to reproduce: ${stepsToReproduce}\n` + + `Expected: ${expectedBehavior}\n` + + `Current: ${currentBehavior}\n` + + (additionalComments ? `Additional: ${additionalComments}\n` : '') + + (codeContext + ? `\nRelevant source code:${codeContext}\n` + : '\n(No source files matched – reason from code structure)\n') + + '\nPlease provide:\n' + + '1. **Likely root cause** – a concise hypothesis with references to ' + + 'specific files, types, or functions.\n' + + '2. **Relevant code areas** – file paths and line ranges worth ' + + 'investigating.\n' + + '3. **Clarifying questions** – any details that would confirm or rule ' + + 'out the hypothesis.\n' + + '4. **Suggested investigation steps** – what a developer should do ' + + 'next.\n'; + + const analysis = await callModelsAPI(SYSTEM_MESSAGE, analysisPrompt); + + const commentBody = `${BOT_COMMENT_MARKER} + ## 🤖 Automated Bug Report Analysis + + Thank you for the detailed report! Here is an automated analysis to help the maintainers investigate: + + ${analysis} + + --- + *This analysis was generated automatically from the issue description and the repository source. A human maintainer will review and follow up shortly.*`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: commentBody, + }); + core.info('Posted analysis comment.'); + + } catch (error) { + core.warning(`AI analysis failed (${error.message}). Posting fallback acknowledgement.`); + + const fallback = `${BOT_COMMENT_MARKER} + ## 🤖 Bug Report Received + + Thank you for this detailed bug report! A maintainer will review it and investigate the root cause. + + If you can provide any of the following it will speed up the investigation: + - Device logs from the Debug Log feature + - Whether this is a regression (last known-good firmware version) + - A minimal set of steps that consistently reproduce the issue`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: fallback, + }); + } From 4854c725846b9b4fe891db30b7b51cc646f7407a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:26:53 -0700 Subject: [PATCH 19/20] Update bug-report-analyzer to use gpt-5.4 model (#1673) Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/2388c071-ccd5-4875-b092-ff60b0a3a5ae Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --- .github/workflows/bug-report-analyzer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bug-report-analyzer.yml b/.github/workflows/bug-report-analyzer.yml index 49850552..12fb495b 100644 --- a/.github/workflows/bug-report-analyzer.yml +++ b/.github/workflows/bug-report-analyzer.yml @@ -71,7 +71,7 @@ jobs: 'Content-Type': 'application/json', }, body: JSON.stringify({ - model: 'gpt-4o-mini', + model: 'gpt-5.4', messages: [ { role: 'system', content: systemMessage }, { role: 'user', content: userMessage }, From 047c1c8f5fe56c415e008e05855fc0bcd3a0d8a6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:48:33 -0700 Subject: [PATCH 20/20] fix: set BOT_COMMENT_MARKER to unique HTML comment to fix dedup check (#1675) Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/e1333deb-1db6-4175-963f-9f5035a1356b Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com> --- .github/workflows/bug-report-analyzer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bug-report-analyzer.yml b/.github/workflows/bug-report-analyzer.yml index 12fb495b..17fc21db 100644 --- a/.github/workflows/bug-report-analyzer.yml +++ b/.github/workflows/bug-report-analyzer.yml @@ -37,7 +37,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: script: | - const BOT_COMMENT_MARKER = ''; + const BOT_COMMENT_MARKER = ''; const MODELS_API_URL = 'https://models.inference.ai.azure.com/chat/completions'; // ── tuneable constants ────────────────────────────────────────────