diff --git a/.github/workflows/bug-report-analyzer.yml b/.github/workflows/bug-report-analyzer.yml new file mode 100644 index 00000000..17fc21db --- /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-5.4', + 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, + }); + } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 740bee8f..dfbefd0d 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -2,10 +2,30 @@ "sourceLanguage" : "en", "strings" : { "" : { - "shouldTranslate" : false + "shouldTranslate" : false, + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } }, "\t%@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "\t%@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "\t%@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39,8 +59,104 @@ }, "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -76,6 +192,18 @@ }, " %@%%" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%%" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " %@%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -97,71 +225,95 @@ }, "shouldTranslate" : false }, - ": %@" : { + " : %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : " : %@" + } + }, + "es" : { + "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" + } + }, + "es" : { + "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 +321,18 @@ }, "(Re)define PIN_GPS_EN for your board." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "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", @@ -181,6 +345,12 @@ "value" : "ボード用のPIN_GPS_ENを(再)定義してください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "(Пере)определить PIN_GPS_EN для вашей платы." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -203,6 +373,18 @@ }, "%@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -238,12 +420,24 @@ }, "%@ - %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ - %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -256,6 +450,12 @@ "value" : "%1$@ - %2$@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -279,12 +479,24 @@ }, "%@ - %@ - %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ - %3$@" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ - %2$@ - %3$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ - %3$@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -297,6 +509,12 @@ "value" : "%1$@ - %2$@ - %3$@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ - %3$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -320,6 +538,12 @@ }, "%@ - %@ Towards %@ Back" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ Mod %3$@ Tilbage" + } + }, "en" : { "stringUnit" : { "state" : "translated", @@ -338,6 +562,12 @@ "value" : "%1$@ - %2$@ 送信 %3$@ 受信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ В обратном %3$@ направлении" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -360,12 +590,24 @@ }, "%@ - No Response" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - Intet svar" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%@ - Keine Antwort" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - Ninguna respuesta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -378,6 +620,12 @@ "value" : "%@ - 応答なし" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - Нет ответа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -400,12 +648,24 @@ }, "%@ - Not Sent" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - Ikke afsendt" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%@ - Nicht gesendet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - No enviado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -418,6 +678,12 @@ "value" : "%@ - 送信されませんでした" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ - Не отправлены" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -440,12 +706,24 @@ }, "%@ (%@)" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ (%2$@)" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ (%2$@)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ (%2$@)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -458,6 +736,12 @@ "value" : "%1$@ (%2$@)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ (%2$@)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -480,13 +764,26 @@ "shouldTranslate" : false }, "%@ %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -499,6 +796,12 @@ "value" : "%1$@ %2$@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -522,12 +825,24 @@ }, "%@ %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$lld" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@ %2$lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -540,6 +855,12 @@ "value" : "%1$@ %2$lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -563,12 +884,24 @@ }, "%@ away" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ væk" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%@ entfernt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "a %@ de distancia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -581,6 +914,12 @@ "value" : "%@ 離れた場所" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ доступны " + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -603,12 +942,24 @@ }, "%@ can be up to %@ bytes long." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ kan være op til %@ bytes lang." + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%@ kann bis zu %@ Byte lang sein." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ puede ser hasta %@ bytes de longitud." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -621,6 +972,12 @@ "value" : "%1$@ は最大 %2$@ バイトまで設定できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ может иметь длину до %@ байт." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -643,6 +1000,18 @@ }, "%@ Channels?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ kanaler?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Canales?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -655,6 +1024,12 @@ "value" : "%@ チャンネル?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ каналов?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -677,6 +1052,18 @@ }, "%@ 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", + "value" : "%@ данные конфигурации были запрошены через PKC admin, но ответа от удаленной ноды получено не было." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -687,6 +1074,18 @@ }, "%@ dB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -699,6 +1098,12 @@ "value" : "%@ dB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -725,12 +1130,24 @@ }, "%@, %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@, %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -743,6 +1160,12 @@ "value" : "%1$@, %2$@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@, %2$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -766,12 +1189,24 @@ }, "%@: %lld / %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$lld / %3$lld" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$@: %2$lld / %3$lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$lld / %3$lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -784,6 +1219,12 @@ "value" : "%1$@: %2$lld / %3$lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$lld / %3$lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -807,6 +1248,18 @@ }, "%@%%" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%%" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -819,6 +1272,12 @@ "value" : "%@%%" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -841,6 +1300,18 @@ }, "%@°F" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@°F" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@°F" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -853,6 +1324,12 @@ "value" : "%@°F" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@°F" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -875,6 +1352,18 @@ }, "%@mA" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@mA" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@mA" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -887,6 +1376,12 @@ "value" : "%@mA" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@мА" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -903,6 +1398,18 @@ }, "%@V" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@V" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@V" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -915,6 +1422,12 @@ "value" : "%@V" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : " %@В" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -931,6 +1444,18 @@ }, "%d" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -943,6 +1468,12 @@ "value" : "%d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -965,6 +1496,24 @@ }, "%d Hops" : { "localizations" : { + "da" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ét hop" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d hop" + } + } + } + } + }, "en" : { "variations" : { "plural" : { @@ -983,6 +1532,24 @@ } } }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d Salto" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d Saltos" + } + } + } + } + }, "it" : { "variations" : { "plural" : { @@ -1013,6 +1580,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" : { @@ -1064,7 +1661,20 @@ } }, "%d%%" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d%%" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1077,6 +1687,12 @@ "value" : "%d%%" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d%%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1098,7 +1714,20 @@ } }, "%f%%" : { + "extractionState" : "stale", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%f%%" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%f%%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1108,7 +1737,20 @@ } }, "%lf" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lf" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lf" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1121,6 +1763,12 @@ "value" : "%lf" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lf" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1143,6 +1791,18 @@ }, "%lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1155,6 +1815,12 @@ "value" : "%lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1176,6 +1842,7 @@ } }, "%lld %@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1183,6 +1850,18 @@ "value" : "%1$lld %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld %2$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1229,6 +1908,54 @@ } } }, + "es" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld feature" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld features" + } + } + } + } + }, + "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" : { @@ -1257,12 +1984,24 @@ }, "%lld or less hops away" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "afstand på %lld eller færre hop" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%lld oder weniger Hops entfernt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld o menos saltos de distancia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1275,6 +2014,12 @@ "value" : "%lldホップ以下" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld или меньше хопов доступны" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1291,6 +2036,18 @@ }, "%lld Readings Total" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samlet %lld aflæsninger" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Lecturas Totales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1303,6 +2060,12 @@ "value" : "計 %lld 件の読み取り値" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld общее количество показаний" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1319,6 +2082,18 @@ }, "%lld Total Detection Events" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Samlet %lld detektioner" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Eventos de Detección Totales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1331,6 +2106,12 @@ "value" : "計 %lld 件の検出イベント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld общее количество событий обнаружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1347,6 +2128,18 @@ }, "%lld%%" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1359,6 +2152,12 @@ "value" : "%lld%%" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1381,12 +2180,24 @@ }, "%llddb Transmit Power" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dB sendestyrke" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "%llddb Übertragungsleistung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%llddb Potencia de Transmisión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1399,6 +2210,12 @@ "value" : "%llddb送信電力" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%llddb мощность передачи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1421,6 +2238,12 @@ }, "%llddBm Transmit Power" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dBm sendestyrke" + } + }, "de" : { "stringUnit" : { "state" : "translated", @@ -1439,6 +2262,12 @@ "value" : "%llddBm送信電力" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : " %llddBm мощность передачи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1458,6 +2287,18 @@ }, "< 1%" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "< 1%" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "< 1%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1470,6 +2311,12 @@ "value" : "< 1%" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "< 1%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1492,10 +2339,36 @@ }, "⚠️ 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" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ El valor configurado: (%@) no es una de las opciones optimizadas." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Настроенное значение: (%@) не является одним из оптимизированных параметров." + } + } + } }, "🦕 End of life Version 🦖 ☄️" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦕 Ikke-supporteret version 🦖 ☄️" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦕 Versión End of life 🦖 ☄️" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1508,6 +2381,12 @@ "value" : "🦕 サポート終了バージョン 🦖 ☄️" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "🦕 Неподдерживаемая версия 🦖 ☄️" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1523,7 +2402,14 @@ } }, "0" : { + "extractionState" : "stale", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1535,6 +2421,12 @@ }, "1" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "1" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1546,6 +2438,18 @@ }, "1 byte" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 byte" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 byte" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1558,6 +2462,12 @@ "value" : "1バイト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 байт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1580,6 +2490,18 @@ }, "1 hop away" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 hop væk" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "a 1 salto de distancia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1592,6 +2514,12 @@ "value" : "1ホップ先" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 хоп доступен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1613,7 +2541,20 @@ } }, "2.4 Ghz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "2.4 GHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "2.4 Ghz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1626,6 +2567,12 @@ "value" : "2.4GHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "2.4 ГГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1642,6 +2589,18 @@ }, "7" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "7" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "7" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1654,6 +2613,12 @@ "value" : "7" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "7" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1676,12 +2641,24 @@ }, "12 Hour Clock" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reloj 12 Horas" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "12時間表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "12-часовой формат" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1692,6 +2669,18 @@ }, "25" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "25" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "25" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1704,6 +2693,12 @@ "value" : "25" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "25" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1726,6 +2721,18 @@ }, "50" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "50" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "50" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1738,6 +2745,12 @@ "value" : "50" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "50" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1760,6 +2773,18 @@ }, "75" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "75" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "75" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1772,6 +2797,12 @@ "value" : "75" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "75" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1794,6 +2825,18 @@ }, "100" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "100" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "100" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1806,6 +2849,12 @@ "value" : "100" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "100" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1828,6 +2877,18 @@ }, "128 bit" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "128 bit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "128 bit" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1840,6 +2901,12 @@ "value" : "128 bit" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "128 бит" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1862,6 +2929,12 @@ }, "180" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "180" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1873,6 +2946,18 @@ }, "256 bit" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "256 bit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "256 bit" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -1885,6 +2970,12 @@ "value" : "256 bit" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "256 бит" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1905,8 +2996,24 @@ } } }, + "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" : { + "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", + "value" : "Индекс канала, равный 0, указывает на основной канал, по которому отправляются широковещательные пакеты. Данные о местоположении передаются по первому каналу, где они включены с помощью встроенного программного обеспечения 2.7 и выше." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1915,14 +3022,27 @@ } } }, + "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", "value" : "緑色の鍵は、チャンネルが128ビットまたは256ビットのAESキーで安全に暗号化されていることを意味します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зеленый замок означает, что канал надежно зашифрован с помощью 128-битного или 256-битного ключа AES." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -1937,12 +3057,24 @@ }, "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", "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", @@ -1973,6 +3105,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", @@ -2001,6 +3139,18 @@ }, "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", + "value" : "Красный открытый замок означает, что канал небезопасно зашифрован и используется для передачи точных данных о местоположении, в нем либо вообще не используется ключ, либо известен ключ размером в 1 байт." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2011,6 +3161,18 @@ }, "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", + "value" : "Красный открытый замок с предупреждением означает, что канал небезопасно зашифрован и используется для передачи точных данных о местоположении, которые передаются в Интернет через MQTT, при этом ключ либо не используется вообще, либо имеет размер в 1 байт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2021,6 +3183,18 @@ }, "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un Trace Route fue enviado, no se ha recibido respuesta." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2033,6 +3207,12 @@ "value" : "Trace Route が送信されましたが、応答が受信されませんでした。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Был отправлен запрос трассировки маршрута, ответа получено не было." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2050,6 +3230,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", @@ -2060,16 +3246,42 @@ }, "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" : { + "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", + "value" : "Желтый открытый замок означает, что канал небезопасно зашифрован, но он не используется для передачи точных данных о местоположении, в нем либо вообще нет ключа, либо известен ключ размером в 1 байт." + } + } + } }, "About" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Om" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Über Meshtastic" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acerca de" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2082,6 +3294,12 @@ "value" : "概要" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О программе" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2098,12 +3316,24 @@ }, "About Meshtastic" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Om Meshtastic" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Über Meshtastic" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acerca de Meshtastic" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2116,6 +3346,12 @@ "value" : "Meshtasticについて" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О Meshtastic" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2132,12 +3368,24 @@ }, "Accuracy %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Præcision %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Genauigkeit %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Precisión %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2150,6 +3398,12 @@ "value" : "精度 %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Точность %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2166,6 +3420,18 @@ }, "Ack SNR: %@ dB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ack SNR: %@ dB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ack SNR: %@ dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2178,6 +3444,12 @@ "value" : "応答SNR: %@ dB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ack SNR: %@ dB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2194,6 +3466,18 @@ }, "Ack Time: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Bekræftelsestidspunkt: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo ACK: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2206,6 +3490,12 @@ "value" : "応答時間: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время Ack: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2221,13 +3511,26 @@ } }, "Acknowledged" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtagelse bekræftet" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Bestätigt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -2258,6 +3561,12 @@ "value" : "Potwierdzono" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтверждение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -2286,6 +3595,18 @@ }, "Acknowledged by another node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtagelse bekræftet af en anden node" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmado por otro nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2298,6 +3619,12 @@ "value" : "他のノードで確認済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтверждено другой нодой" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2314,12 +3641,24 @@ }, "Actions" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Handlinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Aktionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acciones" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2332,6 +3671,12 @@ "value" : "アクション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2348,12 +3693,24 @@ }, "Active" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiv" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Aktiv" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2366,6 +3723,12 @@ "value" : "アクティブ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2382,12 +3745,24 @@ }, "Activity" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivitet" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Aktivität" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actividad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2400,6 +3775,12 @@ "value" : "アクティビティ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активность" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2422,12 +3803,24 @@ }, "ADC Override" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "ADC-tilsidesættelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "ADC Override" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ADC Override" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -2452,6 +3845,12 @@ "value" : "ADC Override" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переопределение АЦП" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -2472,8 +3871,21 @@ } } }, + "Add CA" : {}, "Add Channel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj kanal" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2486,6 +3898,12 @@ "value" : "チャンネルを追加" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить канал" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2502,6 +3920,18 @@ }, "Add Channels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj kanaler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Canales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2514,6 +3944,12 @@ "value" : "チャンネルを追加" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить каналы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2530,12 +3966,24 @@ }, "Add Contact" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Contacto" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "連絡先を追加" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить контакт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2552,6 +4000,12 @@ }, "Add Meshtastic Node %@ as a contact" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar nodo Meshtastic %@ como contacto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2564,6 +4018,12 @@ "value" : "Meshtasticノード%@を連絡先に追加" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить ноду Meshtastic %@ как контакт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2580,12 +4040,24 @@ }, "Add to favorites" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj til foretrukne" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zu Favoriten hinzufügen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar a favoritos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2598,6 +4070,12 @@ "value" : "お気に入りに追加" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить в избранные" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2614,6 +4092,18 @@ }, "Additional help" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yderligere hjælp" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayuda adicional" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2626,6 +4116,12 @@ "value" : "追加のヘルプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дополнительная помощь" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2648,6 +4144,18 @@ }, "Address" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adresse" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dirección" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2660,6 +4168,12 @@ "value" : "住所" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адрес" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2682,12 +4196,24 @@ }, "Admin Keys" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Claves de Admin" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "管理者キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ключи админа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2698,6 +4224,18 @@ }, "Administration" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administration" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2710,6 +4248,12 @@ "value" : "管理" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Администрирование" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2732,12 +4276,30 @@ }, "Administration Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administration aktiveret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administración habilitada" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "管理機能が有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Администрирование включено" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2754,6 +4316,18 @@ }, "Advanced" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avanceret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avanzado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2766,6 +4340,12 @@ "value" : "上級" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дополнительно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2788,6 +4368,18 @@ }, "Advanced Device GPS" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avanceret indbygget GPS" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo GPS Avanzado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2800,6 +4392,12 @@ "value" : "高度なデバイスGPS" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширенное устройство GPS" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2823,6 +4421,18 @@ "Advanced GPIO Options" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avancerede GPIO-indstillinger" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones GPIO avanzadas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2857,6 +4467,18 @@ }, "Advanced Position Flags" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avancerede positionsflag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flags de posición avanzadas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2869,6 +4491,12 @@ "value" : "高度な位置フラグ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширенные флаги позиционирования" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -2890,13 +4518,26 @@ } }, "After" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efter" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Nach" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Después" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -2909,6 +4550,12 @@ "value" : "後" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "После" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -2967,6 +4614,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" : { @@ -2979,6 +4644,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" : { @@ -3007,12 +4702,24 @@ }, "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", "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", @@ -3043,6 +4750,12 @@ "value" : "Po zapisaniu wartości konfiguracji węzeł zostanie zrestartowany." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "После сохранения значений нода перезагрузится." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3070,13 +4783,26 @@ } }, "Afternoon" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eftermiddag" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Nachmittag" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarde" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3089,6 +4815,12 @@ "value" : "午後" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "После полудня" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3105,12 +4837,24 @@ }, "Airtime" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taletid" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Airtime" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Airtime" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -3141,6 +4885,12 @@ "value" : "Czas nadawania" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эфирное время" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3169,6 +4919,18 @@ }, "Alert" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alarm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviso" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3181,6 +4943,12 @@ "value" : "アラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3197,6 +4965,18 @@ }, "Alert GPIO buzzer when receiving a bell" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "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", @@ -3209,6 +4989,12 @@ "value" : "ベル受信時にGPIOブザーでアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждающий зуммер GPIO при получении звукового сигнала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3231,6 +5017,18 @@ }, "Alert GPIO buzzer when receiving a message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -3243,6 +5041,12 @@ "value" : "メッセージ受信時にGPIOブザーでアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждающий зуммер GPIO при получении сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3259,6 +5063,18 @@ }, "Alert GPIO vibra motor when receiving a bell" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "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", @@ -3271,6 +5087,12 @@ "value" : "ベル受信時にGPIO振動モーターでアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждающий вибромотор GPIO о получении звукового сигнала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3293,6 +5115,18 @@ }, "Alert GPIO vibra motor when receiving a message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -3305,6 +5139,12 @@ "value" : "メッセージ受信時にGPIO振動モーターでアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждающий вибромотор GPIO о получении сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3321,6 +5161,18 @@ }, "Alert when receiving a bell" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Giv besked ved modtagelse af en ASCII-klokke" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerta cuando se recibe una campana" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3333,6 +5185,12 @@ "value" : "ベル受信時にアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждение при получении звукового сигнала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3355,6 +5213,18 @@ }, "Alert when receiving a message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giv besked ved modtagelse af en besked" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerta cuando se recibe un mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3367,6 +5237,12 @@ "value" : "メッセージ受信時にアラート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждение при получении сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3383,12 +5259,24 @@ }, "All" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Alle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3401,6 +5289,12 @@ "value" : "全て" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Все" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3423,6 +5317,18 @@ }, "Allow Position Requests" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tillad positions-anmodninger" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permitir Peticiones de Posición" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3435,6 +5341,12 @@ "value" : "位置要求を許可" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешить запрос позиции" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3451,6 +5363,18 @@ }, "Alt" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højde" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alt" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3463,6 +5387,12 @@ "value" : "高度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выс" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3479,12 +5409,24 @@ }, "Altitude" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højde" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Höhe" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altitud" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3497,6 +5439,12 @@ "value" : "高度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высота" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3513,12 +5461,24 @@ }, "Altitude %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højde %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Höhe %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altitud %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3531,6 +5491,12 @@ "value" : "高度 %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высота %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3547,6 +5513,18 @@ }, "Altitude Geoidal Separation" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geoidhøjde Adskillelse" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Separación Geoidal de Altitud" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3559,6 +5537,12 @@ "value" : "高度ジオイド分離" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Значение коррекции высоты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3575,6 +5559,18 @@ }, "Altitude is Mean Sea Level" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højde er middelhavsniveau" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altitud es nivel medio del mar (MSL)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3587,6 +5583,12 @@ "value" : "高度は平均海面レベル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высота над уровнем моря" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3602,13 +5604,26 @@ } }, "Always On" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altid tændt" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Immer an" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siempre encendido" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -3639,6 +5654,12 @@ "value" : "Zawsze włączone" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всегда включено" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3667,12 +5688,24 @@ }, "Always point north" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peg altid mod nord" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Immer nach Norden zeigen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siempre apuntar al norte" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3685,6 +5718,12 @@ "value" : "常に北を指す" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всегда указывать на север" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3701,12 +5740,24 @@ }, "Ambient Lighting" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Omgivelsesbelysning" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Ambientebeleuchtung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luz ambiente" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -3731,6 +5782,12 @@ "value" : "環境照明" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Окружающее освещение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3759,12 +5816,24 @@ }, "Ambient Lighting Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration af omgivelsesbelysning" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Ambientebeleuchtungskonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración luz ambiente" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -3789,6 +5858,12 @@ "value" : "環境照明設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка окружающего освещения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3816,7 +5891,20 @@ } }, "Ambient Lighting module config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration af ambient belysningsmodul modtaget: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo de luz ambiante recibida : %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -3847,6 +5935,12 @@ "value" : "Ambient Lighting module config received: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля внешнего освещения: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -3875,12 +5969,24 @@ }, "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", "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", @@ -3893,6 +5999,12 @@ "value" : "手頃な価格の低電力無線機で動作する、オープンソース、オフグリッド、分散型メッシュネットワーク。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это автономная децентрализованная mesh-сеть с открытым исходным кодом, работающая на недорогих и маломощных радиостанциях." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3915,6 +6027,18 @@ }, "Any missed messages will be delivered again." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle missede beskeder vil blive leveret igen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cualquier mensaje perdido será entregado nuevamente." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -3927,6 +6051,12 @@ "value" : "見逃したメッセージは再配信されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Все пропущенные сообщения будут доставлены повторно." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -3948,13 +6078,26 @@ } }, "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", "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", @@ -3985,6 +6128,12 @@ "value" : "Klient (domyślnie) - Klient połączony z aplikacją." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключенное к приложению или автономное устройство для обмена сообщениями." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4013,12 +6162,24 @@ }, "App Data" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-data" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "App-Daten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos de la aplicación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4031,6 +6192,12 @@ "value" : "App データ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4053,6 +6220,18 @@ }, "App Files" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-filer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archivos de la aplicación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4065,6 +6244,12 @@ "value" : "アプリファイル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить файлы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4087,6 +6272,18 @@ }, "App Icon" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icono de la App" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Иконка приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4103,6 +6300,18 @@ "value" : "Mitteilungseinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificaciones de la App" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4113,12 +6322,24 @@ }, "App Settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "App-Einstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes de la App" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4131,6 +6352,12 @@ "value" : "アプリ設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4153,6 +6380,12 @@ }, "Apple Apps" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple-apps" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4165,6 +6398,12 @@ "value" : "Appleアプリ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приложения Apple" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4187,12 +6426,24 @@ }, "Approximate Location" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Omtrentlig placering" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Ungefährer Standort" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posición Aproximada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4205,6 +6456,12 @@ "value" : "正確な位置" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приблизительное местоположение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4221,6 +6478,18 @@ }, "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?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Estás seguro de que quieres eliminar este mensaje?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4233,6 +6502,12 @@ "value" : "このメッセージを削除してもよろしいですか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите удалить это сообщение?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4255,12 +6530,24 @@ }, "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", "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", @@ -4273,6 +6560,12 @@ "value" : "ノードを工場出荷時設定にリセットしてもよろしいですか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены что хотите сбросить настройки ноды?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4295,12 +6588,24 @@ }, "Are you sure?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker?" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Bist Du sicher?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Estás seguro?" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -4331,6 +6636,12 @@ "value" : "Jesteś pewny?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены?" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4358,7 +6669,20 @@ } }, "Australia / New Zealand" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Australien og New Zealand" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Australia / Nueva Zelanda" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4371,6 +6695,12 @@ "value" : "オーストラリア / ニュージーランド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Австралия / Новая Зеландия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4385,8 +6715,24 @@ } } }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conexión Automática" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматическое подключение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4397,6 +6743,18 @@ }, "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." + } + }, + "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", @@ -4409,6 +6767,12 @@ "value" : "指定した間隔に基づいて、カルーセルのように画面の次のページに自動的に切り替わります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматический переход к следующей странице на экране, как в карусели, с заданным интервалом." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4431,12 +6795,24 @@ }, "Available modem presets, default is Long Fast." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilgængelige modemforudindstillinger, standard er Lang Hurtig." + } + }, "de" : { "stringUnit" : { "state" : "translated", "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", @@ -4449,6 +6825,12 @@ "value" : "利用可能なモデムプリセット、デフォルトは Long Fast です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступны предустановки модема, по умолчанию используется Long Fast." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4471,12 +6853,24 @@ }, "Available Radios" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilgængelige radioer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Geräte in der Nähe" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radios Disponibles" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -4507,6 +6901,12 @@ "value" : "Dostępne radia" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступные радиостанции" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4534,13 +6934,26 @@ } }, "Back" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilbage" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zurück" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atrás" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -4571,6 +6984,12 @@ "value" : "Wstecz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назад" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4605,6 +7024,12 @@ "value" : "バックアップ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Резервная копия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4615,12 +7040,24 @@ }, "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", "value" : "プライベートキーをiCloudキーチェーンにバックアップします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать резервную копию приватного ключа в связку ключей iCloud." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4630,7 +7067,20 @@ } }, "Bad" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dårlig" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Incorrecto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4643,6 +7093,12 @@ "value" : "悪い" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Плохой" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4658,7 +7114,20 @@ } }, "Bad Request" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejl i forespørgsel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Petición incorrecta" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -4689,6 +7158,12 @@ "value" : "Złe żądanie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Плохой запрос" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4721,12 +7196,24 @@ }, "Bandwidth" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Båndbredde" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Bandbreite" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ancho de banda" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4739,6 +7226,12 @@ "value" : "帯域幅" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пропускная способность" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4760,7 +7253,20 @@ } }, "Bar" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Søjle" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4773,6 +7279,12 @@ "value" : "バー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Давл" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4795,6 +7307,18 @@ }, "Bar Series" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Søjlediagramserie" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serie Bar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4807,6 +7331,12 @@ "value" : "バー系列" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Диапазон давления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4828,7 +7358,20 @@ } }, "Barometric Pressure" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Barometertryk" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presión Barométrica" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -4841,6 +7384,12 @@ "value" : "気圧" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Атмосферное давление" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -4863,12 +7412,24 @@ }, "Battery" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batteri" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Batterie" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batería" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -4893,6 +7454,12 @@ "value" : "Battery" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Батарея" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4922,12 +7489,24 @@ "Battery Level" : { "comment" : "VoiceOver label for battery gauge", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batteriniveau" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Batterie Ladung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nivel de Batería" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -4958,6 +7537,12 @@ "value" : "Poziom naładowania baterii" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уровень заряда" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -4987,12 +7572,24 @@ "Battery Level %" : { "comment" : "VoiceOver value for battery level", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterinveau %" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Batterie Ladung %" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nivel de Batería %" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5023,6 +7620,12 @@ "value" : "Poziom naładowania baterii %" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уровень заряда %" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5058,6 +7661,12 @@ "value" : "Batterie Ladung %d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nivel de Batería %d" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5088,6 +7697,12 @@ "value" : "Poziom naładowania baterii %d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уровень заряда %d" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5116,6 +7731,18 @@ }, "Baud" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baud" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baudios" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5128,6 +7755,12 @@ "value" : "ボーレート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Бод" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5149,19 +7782,46 @@ } }, "Bearing: %@" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Несущая: %@" + } + } + } }, "Bearing: N/A" : { - + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Несущая: Н/А" + } + } + } }, "Biking" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "På cykel" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Biken" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "En bicicleta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5174,6 +7834,12 @@ "value" : "サイクリング" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Езда на велосипеде" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5196,6 +7862,18 @@ }, "BLE" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLE" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5208,6 +7886,12 @@ "value" : "BLE" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "BLE" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5230,6 +7914,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", @@ -5266,6 +7956,12 @@ "value" : "Pin BLE musi mieć długość 6 cyfr." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длина пин-кода BLE должна быть 6 цифр. " + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5294,12 +7990,24 @@ }, "Bluetooth" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5330,6 +8038,12 @@ "value" : "Bluetooth" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5358,12 +8072,24 @@ }, "Bluetooth Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth-indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración Bluetooth" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5394,6 +8120,12 @@ "value" : "Konfiguracja Bluetooth" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка Bluetooth" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5421,13 +8153,26 @@ } }, "Bluetooth config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth-konfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Bluetooth Konfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración Bluetooth recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5458,6 +8203,12 @@ "value" : "Otrzymano konfigurację Bluetooth: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Принята конфигурация Bluetooth: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5488,6 +8239,18 @@ "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", + "value" : "Bluetooth-подключение " + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5498,6 +8261,12 @@ }, "Bold Heading" : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Жирный заголовок" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5508,6 +8277,12 @@ }, "Bold the heading text on the screen." : { "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выделять жирным шрифтом текст заголовка на экране." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5516,11 +8291,41 @@ } } }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métricas de Transmisión del Dispositivo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показатели широковещательного устройства" + } + } + } }, "Broadcast Interval" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Broadcast-interval" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de transmisión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5533,6 +8338,12 @@ "value" : "ブロードキャスト間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал вещания" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5554,13 +8365,26 @@ } }, "Broadcasts GPS position packets as priority." : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sender GPS-positionspakker som prioritet." + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sendet GPS-Positionspakete mit Priorität." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmitir paquetes de posición GPS con prioridad." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5591,6 +8415,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", @@ -5618,13 +8448,26 @@ } }, "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", "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", @@ -5655,6 +8498,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", @@ -5682,13 +8531,26 @@ } }, "Broadcasts telemetry packets as priority." : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sender telemetripakker som prioritet" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sendet Telemetriepakete mit Priorität." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmitir los paquetes de telemetría con prioridad." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5719,6 +8581,12 @@ "value" : "Broadcasts telemetry packets as priority." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Передает пакеты телеметрии в приоритетном порядке." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5747,6 +8615,18 @@ }, "Button GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "GPIO-knap" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Botón GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5759,6 +8639,12 @@ "value" : "ボタンGPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO кнопки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5781,6 +8667,18 @@ }, "Buy Complete Radios" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Køb komplette radioer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comprar Radios Completas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5793,6 +8691,12 @@ "value" : "完成品無線機を購入" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Покупайте комплектные радиоприемники" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5815,6 +8719,18 @@ }, "Buzzer GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO-vibrator" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zumbador GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5827,6 +8743,12 @@ "value" : "ブザーGPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO зуммер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5849,12 +8771,30 @@ }, "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." + } + }, + "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", "value" : "この機能を有効にすることで、お客様のデバイスのリアルタイム地理位置が暗号化されずにMQTTプロトコル経由で送信されることを承知し、明示的に同意することを認めます。この位置データは、ライブマップ報告、デバイス追跡、関連テレメトリー機能などの目的で使用される場合があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включив эту функцию, вы подтверждаете и даете явное согласие на передачу данных о географическом местоположении вашего устройства в режиме реального времени по протоколу MQTT без шифрования. Эти данные о местоположении могут использоваться для таких целей, как создание отчетов по карте в режиме реального времени, отслеживание устройства и связанные с этим функции телеметрии." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5870,13 +8810,26 @@ } }, "Bytes" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bytes" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Bytes" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bytes" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -5907,6 +8860,12 @@ "value" : "Bajty" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Байты" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -5936,12 +8895,24 @@ "Bytes Used" : { "comment" : "VoiceOver value for bytes used", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bytes Usados" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "使用バイト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используемые байты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5952,12 +8923,24 @@ }, "Call Sign" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kaldesignal" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Rufzeichen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Señal de llamada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -5970,6 +8953,12 @@ "value" : "コールサイン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позывной" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -5992,12 +8981,24 @@ }, "Call Sign must not be empty" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kaldesignal må ikke være tomt" + } + }, "de" : { "stringUnit" : { "state" : "translated", "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", @@ -6010,6 +9011,12 @@ "value" : "コールサインは空にできません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позывной не должен быть пустым" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6032,12 +9039,24 @@ }, "Cancel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuller" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Abbrechen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6068,6 +9087,12 @@ "value" : "Anuluj" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6101,7 +9126,20 @@ }, "Canned Message module config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurationsmodul for standardbesked modtaget: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuraciíon del módulo Canned Message recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6132,6 +9170,12 @@ "value" : "Otrzymano konfigurację modułu wiadomości gotowych: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получено сохраненное сообщение о конфигурации модуля: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6160,12 +9204,24 @@ }, "Canned Messages" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foruddefinerede beskeder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Canned Messages" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canned Messages" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6196,6 +9252,12 @@ "value" : "Gotowe wiadomości" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохраненные сообщения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6224,12 +9286,24 @@ }, "Canned Messages Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurer Forhåndsdefinerede Meddelelser" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Canned Messages Config" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de Canned Messages" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6260,6 +9334,12 @@ "value" : "Konfiguracja gotowych wiadomości" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация сохраненных сообщений" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6287,7 +9367,20 @@ } }, "Canned Messages Messages Received For: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtagne beskeder for: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recibidos Canned Messages para : %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6318,6 +9411,12 @@ "value" : "Otrzymano Wiadomości Gotowe Dla: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохраненные сообщения полученные для: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6346,6 +9445,18 @@ }, "Carousel Interval" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Karusselinterval" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo del carrusel" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6358,6 +9469,12 @@ "value" : "カルーセル間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал карусели" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6374,12 +9491,24 @@ }, "Categories" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kategorier" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kategorien" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Categorías" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6392,6 +9521,12 @@ "value" : "カテゴリ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Категории" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6414,12 +9549,24 @@ }, "Category" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kategori" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kategorie" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Categoría" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6432,6 +9579,12 @@ "value" : "カテゴリ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Категория" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6448,6 +9601,18 @@ }, "Ch1 Current" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch1 strøm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corriente Ch1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6460,6 +9625,12 @@ "value" : "Ch1 現在" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ток к1" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6476,6 +9647,18 @@ }, "Ch1 Voltage" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch1 spænding" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltaje Ch1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6488,6 +9671,12 @@ "value" : "Ch1 電圧" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Напряжение к1" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6504,6 +9693,18 @@ }, "Ch2 Current" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch2 strøm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corriente Ch2" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6516,6 +9717,12 @@ "value" : "Ch2 現在" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ток к2" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6532,6 +9739,18 @@ }, "Ch2 Voltage" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch2 spænding" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltaje Ch2" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6544,6 +9763,12 @@ "value" : "Ch2 電圧" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Напряжение к2" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6560,6 +9785,18 @@ }, "Ch3 Current" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch3 strøm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corriente Ch3" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6572,6 +9809,12 @@ "value" : "Ch3 現在" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ток к3" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6588,6 +9831,18 @@ }, "Ch3 Voltage" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ch3 spænding" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltaje Ch3" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6600,6 +9855,12 @@ "value" : "Ch3 電圧" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Напряжение к3" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6616,12 +9877,24 @@ }, "Channel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kanal" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -6652,6 +9925,12 @@ "value" : "Kanał" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -6680,6 +9959,18 @@ }, "Channel 0 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 0 inkluderet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 0 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6692,6 +9983,12 @@ "value" : "チャンネル0を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 0 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6714,6 +10011,18 @@ }, "Channel 1" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 1" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6726,6 +10035,12 @@ "value" : "チャンネル1" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 1" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6742,6 +10057,18 @@ }, "Channel 1 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 1 inkluderet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 1 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6754,6 +10081,12 @@ "value" : "チャンネル1を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 1 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6776,6 +10109,18 @@ }, "Channel 2" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 2" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 2" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6788,6 +10133,12 @@ "value" : "チャンネル2" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 2" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6804,6 +10155,18 @@ }, "Channel 2 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 2 inkluderet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 2 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6816,6 +10179,12 @@ "value" : "チャンネル2を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 2 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6838,6 +10207,18 @@ }, "Channel 3" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 3" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 3" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6850,6 +10231,12 @@ "value" : "チャンネル 3" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 3" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6866,6 +10253,18 @@ }, "Channel 3 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 3 inkluderet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 3 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6878,6 +10277,12 @@ "value" : "チャンネル 3を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 3 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6900,6 +10305,18 @@ }, "Channel 4 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 4 inkluderet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 4 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6912,6 +10329,12 @@ "value" : "チャンネル 4を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 4 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6934,6 +10357,18 @@ }, "Channel 5 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 5 inkluderet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 5 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6946,6 +10381,12 @@ "value" : "チャンネル 5を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 5 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -6968,6 +10409,18 @@ }, "Channel 6 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 6 inkluderet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 6 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -6980,6 +10433,12 @@ "value" : "チャンネル 6を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 6 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7002,6 +10461,18 @@ }, "Channel 7 Included" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal 7 inkluderet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canal 7 Incluído" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7014,6 +10485,12 @@ "value" : "チャンネル 7を含む" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Канал 7 включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7036,6 +10513,12 @@ }, "Channel Details" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalles del Canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7048,6 +10531,12 @@ "value" : "チャンネル詳細" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сведения о канале" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7068,8 +10557,24 @@ } } }, + "Channel Fixed!" : { + "comment" : "A message displayed when the primary channel is successfully fixed.", + "isCommentAutoGenerated" : true + }, "Channel Name" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanalnavn" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre del Canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7082,6 +10587,12 @@ "value" : "チャンネル名" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя канала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7104,6 +10615,18 @@ }, "Channel number must be between 0 and 7." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -7116,6 +10639,12 @@ "value" : "チャンネル番号は0から7の間である必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Номер канала должен быть от 0 до 7." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7138,6 +10667,18 @@ }, "Channel Role" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanalrolle" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rol del Canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7150,6 +10691,12 @@ "value" : "チャンネル役割" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Роль канала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7172,6 +10719,18 @@ }, "Channel URL" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanal-URL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL del canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7184,6 +10743,12 @@ "value" : "チャンネル URL" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL канала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7200,12 +10765,24 @@ }, "Channel Utilization" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanaludnyttelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kanalbelegung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilización del Canal" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7236,6 +10813,12 @@ "value" : "Wykorzystanie kanału" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использование канала" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -7264,6 +10847,18 @@ }, "Channel Utilization %@%%" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanaludnyttelsesgrad %@%%" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilización del canal %@%%" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7276,6 +10871,12 @@ "value" : "チャンネル使用率 %@%%" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использование канала %@%%" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7292,12 +10893,24 @@ }, "Channels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kanaler" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kanäle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canales" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7328,6 +10941,12 @@ "value" : "Kanały" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каналы" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -7355,7 +10974,20 @@ } }, "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." + } + }, + "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", @@ -7368,6 +11000,12 @@ "value" : "QRコードから追加されたチャンネルが保存されませんでした。チャンネルを追加する際は、名前が一意である必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каналы, добавленные с помощью QR-кода, не сохраняются. При добавлении каналов названия должны быть уникальными." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7390,12 +11028,24 @@ }, "Channels Help" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayuda de los Canales" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "チャンネルヘルプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каналы помощи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7405,7 +11055,20 @@ } }, "Chart" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Graf" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gráfico" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7418,6 +11081,12 @@ "value" : "チャート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Диаграмма" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7440,6 +11109,18 @@ }, "CHG" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "ÆND" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "CHG" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7452,6 +11133,12 @@ "value" : "充電中" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "CHG" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7467,7 +11154,20 @@ } }, "China" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kina" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "China" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7480,6 +11180,12 @@ "value" : "中国" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Китай" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7495,7 +11201,14 @@ } }, "Chirpy" : { + "extractionState" : "stale", "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Щебетун" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7506,6 +11219,18 @@ }, "Clear" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tøm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limpiar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7518,6 +11243,12 @@ "value" : "クリア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7540,12 +11271,24 @@ }, "Clear App Data" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tøm app-data" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "App-Daten löschen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar Datos de Aplicación" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7576,6 +11319,12 @@ "value" : "Wyczyść dane aplikacji" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить данные приложения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -7604,6 +11353,18 @@ }, "Clear Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Tøm log" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar Log" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7616,6 +11377,12 @@ "value" : "ログクリア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить журнал" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7638,12 +11405,24 @@ "value" : "Veraltete Knoten löschen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar nodos obsoletos" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "古いノードをクリア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очистить устаревшие ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7653,7 +11432,20 @@ } }, "Client" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klient" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7666,6 +11458,12 @@ "value" : "クライアント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Клиент" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7688,16 +11486,45 @@ }, "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 - }, - "Client Hidden" : { + "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", + "value" : "Используя Client Base, вы должны добавлять в избранное только подконтрольные вам ноды. Неправильное использование может ухудшить сетевую связность" + } + } + } + }, + "Client CA Certificate" : {}, + "Client Configuration" : {}, + "Client Hidden" : { + "extractionState" : "stale", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skjult klient" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Client - Versteckt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliente Oculto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7710,6 +11537,12 @@ "value" : "クライアント Hidden" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрытый клиент" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7732,6 +11565,18 @@ }, "Client History" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klienthistorik" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historial del cliente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7744,6 +11589,12 @@ "value" : "クライアント履歴" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История клиента" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7766,6 +11617,18 @@ }, "Client History Request Sent" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klienthistorik-anmodning sendt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Petición de cronología del cliente enviada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7778,6 +11641,12 @@ "value" : "クライアント履歴リクエストを送信しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлен запрос истории клиента" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7799,7 +11668,20 @@ } }, "Client Mute" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tavs klient (client mute)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cliente Mudo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7812,6 +11694,12 @@ "value" : "クライアント無音" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заглушить клиента" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7834,6 +11722,18 @@ }, "Client options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klientindstillinger" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones del cliente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7846,6 +11746,12 @@ "value" : "クライアントオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры клиента" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7868,6 +11774,18 @@ }, "Clockwise Rotary Event" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Med uret roterende hændelse" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evento de giro en sentido horario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7880,6 +11798,12 @@ "value" : "時計回りロータリーイベント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вращение по часовой стрелке" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -7902,12 +11826,24 @@ }, "Close" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luk" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Schließen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -7938,6 +11874,12 @@ "value" : "Zamknij" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -7966,6 +11908,18 @@ }, "Coding Rate" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kodningshastighed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tasa de codificación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -7978,6 +11932,12 @@ "value" : "符号化率" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость кодирования" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8000,12 +11960,24 @@ }, "Color" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Farve" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Farbe" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Color" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8018,6 +11990,12 @@ "value" : "色" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цвет" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8046,6 +12024,18 @@ "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", + "value" : "Общайтесь вне сети со своими друзьями и сообществом без использования сотовой связи." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8056,6 +12046,18 @@ }, "Communicating" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommunikerer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "En comunicación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8068,6 +12070,12 @@ "value" : "通信中" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Общение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8084,6 +12092,18 @@ }, "Community Support" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Support fra fællesskabet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Soporte de la comunidad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8096,6 +12116,12 @@ "value" : "コミュニティサポート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поддержка сообщества" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8111,10 +12137,35 @@ } }, "Compass" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brújula" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Компас" + } + } + } }, "Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8127,6 +12178,12 @@ "value" : "設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8147,14 +12204,27 @@ } } }, + "Configuration" : {}, "Configuration for: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration for %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Konfiguration für: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración para: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8167,6 +12237,12 @@ "value" : "%@ の設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация для: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8183,6 +12259,18 @@ }, "Configuration Presets" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standardkonfigurationer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presets de Configuración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8195,6 +12283,12 @@ "value" : "設定プリセット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Преднастройки конфигурации" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8217,12 +12311,24 @@ }, "Configure" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurér" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Konfigurieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8235,6 +12341,12 @@ "value" : "設定する" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8253,6 +12365,18 @@ "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", + "value" : "Настройка подключения по Bluetooth" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8265,6 +12389,18 @@ "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", + "value" : "Настройка доступа по локальной сети" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8281,6 +12417,18 @@ "value" : "Standortberechtigungen konfigurieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurar permisos de ubicación" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка разрешений на определение местоположения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8297,6 +12445,18 @@ "value" : "Mitteilungsberechtigungen konfigurieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurar permisos de notificaciones" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка разрешений на уведомления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8307,6 +12467,18 @@ }, "Confirm" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bekræft" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8319,6 +12491,12 @@ "value" : "確認" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтверждение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8335,6 +12513,18 @@ }, "Connect" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8345,12 +12535,24 @@ }, "Connect to a Node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslut en Node" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Verbunden mit einem Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar a un nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8363,6 +12565,12 @@ "value" : "ノードに接続" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключение к ноде" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8379,12 +12587,30 @@ }, "Connect to MQTT via Proxy" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslut MQTT over proxy" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar a MQTT via Proxy" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "プロキシ経由でMQTTに接続" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключение к MQTT через прокси" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8401,6 +12627,18 @@ }, "Connect to new radio?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslut ny radio" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Conectar a la nueva radio?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8413,6 +12651,12 @@ "value" : "新しい無線機に接続しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключиться к новому радиоприемнику?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8429,12 +12673,24 @@ }, "Connected" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilsluttet" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Derzeit verbunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -8465,6 +12721,12 @@ "value" : "Podłączony" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключено" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -8493,12 +12755,24 @@ }, "Connected Node %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilsluttet Node %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Verbunden mit Knoten %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodo conectado %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8511,6 +12785,12 @@ "value" : "接続済みノード %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключенная нода %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8527,6 +12807,12 @@ }, "Connected Radio" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilsluttet radio" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8539,6 +12825,12 @@ "value" : "接続済み無線機" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключенный радиоприемник" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8555,12 +12847,24 @@ }, "Connecting . ." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslutter . ." + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Verbinde..." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectando . ." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -8591,6 +12895,12 @@ "value" : "Łączenie . ." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключение . ." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -8619,6 +12929,18 @@ }, "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" + } + }, + "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", @@ -8631,6 +12953,12 @@ "value" : "新しい無線機に接続すると、電話上の全てのアプリデータがクリアされます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При подключении к новому радиоприемнику все данные приложения на телефоне будут удалены." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8647,12 +12975,24 @@ }, "Connection Attempt %lld of 10" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilslutningsforsøg %lld af 10" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Verbindungsversuch %lld von 10" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intento de conexión %lld de 10" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8665,6 +13005,12 @@ "value" : "接続試行 %lld / 10" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Количество попыток подключения, %lld из 10" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8687,6 +13033,18 @@ }, "Connection Name" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre de la conexión" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя подключения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8697,12 +13055,30 @@ }, "Consent to Share Unencrypted Node Data via MQTT" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", "value" : "MQTT経由での暗号化されていないノードデータの共有に同意" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Согласие на передачу незашифрованных данных ноды через MQTT" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8718,6 +13094,7 @@ } }, "Contact Filters" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -8725,6 +13102,18 @@ "value" : "Kontaktfilter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtros de contacto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фильтры контактов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8735,12 +13124,24 @@ }, "Contact URL" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL del contacto" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "連絡先URL" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL контакта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8756,13 +13157,26 @@ } }, "Contacts (%@)" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontakter (%@)" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kontakte (%@)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contactos (%@)" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -8793,6 +13207,12 @@ "value" : "Kontakty (%@)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакты (%@)" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -8821,6 +13241,18 @@ }, "Control Type" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontroltype" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo de control" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8833,6 +13265,12 @@ "value" : "Control タイプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип управления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8855,6 +13293,18 @@ }, "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." + } + }, + "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", @@ -8867,6 +13317,12 @@ "value" : "デバイス上の点滅LEDを制御します。ほとんどのデバイスでは最大4つのLEDのうち1つを制御し、充電器とGPS LEDは制御できません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управляет мигающим светодиодом на устройстве. Для большинства устройств это будет управлять одним из максимум 4 светодиодов, светодиоды зарядки и GPS не управляются." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8889,12 +13345,24 @@ }, "Convex Hull" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Convex hull" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Konvexe Hülle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoltura convexa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8907,6 +13375,12 @@ "value" : "凸包" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выпуклая оболочка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8923,12 +13397,24 @@ }, "Coordinate" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koordinat" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Koordinate" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coordenar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8941,6 +13427,12 @@ "value" : "座標" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Координаты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8957,12 +13449,24 @@ }, "Coordinate %@, %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koordinat %@, %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Koordinate %1$@, %2$@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coordenadas %1$@, %2$@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -8975,6 +13479,12 @@ "value" : "座標 %@, %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Координаты %@, %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -8997,12 +13507,24 @@ }, "Coordinates:" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koordinater:" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Koordinaten:" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coordenadas:" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9015,6 +13537,12 @@ "value" : "座標:" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Координаты:" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9031,12 +13559,24 @@ }, "Copy" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kopier" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kopieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -9067,6 +13607,12 @@ "value" : "Kopiuj" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Копировать" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -9095,12 +13641,24 @@ }, "Could not find node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke finde node" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten nicht gefunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodo no encontrado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9113,6 +13671,12 @@ "value" : "ノードが見つかりません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось найти ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9135,6 +13699,18 @@ }, "Counter Clockwise Rotary Event" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mod-uret Rundt Roterende Begivenhed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evento rotativo antihorario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9147,6 +13723,12 @@ "value" : "反時計回りの回転イベント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вращение энкодера против часовой стрелки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9163,12 +13745,24 @@ }, "Create Waypoint" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opret viapunkt" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Wegpunkt erstellen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crear punto de ruta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9181,6 +13775,12 @@ "value" : "ウェイポイントを作成" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать путевую точку" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9203,6 +13803,18 @@ "value" : "Erstelle deine eigenen Netzwerke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crea tus propias redes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создавайте свои собственные сети" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9213,12 +13825,24 @@ }, "Created: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oprettet: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Erstellt: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creado: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9231,6 +13855,12 @@ "value" : "作成日時: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создано: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9253,6 +13883,18 @@ "value" : "Kritische Hinweise" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alertas críticas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Критические оповещения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9263,6 +13905,18 @@ }, "Current" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Strøm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actual" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9275,6 +13929,12 @@ "value" : "現在" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ток" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9291,12 +13951,24 @@ }, "Current Firmware Version: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuværende firmwareversion: %@." + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Aktuelle Firmware Version: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión de firmware actual: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9309,6 +13981,12 @@ "value" : "現在のファームウェアバージョン: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установленная прошивка: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9331,12 +14009,24 @@ }, "Current Firmware Version: %@, Latest Firmware Version: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuværende firmwareversion: %@. Seneste firmwareversion: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "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", @@ -9349,6 +14039,12 @@ "value" : "現在のファームウェアバージョン: %@、最新のファームウェアバージョン: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установленная прошивка: %@, актуальная версия: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9371,12 +14067,24 @@ }, "Current: %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Strøm: %lld" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Aktuell: %lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actual: %lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9389,6 +14097,12 @@ "value" : "現在: %lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущий: %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9405,6 +14119,18 @@ }, "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." + } + }, + "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", @@ -9417,6 +14143,12 @@ "value" : "現在、ESP32デバイスの更新の推奨方法は、デスクトップコンピューターのChromeベースのブラウザーでWebフラッシャーを使用することです。モバイルデバイスやBLE経由では動作しません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В настоящее время рекомендуемый способ обновления устройств ESP32 - использование веб-прошивальщика на настольном компьютере из браузера на основе Chrome. Не работает на мобильных устройствах или через BLE." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9439,12 +14171,24 @@ }, "Date" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dato" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Datum" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fecha" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9457,6 +14201,12 @@ "value" : "日付" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дата" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9473,6 +14223,18 @@ }, "Debug" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejlfinding" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depurar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9485,6 +14247,12 @@ "value" : "デバッグ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отладка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9507,12 +14275,24 @@ }, "Debug Logs" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejlfindingslogs" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Fehlersuchprotokolle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros de depuración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9525,6 +14305,12 @@ "value" : "デバッグログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал отладки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9541,6 +14327,18 @@ }, "Debug Logs%@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejlfindingslogs %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros de depuración%@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9553,6 +14351,12 @@ "value" : "デバッグログ%@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал отладки %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9569,12 +14373,24 @@ }, "Default" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Standard" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predeterminado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -9605,6 +14421,12 @@ "value" : "Domyślny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По умолчанию" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -9632,7 +14454,20 @@ } }, "Default 128x64 screen layout" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standardskærmlayout på 128x64" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diseño de pantalla predeterminado de 128x64" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9645,6 +14480,12 @@ "value" : "デフォルト128x64スクリーンレイアウト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Формат экрана по умолчанию 128x64" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9667,12 +14508,24 @@ }, "Delete" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Löschen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -9703,6 +14556,12 @@ "value" : "Usuń" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -9729,8 +14588,15 @@ } } }, + "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", @@ -9743,6 +14609,12 @@ "value" : "全ての設定、キー、BLEボンドを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все настройки, ключи и BLE-соединения?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9753,6 +14625,12 @@ }, "Delete all config? " : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar todas las configuraciones? " + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9765,6 +14643,12 @@ "value" : "全ての設定を削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все настройки?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9775,6 +14659,18 @@ }, "Delete all device metrics?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet alle enhedens måledata?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar todas las métricas del dispositivo?" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -9805,6 +14701,12 @@ "value" : "Usunąć wszystkie metryki urządzenia?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все метрики устройства?" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -9833,6 +14735,18 @@ }, "Delete all environment metrics?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet alle miljødata?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar todas las métricas del entorno?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9845,6 +14759,12 @@ "value" : "全ての環境メトリクスを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все показатели среды?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9871,6 +14791,18 @@ }, "Delete all pax data?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet alle persontællingsdata?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar todos los datos de los pasajeros?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9883,6 +14815,12 @@ "value" : "全てのPAXデータを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все данные о прохожих?" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -9905,6 +14843,18 @@ }, "Delete all positions?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet alle positioner?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar todas las posiciones?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9917,6 +14867,12 @@ "value" : "全ての位置データを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все данные позиционирования?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9933,6 +14889,18 @@ }, "Delete Message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet besked" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9945,6 +14913,12 @@ "value" : "メッセージを削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9961,6 +14935,18 @@ }, "Delete Messages" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet beskeder" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar mensajes" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -9973,6 +14959,12 @@ "value" : "メッセージを削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -9989,12 +14981,24 @@ }, "Delete Node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet node" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten löschen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10007,6 +15011,12 @@ "value" : "ノードを削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10023,12 +15033,24 @@ }, "Delete Node?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet node?" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten löschen?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar nodo?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10041,6 +15063,12 @@ "value" : "ノードを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить ноду?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10057,6 +15085,18 @@ }, "Delete Power metrics?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet alle energiforbrugsdata?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Eliminar métricas de potencia?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10069,6 +15109,12 @@ "value" : "電力メトリクスを削除しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить метрики питания?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10085,12 +15131,24 @@ }, "Description" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskrivelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Beschreibung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descripción" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10103,6 +15161,12 @@ "value" : "説明" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Описание" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10119,6 +15183,18 @@ }, "Description must be less than 100 bytes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -10131,6 +15207,12 @@ "value" : "説明は100バイト未満である必要があります" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Описание должно быть не более 100 байт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10153,6 +15235,18 @@ }, "Details..." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalles..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подробности..." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10163,6 +15257,18 @@ }, "Detection" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detektion" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detección" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10175,6 +15281,12 @@ "value" : "検出" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обнаружение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10191,6 +15303,18 @@ }, "Detection event" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detektionshændelse" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evento de detección" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10203,6 +15327,12 @@ "value" : "検出イベント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Событие обнаружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10220,12 +15350,24 @@ "Detection Sensor" : { "extractionState" : "manual", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detektionssensor" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Detection Sensor" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensor de detección" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -10256,6 +15398,12 @@ "value" : "Detection Sensor" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Датчик обнаружения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10284,6 +15432,18 @@ }, "Detection Sensor Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detektionssensor-indstillinger" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del sensor de detección" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -10308,6 +15468,12 @@ "value" : "検出センサー設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка датчика обнаружения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10336,6 +15502,18 @@ }, "Detection Sensor Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detektionssensor-log" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro del sensor de detección" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10348,6 +15526,12 @@ "value" : "検出センサーログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал датчика обнаружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10364,6 +15548,18 @@ }, "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." + } + }, + "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", @@ -10376,6 +15572,12 @@ "value" : "検出センサーメッセージはテキストメッセージとして受信されます。通知を有効にすると、受信した各検出メッセージの通知と対応する未読メッセージバッジが表示されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщения с датчиков обнаружения принимаются в виде текстовых сообщений. Если вы включите уведомления, вы будете получать уведомление о каждом полученном сообщении об обнаружении и соответствующий значок непрочитанного сообщения." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10397,7 +15599,20 @@ } }, "Detection Sensor module config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -10428,6 +15643,12 @@ "value" : "Detection Sensor module config received: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля датчика обнаружения: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10456,6 +15677,18 @@ }, "Developers" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udviklere" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desarrolladores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10468,6 +15701,12 @@ "value" : "開発者" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разработчики" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10490,12 +15729,24 @@ }, "Device" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhed" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gerät" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -10526,6 +15777,12 @@ "value" : "Urządzenie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройство" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10554,12 +15811,24 @@ }, "Device Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsopsætning" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gerätekonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del dispositivo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -10590,6 +15859,12 @@ "value" : "Konfiguracja urządzenia" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка устройства" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10617,13 +15892,26 @@ } }, "Device config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedskonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gerätekonfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del dispositivo recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -10654,6 +15942,12 @@ "value" : "Otrzymano konfigurację urządzenia: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация устройства: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10682,12 +15976,24 @@ }, "Device Configuration" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsopsætning" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gerätekonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10700,6 +16006,12 @@ "value" : "デバイス設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация устройства" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10728,12 +16040,24 @@ }, "Device GPS" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enheds-GPS" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Geräte-GPS" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo GPS" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10746,6 +16070,12 @@ "value" : "デバイス GPS" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройство GPS" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10768,6 +16098,18 @@ }, "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." + } + }, + "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", @@ -10780,6 +16122,12 @@ "value" : "デバイスはメッシュ管理者によって管理されており、ユーザーはデバイス設定にアクセスできません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройством управляет администратор сети, пользователь не может получить доступ ни к одной настройке устройства." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10801,13 +16149,26 @@ } }, "Device Metadata received from: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsmetadata modtaget fra: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Device Metadata empfangen von: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metadatos del dispositivo recibidos de: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -10838,6 +16199,12 @@ "value" : "Otrzymano metadane urządzenia od: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метаданные устройства получены от: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -10865,7 +16232,20 @@ } }, "Device Metrics" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsmåledata" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métricas del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10878,6 +16258,12 @@ "value" : "デバイスメトリクス" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метрики устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10900,6 +16286,18 @@ }, "Device Metrics Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsmetriklog" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de métricas del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10912,6 +16310,12 @@ "value" : "デバイスメトリクスログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал метрик устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10934,12 +16338,24 @@ }, "Device Model: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsmodel: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gerätemodell: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelo de dispositivo: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10952,6 +16368,12 @@ "value" : "デバイスモデル: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель устройства: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -10973,10 +16395,35 @@ } }, "Device Options" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones del dispositivo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры устройства" + } + } + } }, "Device Role" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsrolle" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Función del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -10989,6 +16436,12 @@ "value" : "デバイス役割" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Роль устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11009,8 +16462,24 @@ } } }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedsskærm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pantalla del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11023,6 +16492,12 @@ "value" : "デバイス画面" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экран устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11044,13 +16519,26 @@ } }, "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", "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", @@ -11081,6 +16569,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", @@ -11108,13 +16602,26 @@ } }, "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", "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", @@ -11145,6 +16652,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", @@ -11173,6 +16686,18 @@ }, "Dilution of precision (DOP) PDOP used by default" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -11185,6 +16710,12 @@ "value" : "精度希釈(DOP)、デフォルトでPDOPを使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Снижение точности (DOP) PDOP используется по умолчанию" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11201,12 +16732,24 @@ }, "Direct" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkte" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Direkt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "directo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11219,6 +16762,12 @@ "value" : "ダイレクト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прямое" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11241,12 +16790,24 @@ }, "Direct Message Help" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hjælp til direkte beskeder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Hilfe für Direktnachrichten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayuda por mensaje directo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11259,6 +16820,12 @@ "value" : "ダイレクトメッセージヘルプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Справка по личным сообщениям" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11281,12 +16848,24 @@ }, "Direct Message Key" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tecla de mensaje directo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ダイレクトメッセージキー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ключ личных переписок" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11297,12 +16876,24 @@ }, "Direct Messages" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkte beskeder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Direktnachrichten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensajes directos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -11333,6 +16924,12 @@ "value" : "Bezpośrednie Wiadomości" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Личные сообщения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11361,6 +16958,18 @@ }, "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" + } + }, + "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", @@ -11373,6 +16982,12 @@ "value" : "ダイレクトメッセージは暗号化のために新しい公開鍵インフラストラクチャを使用しています。ファームウェアバージョン2.5以上が必要です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Личные сообщения используют новую инфраструктуру открытых ключей для шифрования. Требуется версия прошивки 2.5 или выше." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11395,6 +17010,18 @@ }, "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los mensajes directos utilizan la clave compartida del canal." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11407,6 +17034,12 @@ "value" : "ダイレクトメッセージはチャンネルの共有キーを使用しています。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Личные сообщения используют общий ключ для канала." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11429,12 +17062,24 @@ }, "Disabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktiveret" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Deaktiviert" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discapacitado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -11465,6 +17110,12 @@ "value" : "Wyłączony" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключен" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11493,12 +17144,24 @@ }, "Disconnect" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fra" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Trennen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -11529,6 +17192,12 @@ "value" : "Rozłącz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11557,12 +17226,24 @@ }, "Disconnect Node" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar nodo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ノードを切断" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключить ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11573,12 +17254,24 @@ }, "Disconnect the currently connected node" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar el nodo actualmente conectado" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "現在接続中のノードを切断します" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключить подключенную в данный момент ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11589,12 +17282,24 @@ }, "Dismiss" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afvis" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Tastatur ausblenden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descartar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -11625,6 +17330,12 @@ "value" : "Zamknij" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отклонить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11653,12 +17364,24 @@ }, "Display" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skærm" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Display (Device Screen)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pantalla" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -11689,6 +17412,12 @@ "value" : "Wyświetlacz (Ekran Urządzenia)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экран" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11717,6 +17446,18 @@ }, "Display Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skærmopsætning" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de pantalla" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -11747,6 +17488,12 @@ "value" : "Konfiguracja Wyświetlacza" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки экрана" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11774,13 +17521,26 @@ } }, "Display config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skærmopsætning modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Display Konfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de pantalla recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -11811,6 +17571,12 @@ "value" : "Otrzymano konfigurację wyświetlacza: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Полученная конфигурация отображения: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -11839,6 +17605,18 @@ }, "Display Fahrenheit" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis Fahrenheit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar grados Fahrenheit" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11851,6 +17629,12 @@ "value" : "華氏表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отображение по Фаренгейту" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11873,6 +17657,18 @@ }, "Display Mode" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Display Mode" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo de visualización" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11885,6 +17681,12 @@ "value" : "表示モード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим экрана" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11913,6 +17715,18 @@ "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", + "value" : "Отобразить расстояние между вашим телефоном и другими нодами Meshtastic с указанием местоположения." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11923,6 +17737,18 @@ }, "Display Units" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Display Units" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unidades de visualización" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11935,6 +17761,12 @@ "value" : "表示単位" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Единицы измерения на экране" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -11957,12 +17789,24 @@ }, "Distance" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Distanz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distancia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -11975,6 +17819,12 @@ "value" : "距離" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расстояние" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12003,6 +17853,18 @@ "value" : "Distanzfilter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtros de distancia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фильтры расстояния" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12019,6 +17881,18 @@ "value" : "Distanzmessungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediciones de distancia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Измерения расстояний" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12028,16 +17902,41 @@ } }, "Distance: %@" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distancia: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расстояние: %@" + } + } + } }, "Documentation" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dokumentation" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Dokumentation" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documentación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12050,6 +17949,12 @@ "value" : "ドキュメント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Документация" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12072,12 +17977,24 @@ }, "Done" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "hecho" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "完了" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Готово" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12094,6 +18011,18 @@ }, "Double Tap as Button" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dobbelttryk som knap" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toque dos veces como botón" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12106,6 +18035,12 @@ "value" : "ダブルタップをボタンとして使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Двойной тап как кнопка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12127,13 +18062,26 @@ } }, "Down" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nede" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Runter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abajo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -12164,6 +18112,12 @@ "value" : "W Dół" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вниз" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -12192,6 +18146,18 @@ }, "Downlink Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downlink slået til" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enlace descendente habilitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12204,6 +18170,12 @@ "value" : "ダウンリンク有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нисходящая связь включена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12224,8 +18196,21 @@ } } }, + "Download TAK Server Data Package" : {}, "Drag & Drop Firmware Update" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Træk-og-slip firmwareopdatering" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualización de firmware de arrastrar y soltar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12238,6 +18223,12 @@ "value" : "ドラッグ&ドロップファームウェア更新" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление прошивки в режиме внешнего диска" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12260,6 +18251,18 @@ }, "Drag & Drop Firmware Update Documentation" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -12272,6 +18275,12 @@ "value" : "ドラッグ&ドロップファームウェア更新ドキュメント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Документация по обновлению прошивки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12294,6 +18303,18 @@ }, "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." + } + }, + "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", @@ -12306,6 +18327,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", @@ -12327,13 +18354,26 @@ } }, "Driving" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kører" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Fahren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conducir" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12346,6 +18386,12 @@ "value" : "運転" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вождение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12368,6 +18414,18 @@ }, "Drop Pin in Maps" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Placer nål i kort" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Colocar pin en mapas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12380,6 +18438,12 @@ "value" : "マップにピンを配置" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить метку на карте" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12408,6 +18472,18 @@ "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", + "value" : "Легко настройте частные ячеистые сети для безопасной и надежной связи в отдаленных районах." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12418,6 +18494,18 @@ }, "Echo" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Echo" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "eco" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -12442,6 +18530,12 @@ "value" : "エコー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эхо" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -12470,6 +18564,18 @@ }, "Editing Waypoint" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redigerer viapunkt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar punto de referencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12482,6 +18588,12 @@ "value" : "ウェイポイント編集" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактирование путевой точки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12497,13 +18609,26 @@ } }, "Eighteen Hours" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atten timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Achtzehn Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieciocho horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -12534,6 +18659,12 @@ "value" : "Osiemnaście Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восемнадцать часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -12562,12 +18693,24 @@ }, "Elev. Gain" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højdeforøgelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Höhenunterschied" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elev. Ganar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12580,6 +18723,12 @@ "value" : "標高ゲイン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Набор высоты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12596,6 +18745,18 @@ }, "Emoji" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emoji" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "emojis" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12608,6 +18769,12 @@ "value" : "絵文字" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эмодзи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12630,6 +18797,18 @@ }, "Empty" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tom" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "vacio" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12642,6 +18821,12 @@ "value" : "空" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пустой" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12663,10 +18848,35 @@ } }, "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", + "value" : "Включить передачу метрик устройства в ячеистую сеть. При отключении метрики отправляются только подключенным клиентам." + } + } + } }, "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." + } + }, + "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", @@ -12679,6 +18889,12 @@ "value" : "ローカルネットワーク上でUDP経由のパケットブロードキャストを有効にします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить трансляцию пакетов через UDP по локальной сети." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12701,6 +18917,18 @@ "value" : "Standortfreigabe aktivieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitar compartir ubicación" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Делиться местоположением" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12711,6 +18939,18 @@ }, "Enable Notifications" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tillad notifikationer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitar notificaciones" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12723,6 +18963,12 @@ "value" : "通知を有効にする" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить уведомления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12743,8 +18989,21 @@ } } }, + "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilite este dispositivo como servidor Store and Forward. Requiere un dispositivo ESP32 con PSRAM." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12757,6 +19016,12 @@ "value" : "このデバイスをStore and Forwardサーバーとして有効にします。PSRAMを搭載したESP32デバイスが必要です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить это устройство как сервер хранения и пересылки. Требуется устройство ESP32 с PSRAM." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12773,12 +19038,24 @@ }, "Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiveret" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Aktiviert" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -12809,6 +19086,12 @@ "value" : "Włączony" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включен" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -12836,13 +19119,26 @@ } }, "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", "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", @@ -12873,6 +19169,12 @@ "value" : "Enables automatic TAK PLI broadcasts and reduces routine broadcasts." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включает автоматическую передачу TAK PLI и сокращает регулярные передачи." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -12901,6 +19203,18 @@ }, "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." + } + }, + "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", @@ -12913,6 +19227,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", @@ -12941,6 +19261,18 @@ "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", + "value" : "Включает синюю точку местоположения вашего телефона на карте сети." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12951,6 +19283,18 @@ }, "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" + } + }, + "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", @@ -12963,6 +19307,12 @@ "value" : "検出センサーモジュールを有効にします。センサーを持つノードと、検出センサーテキストメッセージを受信したり、検出センサーログやチャートを表示したいノードの両方で有効にする必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включает модуль датчика обнаружения. Его необходимо включить как на ноде с датчиком, так и на всех нодах, на которых вы хотите получать текстовые сообщения датчика обнаружения или просматривать журнал и график датчика обнаружения." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -12985,6 +19335,18 @@ }, "Enables the store and forward module." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiverer butiks- og videresendelsesmodulet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilita el módulo de almacenamiento y reenvío." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -12997,6 +19359,12 @@ "value" : "Store and Forwardモジュールを有効にします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включает модуль хранения и пересылки." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13013,6 +19381,18 @@ }, "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Al habilitar Ethernet se deshabilitará la conexión bluetooth a la aplicación." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13025,6 +19405,12 @@ "value" : "Ethernetを有効にすると、アプリへのBluetooth接続が無効になります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включение Ethernet отключит соединение Bluetooth с приложением." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13035,6 +19421,24 @@ }, "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Habilitar WiFi deshabilitará la conexión bluetooth a la aplicación." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При включении WiFi отключится bluetooth подключение к телефону" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13045,6 +19449,18 @@ }, "Encoder Press Event" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encoder trykhændelse" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evento de prensa del codificador" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13057,6 +19473,12 @@ "value" : "エンコーダープレスイベント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Событие нажатия энкодера" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13073,12 +19495,24 @@ }, "Encrypted" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Krypteret" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Verschlüsselt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cifrado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -13109,6 +19543,12 @@ "value" : "Zaszyfrowany" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зашифровано" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -13138,12 +19578,24 @@ "Encrypted Send Failed" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Krypteret afsendelse mislykkedes" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Verschlüsseltes Senden fehlgeschlagen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error de envío cifrado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13172,6 +19624,18 @@ }, "Encryption Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kryptering aktiveret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cifrado habilitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13184,6 +19648,12 @@ "value" : "暗号化有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Шифрование включено" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13206,12 +19676,24 @@ }, "Enter DFU Mode" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gå ind i DFU-tilstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "DFÜ-Modus aktivieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingrese al modo DFU" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13224,6 +19706,12 @@ "value" : "DFUモードに入る" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Войти в режим DFU" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13248,6 +19736,18 @@ "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", + "value" : "Введите имя хоста[:порт]" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13256,14 +19756,29 @@ } } }, + "Enter P12 Password" : {}, + "Enter the password for the PKCS#12 file" : {}, "environment" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "miljø" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Umgebung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "medio ambiente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13276,6 +19791,12 @@ "value" : "環境" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "окружение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13298,12 +19819,24 @@ }, "Environment" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Miljø" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Umgebung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medio ambiente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13316,6 +19849,12 @@ "value" : "環境" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Окружение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13337,7 +19876,20 @@ } }, "Environment Metrics" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Miljødata" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métricas ambientales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13350,6 +19902,12 @@ "value" : "環境メトリクス" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метрики окружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13371,10 +19929,35 @@ } }, "Environment Metrics Enabled" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métricas de entorno habilitadas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метрики окружения включены" + } + } + } }, "Environment Metrics Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Miljødata-log" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de métricas ambientales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13387,6 +19970,12 @@ "value" : "環境メトリクスログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал метрик окружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13408,16 +19997,41 @@ } }, "Environment Sensor Options" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de sensores ambientales" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры датчика окружения" + } + } + } }, "Erase all app data?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slette alle app-data?" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Alle App-Daten löschen?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Borrar todos los datos de la aplicación?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13430,6 +20044,12 @@ "value" : "全てのアプリデータを消去しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все данные приложения?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13452,12 +20072,24 @@ }, "Erase all device and app data?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slette alle enheds- og appdata?" + } + }, "de" : { "stringUnit" : { "state" : "translated", "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", @@ -13470,6 +20102,12 @@ "value" : "全てのデバイスおよびアプリデータを消去しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все данные устройства и приложения?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13492,6 +20130,18 @@ }, "Error: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejl: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13504,6 +20154,12 @@ "value" : "エラー: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13526,6 +20182,18 @@ }, "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" + } + }, + "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", @@ -13538,6 +20206,12 @@ "value" : "ESP32 OTAアップデートは開発中です。下のボタンをクリックして、デバイスにOTA管理モードへの再起動メッセージを送信してください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление ESP32 OTA находится в разработке, нажмите кнопку ниже, чтобы отправить устройству сообщение о перезагрузке в режим OTA администратора." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13560,6 +20234,18 @@ }, "ESP32 Device Firmware Update" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "ESP32-enhedens firmwareopdatering" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualización del firmware del dispositivo ESP32" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13572,6 +20258,12 @@ "value" : "ESP32デバイスファームウェア更新" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление прошивки для устройства ESP32" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13594,6 +20286,18 @@ }, "Ethernet Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ethernet-indstillinger" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de Ethernet" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13606,6 +20310,12 @@ "value" : "イーサネットオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры Ethernet" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13621,7 +20331,20 @@ } }, "European Union 433MHz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "EU 433 MHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unión Europea 433MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13634,6 +20357,12 @@ "value" : "欧州連合 433MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eвросоюз 433Mhz" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13649,7 +20378,20 @@ } }, "European Union 868MHz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "EU 868 MHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unión Europea 868MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13662,6 +20404,12 @@ "value" : "欧州連合 868MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Евросоюз 868Mhz" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13677,13 +20425,26 @@ } }, "Evening" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aften" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Abend" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tarde" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13696,6 +20457,12 @@ "value" : "夕方" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вечер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13712,6 +20479,18 @@ }, "Exchange Positions" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Byt Positioner" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posiciones de intercambio" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13724,6 +20503,12 @@ "value" : "位置交換" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Делиться местоположением" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13739,16 +20524,42 @@ } }, "Exchange User Info" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Información de usuario de Exchange" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обменяться информацией о пользователе" + } + } + } }, "Exclamation" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udråb" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Ausrufezeichen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "exclamación" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -13779,6 +20590,12 @@ "value" : "Wykrzyknik" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восклицательный знак" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -13807,12 +20624,24 @@ }, "Expiration" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caducidad" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "有効期限" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Истечение срока" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13823,12 +20652,24 @@ }, "Expire" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udløbe" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zeitpunkt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caducar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13841,6 +20682,12 @@ "value" : "期限切れ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Истекает" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13857,12 +20704,24 @@ }, "Expires" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udløber" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Automatisches Löschen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vence" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13875,6 +20734,12 @@ "value" : "有効期限" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Истекает" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13891,6 +20756,18 @@ }, "Expires: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udløber: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expira: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13903,6 +20780,12 @@ "value" : "有効期限: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Истекает: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13919,12 +20802,24 @@ }, "Export" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporter" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Exportieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -13937,6 +20832,12 @@ "value" : "エクスポート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экспорт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -13953,12 +20854,24 @@ }, "External Notification" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekstern meddelelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Externe Benachrichtigung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificación externa" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -13989,6 +20902,12 @@ "value" : "Zewnętrzne Powiadomienie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внешние уведомления" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14017,12 +20936,24 @@ }, "External Notification Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekstern notifikationskonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Einstellungen der externen Benachrichtigung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de notificación externa" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -14053,6 +20984,12 @@ "value" : "Konfiguracja Zewnętrznego Powiadomienia" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка внешних уведомлений" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14080,7 +21017,20 @@ } }, "External Notification module config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -14111,6 +21061,12 @@ "value" : "Otrzymano konfigurację modułu zewnętrznych powiadomień: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля внешних уведомлений: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14139,12 +21095,24 @@ }, "Factory Reset" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gendan til fabriksindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Werkseinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecimiento de fábrica" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14157,6 +21125,12 @@ "value" : "Factory リセット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сброс настроек" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14173,6 +21147,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", @@ -14185,6 +21165,12 @@ "value" : "工場出荷時リセットによりデバイスとアプリのデータが削除されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сброс к заводским настройкам удалит данные устройства и приложения." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14194,7 +21180,20 @@ } }, "Failed to encode message content" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke kode meddelelsens indhold" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo codificar el contenido del mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14207,6 +21206,12 @@ "value" : "メッセージ内容のエンコードに失敗しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось закодировать содержимое сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14222,10 +21227,35 @@ } }, "Failed to exchange user info." : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo intercambiar información de usuario." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось обменяться информацией о пользователе." + } + } + } }, "Failed to get a valid position to exchange" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -14238,6 +21268,12 @@ "value" : "交換用の有効な位置の取得に失敗しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось получить действительную позицию для обмена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14254,6 +21290,18 @@ }, "Failed to get a valid position to exchange." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -14266,6 +21314,12 @@ "value" : "交換用の有効な位置の取得に失敗しました。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось получить действительную позицию для обмена." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14281,13 +21335,26 @@ } }, "Fair" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retfærdig" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordentliche Signalstärke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feria" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14300,6 +21367,12 @@ "value" : "普通" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удовлетворительно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14316,12 +21389,24 @@ }, "Favorite" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foretrukne" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Favorit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorito" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14334,6 +21419,12 @@ "value" : "お気に入り" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Избранное" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14357,6 +21448,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", @@ -14372,16 +21469,41 @@ } }, "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", + "value" : "Избранные и игнорируемые ноды всегда сохраняются. Другие ноды удаляются из базы данных приложения по расписанию, установленному пользователем. (Ноды с ключами PKC всегда сохраняются не менее 7 дней.) Эта функция удаляет из приложения только те ноды, которые не хранятся в базе данных нод устройства." + } + } + } }, "Favorites" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foretrukne" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Favoriten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favoritos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14394,6 +21516,12 @@ "value" : "お気に入り" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Избранное" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14410,12 +21538,24 @@ }, "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", "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", @@ -14428,6 +21568,12 @@ "value" : "お気に入りと最近のメッセージがあるノードは、連絡先リストの上部に表示されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Избранные ноды и ноды с недавно полученными сообщениями появляются в верхней части списка контактов." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14450,12 +21596,24 @@ }, "Fetch the latest position of a cetain node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hent seneste position for én node" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Letzte Position eines Knotens holen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtener la última posición de un nodo determinado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14468,6 +21626,12 @@ "value" : "特定のノードの最新位置を取得" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получить последнюю позицию определенной ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14484,6 +21648,18 @@ }, "Fifteen Minutes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Femten minutter" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "quince minutos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14496,6 +21672,12 @@ "value" : "15分" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 минут" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14511,13 +21693,26 @@ } }, "Fifteen Seconds" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Femten sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Fünfzehn Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "quince segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -14548,6 +21743,12 @@ "value" : "Piętnaście Sekund" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 секунд" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14576,6 +21777,18 @@ }, "File Storage" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filopbevaring" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Almacenamiento de archivos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14588,6 +21801,12 @@ "value" : "ファイルストレージ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хранилище файлов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14605,6 +21824,18 @@ "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", + "value" : "Доступные файлы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14621,6 +21852,18 @@ "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", + "value" : "Фильтровать список нод и карту сети по близости к вашему телефону." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14631,12 +21874,24 @@ }, "Find a contact" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Find en kontaktperson" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kontakt suchen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "encontrar un contacto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14649,6 +21904,12 @@ "value" : "連絡先を検索" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Найти контакт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14665,12 +21926,24 @@ }, "Find a node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Find en node" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Einen Knoten finden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encuentra un nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14683,6 +21956,12 @@ "value" : "ノードを検索" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Найти ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14699,12 +21978,24 @@ }, "Finish" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afslut" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Beenden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "terminar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -14735,6 +22026,12 @@ "value" : "Finish" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14770,6 +22067,18 @@ "value" : "Ziel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "terminar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершить" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14780,12 +22089,24 @@ }, "Firmware" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Firmware" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Firmware" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "firmware" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14798,6 +22119,12 @@ "value" : "ファームウェア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прошивка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14820,6 +22147,18 @@ }, "Firmware update docs" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Firmware opdateringsdokumenter" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documentos de actualización de firmware" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14832,6 +22171,12 @@ "value" : "ファームウェア更新ドキュメント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Документация по обновлению прошивки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14854,12 +22199,24 @@ }, "Firmware Updates" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Firmware-opdateringer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Firmwareaktualisierungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizaciones de firmware" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14872,6 +22229,12 @@ "value" : "ファームウェア更新" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление прошивки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14894,12 +22257,24 @@ }, "Firmware Version" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Firmware-version" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Firmware Version" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión de firmware" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -14930,6 +22305,12 @@ "value" : "Wersja Oprogramowania" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия прошивки" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -14958,6 +22339,18 @@ }, "First heard" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Første gang hørt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "escuchado por primera vez" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -14970,6 +22363,12 @@ "value" : "初回受信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Первое обнаружение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -14985,13 +22384,26 @@ } }, "Five Hours" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fem timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Fünf Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cinco horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15022,6 +22434,12 @@ "value" : "Pięć Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пять часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15050,12 +22468,24 @@ }, "Five Minutes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fem minutter" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Fünf Minuten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cinco minutos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15068,6 +22498,12 @@ "value" : "5分" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пять минут" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15083,13 +22519,26 @@ } }, "Five Seconds" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fem sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Fünf Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cinco segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15120,6 +22569,12 @@ "value" : "Pięć Sekund" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пять секунд" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15146,14 +22601,34 @@ } } }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fastgjort pin " + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Feste PIN" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pasador fijo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15184,6 +22659,12 @@ "value" : "Stały PIN" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фиксированный PIN" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15212,6 +22693,18 @@ }, "Fixed Position" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fast position" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posición fija" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15224,6 +22717,12 @@ "value" : "固定位置" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фиксированная позиция" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15240,6 +22739,18 @@ }, "Flip Screen" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vend Skærm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltear pantalla" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15252,6 +22763,12 @@ "value" : "画面反転" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перевернуть экран" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15268,6 +22785,18 @@ }, "Flip screen vertically" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vend skærm lodret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltear la pantalla verticalmente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15280,6 +22809,12 @@ "value" : "画面を垂直に反転" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перевернуть экран вертикально" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15295,13 +22830,26 @@ } }, "Follow" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Følg" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Folgen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguir" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15332,6 +22880,12 @@ "value" : "Śledź" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Следить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15359,13 +22913,26 @@ } }, "Follow with heading" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Følg med overskrift" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Folgen mit Steuerkurs" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguir con encabezado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15396,6 +22963,12 @@ "value" : "Śledź z kierunkiem" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Следить с направлением" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15424,6 +22997,18 @@ }, "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." + } + }, + "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", @@ -15436,6 +23021,12 @@ "value" : "マップレポート以外のすべてのMqtt機能については、Mqtt経由でブリッジしたい各チャンネルのアップリンクとダウンリンクも設定する必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для всей функциональности MQTT, кроме отчета карты, вы также должны настроить восходящую и нисходящую связь для каждого канала, который хотите мостить через MQTT." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15458,12 +23049,24 @@ }, "For everyone" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "For alle" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Für alle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Para todos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15476,6 +23079,12 @@ "value" : "すべての人に" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для всех" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15492,12 +23101,24 @@ }, "For me" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "For mig" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Für mich" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "para mi" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15510,6 +23131,12 @@ "value" : "自分に" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для меня" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15525,13 +23152,26 @@ } }, "Forty Eight Hours" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otteogfyrre timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Achtundvierzig Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cuarenta y ocho horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15562,6 +23202,12 @@ "value" : "Czterdzieści Osiem Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сорок восемь часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15589,13 +23235,26 @@ } }, "Forty Five Seconds" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Femogfyrre sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Fündundvierzig Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cuarenta y cinco segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15626,6 +23285,12 @@ "value" : "Czterdzieści Pięć Sekund" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сорок пять секунд" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15653,13 +23318,26 @@ } }, "Four Hours" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fire timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Vier Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cuatro horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15690,6 +23368,12 @@ "value" : "Cztery Godziny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Четыре часа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15717,13 +23401,26 @@ } }, "Four Seconds" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fire sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Vier Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "cuatro segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -15754,6 +23451,12 @@ "value" : "Cztery Sekundy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Четыре секунды" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -15782,12 +23485,24 @@ }, "Frequency" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frekvens" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Frequenz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frecuencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15800,6 +23515,12 @@ "value" : "周波数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Частота" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15816,6 +23537,18 @@ }, "Frequency Override" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frekvensoverride" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anulación de frecuencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15828,6 +23561,12 @@ "value" : "周波数オーバーライド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переопределение частоты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15844,6 +23583,18 @@ }, "Frequency Slot" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frekvensplads" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ranura de frecuencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15856,6 +23607,12 @@ "value" : "周波数スロット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Частотный слот" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15872,6 +23629,18 @@ }, "Friendly name" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Venligt navn" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre amigable" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15884,6 +23653,12 @@ "value" : "フレンドリー名" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Понятное имя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15906,6 +23681,18 @@ }, "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\"" + } + }, + "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", @@ -15918,6 +23705,12 @@ "value" : "メッシュに送信されるメッセージのフォーマットに使用されるフレンドリ名。例:「Motion」という名前は「Motion detected」というメッセージになります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Понятное имя, используемое для форматирования сообщения, отправляемого в сеть. Пример: Имя \"Motion\" приведет к сообщению \"Motion detected\"." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15940,6 +23733,18 @@ }, "From Radio (RX): %lld" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "De Radio (RX): %lld" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "От радио (RX): %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15950,6 +23755,18 @@ }, "Full Support" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fuld support" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Soporte completo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -15962,6 +23779,12 @@ "value" : "完全サポート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Полная поддержка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15976,14 +23799,27 @@ } } }, + "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", "value" : "現在使用中のプライベートキーを置き換える新しいプライベートキーを生成します。パブリックキーはプライベートキーから自動的に再生成されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать новый закрытый ключ для замены текущего. Открытый ключ будет автоматически создан из вашего закрытого ключа." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -15994,12 +23830,24 @@ }, "Generate QR Code" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generer QR-kode" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "QR Code Erzeugen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generar código QR" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -16030,6 +23878,12 @@ "value" : "Generuj Kod QR" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать QR-код" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -16058,6 +23912,18 @@ }, "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." + } + }, + "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", @@ -16070,6 +23936,12 @@ "value" : "カスタム防水ソーラー・検出センサールーターノード、アルミニウムデスクトップノード、頑丈なハンドセットを入手できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получите специальные водонепроницаемые солнечные ноды с датчиками обнаружения, алюминиевые настольные ноды и прочные устройства." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16086,12 +23958,24 @@ }, "Get Node Position" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hent nodeposition" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knotenposition ermitteln" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtener la posición del nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16104,6 +23988,12 @@ "value" : "ノード位置取得" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получить позицию ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16126,6 +24016,18 @@ }, "Get NRF DFU from the App Store" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hent NRF DFU fra App Store" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtenga NRF DFU en la App Store" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16138,6 +24040,12 @@ "value" : "App StoreからNRF DFUを取得" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузите NRF DFU из App Store" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16166,6 +24074,18 @@ "value" : "Los geht's" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "empezar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начать" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16176,6 +24096,18 @@ }, "Get the latest stable firmware" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hent den nyeste stabile firmware" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtenga el firmware estable más reciente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16188,6 +24120,12 @@ "value" : "最新の安定版ファームウェアを取得" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получить последнюю стабильную прошивку" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16210,12 +24148,24 @@ }, "GitHub Repository" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repositorio GitHub" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "GitHubリポジトリ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Репозиторий GitHub" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16225,7 +24175,20 @@ } }, "Good" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Godt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bueno" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16238,6 +24201,12 @@ "value" : "良好" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хорошо" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16254,6 +24223,18 @@ }, "GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16266,6 +24247,12 @@ "value" : "GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16288,6 +24275,18 @@ }, "GPIO Output Duration" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO-outputvarighed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duración de la salida GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16300,6 +24299,12 @@ "value" : "GPIO出力時間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длительность выхода GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16316,6 +24321,18 @@ }, "GPIO pin for rotary encoder A port." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -16328,6 +24345,12 @@ "value" : "ロータリーエンコーダーAポート用GPIOピン。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO для порта A поворотного энкодера." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16344,6 +24367,18 @@ }, "GPIO pin for rotary encoder B port." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -16356,6 +24391,12 @@ "value" : "ロータリーエンコーダーBポート用GPIOピン。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO для порта B поворотного энкодера." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16372,6 +24413,18 @@ }, "GPIO pin for rotary encoder Press port." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -16384,6 +24437,12 @@ "value" : "ロータリーエンコーダープレスポート用GPIOピン。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO для порта нажатия поворотного энкодера." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16400,6 +24459,18 @@ }, "GPIO Pin to monitor" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO-pin til overvågning" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin GPIO para monitorear" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16412,6 +24483,12 @@ "value" : "GPIO ピン to monitor" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO для мониторинга" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16428,6 +24505,18 @@ }, "GPS EN GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS PÅ GPIO" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS Y GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16440,6 +24529,12 @@ "value" : "GPS有効化GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS EN GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16462,6 +24557,18 @@ }, "GPS Receive GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS Indgang GPIO" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recepción GPS GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16474,6 +24581,12 @@ "value" : "GPS受信GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO приема GPS" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16496,6 +24609,18 @@ }, "GPS Transmit GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS Send GPIO" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmisión GPS GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16508,6 +24633,12 @@ "value" : "GPS送信GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO передачи GPS" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16530,12 +24661,24 @@ }, "Group Message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gruppemeddelelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gruppennachricht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensaje grupal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16548,6 +24691,12 @@ "value" : "グループメッセージ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Групповое сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16570,6 +24719,18 @@ }, "Gusts %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stød %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ráfagas %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16582,6 +24743,12 @@ "value" : "突風 %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Порывы ветра %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16597,7 +24764,20 @@ } }, "HaHa" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "HaHa" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jaja" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -16610,6 +24790,12 @@ "value" : "ハハ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ХаХа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -16638,6 +24824,18 @@ }, "Hard Reset" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecimiento completo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Жесткий сброс" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16648,6 +24846,18 @@ }, "Hardware" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hardware" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ferretería" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16660,6 +24870,12 @@ "value" : "ハードウェア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оборудование" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16681,7 +24897,20 @@ } }, "Hazardous" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Farlig" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peligroso" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16694,6 +24923,12 @@ "value" : "危険" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Опасно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16710,6 +24945,18 @@ }, "Heading" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retning" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "rumbo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16722,6 +24969,12 @@ "value" : "方位" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Направление" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16738,12 +24991,24 @@ }, "Heading: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retning: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kurs: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Título: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16756,6 +25021,12 @@ "value" : "方位: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Направление: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16771,13 +25042,26 @@ } }, "Heard" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hørt" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gehört" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "escuchado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -16808,6 +25092,12 @@ "value" : "Usłyszano" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Услышан" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -16835,13 +25125,26 @@ } }, "Heart" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hjerte" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Herz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "corazón" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -16872,6 +25175,12 @@ "value" : "Serce" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сердце" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -16900,6 +25209,18 @@ }, "Hide alerts" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skjul alarmer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar alertas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16912,6 +25233,12 @@ "value" : "アラートを非表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть оповещения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16928,6 +25255,18 @@ }, "Hide Alerts" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skjul Alarmer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar alertas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16940,6 +25279,12 @@ "value" : "アラートを非表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть оповещения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16956,12 +25301,24 @@ }, "HIGH" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "HØJ" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "HOCH" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALTA" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -16974,6 +25331,12 @@ "value" : "高" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ВЫСОКИЙ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -16995,13 +25358,26 @@ } }, "Hiking" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vandrer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Wandern" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Senderismo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17014,6 +25390,12 @@ "value" : "ハイキング" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пеший туризм" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17036,6 +25418,18 @@ }, "History Return Max" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historik Return Max" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historial Retorno Max" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17048,6 +25442,12 @@ "value" : "履歴返信最大数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимальное возвращение истории" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17064,6 +25464,18 @@ }, "History Return Window" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vindue for historikreturnering" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ventana de retorno del historial" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17076,6 +25488,12 @@ "value" : "履歴返信時間枠" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Временное окно возврата истории" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17092,12 +25510,24 @@ }, "Hops Away" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hops væk" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Hops Entfernt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "salta lejos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17110,6 +25540,12 @@ "value" : "ホップ距離" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хопов до ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17126,12 +25562,24 @@ }, "Hops Away %d" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hop væk %d" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Hops Entfernt %d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salta lejos %d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17144,6 +25592,12 @@ "value" : "ホップ距離 %d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хопов до ноды: %d" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17159,13 +25613,26 @@ } }, "Hops Away:" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hop væk:" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Hops Entfernt:" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saltos lejos:" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17178,6 +25645,12 @@ "value" : "ホップ距離:" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хопов до ноды:" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17194,12 +25667,24 @@ }, "Hops Away: %d" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hop væk: %d" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Hops Entfernt: %d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salta lejos: %d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17212,6 +25697,12 @@ "value" : "ホップ距離: %d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Хопов до ноды: %d" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17228,12 +25719,24 @@ }, "Hour" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Stunde" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hora" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17246,6 +25749,12 @@ "value" : "時間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Час" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17268,6 +25777,18 @@ }, "Hourly Duty Cycle" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Driftcyklus pr. time" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciclo de trabajo por hora" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17280,6 +25801,12 @@ "value" : "時間あたりデューティサイクル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Почасовой рабочий цикл" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17296,6 +25823,18 @@ }, "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." + } + }, + "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", @@ -17308,6 +25847,12 @@ "value" : "ユーザーボタンが押されたり、メッセージが受信された後、画面が点灯し続ける時間。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как долго экран остается включенным после нажатия кнопки пользователя или получения сообщений." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17330,6 +25875,18 @@ }, "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." + } + }, + "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", @@ -17342,6 +25899,12 @@ "value" : "デバイスメトリクスがメッシュ経由で送信される頻度。デフォルトは30分です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как часто метрики устройства отправляются по сети. По умолчанию 30 минут." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17364,6 +25927,18 @@ }, "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." + } + }, + "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", @@ -17376,6 +25951,12 @@ "value" : "環境メトリクスがメッシュ経由で送信される頻度。デフォルトは30分です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как часто метрики окружения отправляются по сети. По умолчанию 30 минут." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17398,6 +25979,18 @@ }, "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." + } + }, + "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", @@ -17410,6 +26003,12 @@ "value" : "電力メトリクスがメッシュ経由で送信される頻度。デフォルトは30分です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как часто метрики питания отправляются по сети. По умолчанию 30 минут." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17432,6 +26031,18 @@ }, "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" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Con qué frecuencia debemos intentar obtener una posición GPS?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17444,6 +26055,12 @@ "value" : "GPS位置を取得する頻度。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как часто следует пытаться получить позицию GPS." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17466,6 +26083,18 @@ }, "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." + } + }, + "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", @@ -17478,6 +26107,12 @@ "value" : "検出の有無に関係なく、検出センサーの状態をメッシュに送信する頻度。デフォルトは「なし」です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как часто отправлять состояние датчика обнаружения в сеть независимо от обнаружения. По умолчанию никогда." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17500,12 +26135,24 @@ }, "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", "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", @@ -17530,6 +26177,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", @@ -17558,12 +26211,24 @@ }, "How to update Firmware" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sådan opdateres firmware" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Wie wird die Firmware aktualisiert" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cómo actualizar el firmware" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17576,6 +26241,12 @@ "value" : "ファームウェアの更新方法" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как обновить прошивку" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17597,7 +26268,20 @@ } }, "Hum" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brum" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tararear" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17610,6 +26294,12 @@ "value" : "湿度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Влажность" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17626,12 +26316,24 @@ }, "Humidity" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luftfugtighed" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Luftfeuchtigkeit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Humedad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17644,6 +26346,12 @@ "value" : "湿度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Влажность" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17659,7 +26367,20 @@ } }, "Hybrid" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hybrid" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Híbrido" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -17690,6 +26411,12 @@ "value" : "Hybrydowy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Гибридный" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -17717,7 +26444,20 @@ } }, "Hybrid Flyover" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hybrid Luftfoto" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paso elevado híbrido" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -17748,6 +26488,12 @@ "value" : "Hybrydowy Przelot" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Гибридный облет" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -17776,12 +26522,30 @@ }, "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." + } + }, + "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", "value" : "上記を読み理解しました。MQTT経由でのノードデータの暗号化されない送信に自発的に同意します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Я прочитал и понимаю вышеизложенное. Я добровольно даю согласие на незашифрованную передачу данных моей ноды через MQTT." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17798,6 +26562,18 @@ }, "IAQ" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17810,6 +26586,12 @@ "value" : "IAQ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17832,6 +26614,18 @@ }, "IAQ " : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ " + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ " + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17844,6 +26638,12 @@ "value" : "IAQ " } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17866,6 +26666,18 @@ }, "IAQ %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ %lld" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ %lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17878,6 +26690,12 @@ "value" : "IAQ %lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "IAQ %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17904,12 +26722,24 @@ }, "Icon" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikon" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Emoji" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icono" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17922,6 +26752,12 @@ "value" : "アイコン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Значок" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17944,6 +26780,18 @@ }, "Icons" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iconos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Значки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17954,6 +26802,18 @@ }, "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" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si se configura DOP, use valores HDOP/VDOP en lugar de PDOP" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -17966,6 +26826,12 @@ "value" : "DOPが設定されている場合、PDOPの代わりにHDOP / VDOP値を使用します" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если DOP установлен, используйте значения HDOP / VDOP вместо PDOP" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -17988,6 +26854,18 @@ }, "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" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si está habilitado, el pin de 'salida' se activará alto, deshabilitado significa activo bajo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18000,6 +26878,12 @@ "value" : "有効にすると、「出力」ピンがアクティブハイになり、無効にするとアクティブローになります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если включено, контакт 'output' будет активирован на высоком уровне, отключено означает активный низкий уровень." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18022,6 +26906,18 @@ }, "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." + } + }, + "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", @@ -18034,6 +26930,12 @@ "value" : "デバイスのリセットボタンにアクセスが困難な場合は、ここでDFUモードに入ってください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если трудно получить доступ к кнопке сброса вашего устройства, войдите в режим DFU здесь." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18056,6 +26958,18 @@ }, "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si está configurado, cualquier paquete que envíe se enviará a su dispositivo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18068,6 +26982,12 @@ "value" : "設定すると、送信したパケットがデバイスにエコーバックされます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если установлено, все отправляемые вами пакеты будут отправлены эхом обратно на ваше устройство." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18090,6 +27010,18 @@ }, "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." + } + }, + "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", @@ -18102,6 +27034,12 @@ "value" : "デフォルトの地域トピックが混雑している場合は、よりローカルなトピックを選択できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если тема региона по умолчанию слишком загружена, вы можете выбрать более локальную тему." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18124,6 +27062,18 @@ }, "Ignore MQTT" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorer MQTT" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorar MQTT" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18136,6 +27086,12 @@ "value" : "MQTTを無視" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Игнорировать MQTT" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18158,6 +27114,18 @@ }, "Ignore Node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorer node" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorar nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18170,6 +27138,12 @@ "value" : "ノードを無視" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Игнорировать ноду" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18192,6 +27166,18 @@ }, "Ignored" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignoreret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ignorado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18204,6 +27190,12 @@ "value" : "無視" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Игнорируется" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18225,13 +27217,32 @@ } }, "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." + } + }, + "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", "value" : "Local Onlyのように外部メッシュからの観測メッセージを無視しますが、さらに進んで、ノードの既知リストにないノードからのメッセージも無視します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Игнорирует наблюдаемые сообщения из чужих сетей, как Local Only, но идет дальше, также игнорируя сообщения от нод, которых еще нет в списке известных." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18247,13 +27258,32 @@ } }, "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." + } + }, + "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", "value" : "オープンまたは復号化できない外部メッシュからの観測メッセージを無視します。ノードのローカル主要/副次チャンネルでのみメッセージを再ブロードキャストします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Игнорирует наблюдаемые сообщения из чужих сетей, которые открыты или которые не может расшифровать. Повторно передает сообщения только на локальных первичных/вторичных каналах ноды." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18268,14 +27298,30 @@ } } }, + "Import" : {}, + "Import .pem" : {}, + "Import Custom .p12" : {}, + "Import Error" : {}, "Import Route" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importér rute" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Route importieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de importación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18288,6 +27334,12 @@ "value" : "ルートインポート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Импортировать маршрут" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18310,6 +27362,18 @@ }, "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", + "value" : "Помимо конфигурации, будут стерты ключи и связи BLE." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18320,12 +27384,24 @@ }, "Include" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inkluder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Include" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "incluir" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -18356,6 +27432,12 @@ "value" : "Dołącz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -18390,6 +27472,18 @@ "value" : "Eingehende Nachrichten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensajes entrantes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Входящие сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18399,6 +27493,7 @@ } }, "Incomplete" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -18406,6 +27501,12 @@ "value" : "Unvollständig" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Incompleto" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -18424,6 +27525,12 @@ "value" : "未完了" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не завершено" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18439,13 +27546,32 @@ } }, "India" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indien" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "India" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "インド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Индия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18461,7 +27587,20 @@ } }, "Indoor Air Quality" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indendørs luftkvalitet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calidad del aire interior" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18474,6 +27613,12 @@ "value" : "室内空気品質" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Качество воздуха в помещении" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18496,6 +27641,18 @@ }, "Indoor Air Quality (IAQ)" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indeklimakvalitet (IAQ)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calidad del aire interior (IAQ)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18508,6 +27665,12 @@ "value" : "室内空気質(IAQ)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Качество воздуха в помещении (IAQ)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18529,13 +27692,26 @@ } }, "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", "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", @@ -18566,6 +27742,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", @@ -18594,6 +27776,18 @@ }, "Inputs" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Input" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entradas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18606,6 +27800,12 @@ "value" : "入力" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Входы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18634,6 +27834,18 @@ "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", + "value" : "Недопустимое содержимое файла. Проверьте формат файла." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18643,7 +27855,20 @@ } }, "Inverted top bar for 2 Color display" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Omvendt topbjælke til 2-farvevisning" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Barra superior invertida para pantalla de 2 colores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18656,6 +27881,12 @@ "value" : "2色ディスプレイ用反転トップバー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инвертированная верхняя панель для 2-цветного дисплея" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18677,7 +27908,20 @@ } }, "Japan" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Japan" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Japón" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18690,6 +27934,12 @@ "value" : "日本" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Япония" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18706,6 +27956,18 @@ }, "JSON Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON aktiveret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON habilitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18718,6 +27980,12 @@ "value" : "JSON有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18740,6 +28008,18 @@ }, "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" + } + }, + "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", @@ -18752,6 +28032,12 @@ "value" : "JSONモードは、Home Assistantとのローカル統合のための限定的で暗号化されていないMQTT出力です" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим JSON - это ограниченный, незашифрованный вывод MQTT для локальной интеграции с Home Assistant." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18774,12 +28060,30 @@ }, "Jump to present" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gå til nutid" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saltar al presente" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "最新に移動" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перейти к настоящему" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18796,12 +28100,24 @@ }, "Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "clave" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18814,6 +28130,12 @@ "value" : "キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ключ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18836,12 +28158,24 @@ }, "Key Backup" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copia de seguridad clave" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "キーバックアップ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Резервная копия ключа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18852,6 +28186,18 @@ }, "Key Mapping" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tastetilknytning" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mapeo de claves" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18864,6 +28210,12 @@ "value" : "キーマッピング" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Назначение клавиш" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18880,12 +28232,24 @@ }, "Key Size" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nøglestørrelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Schlüsselgröße" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamaño de clave" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18898,6 +28262,12 @@ "value" : "キーサイズ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Размер ключа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18913,7 +28283,20 @@ } }, "Korea" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korea" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corea" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18926,6 +28309,12 @@ "value" : "韓国" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Корея" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18942,12 +28331,24 @@ }, "Last heard" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sidst hørt" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zuletzt gehört" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escuchado por última vez" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -18960,6 +28361,12 @@ "value" : "最終受信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последнее обнаружение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -18982,19 +28389,62 @@ }, "Last seen device:" : { "comment" : "A label displayed next to the last seen device text in the `DeviceConnectRow`.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo visto por última vez:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последнее устройство:" + } + } + } }, "Last seen device: %@" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Último dispositivo visto: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последнее устройство: %@" + } + } + } + }, + "Later" : { + "comment" : "A button that dismisses an alert without taking any action.", + "isCommentAutoGenerated" : true }, "Latitude" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Breddegrad" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Breitengrad" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latitud" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19007,6 +28457,12 @@ "value" : "緯度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Широта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19029,12 +28485,24 @@ }, "Latitude in degrees (e.g., 37.7749)" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latitud en grados (por ejemplo, 37,7749)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "緯度(度単位、例: 37.7749)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Широта в градусах (например, 55.7558)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19045,12 +28513,24 @@ }, "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", "value" : "緯度は-90度から90度の間である必要があります" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Широта должна быть между -90 и 90 градусами." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19061,6 +28541,18 @@ }, "LED Heartbeat" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LED-hjertebanken" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latido del corazón LED" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19073,6 +28565,12 @@ "value" : "LEDハートビート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пульс LED" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19095,12 +28593,24 @@ }, "LED State" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LED-tilstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "LED Status" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado del LED" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19113,6 +28623,12 @@ "value" : "LED状態" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Состояние LED" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19134,13 +28650,26 @@ } }, "Left" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Venstre" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Links" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Izquierda" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -19171,6 +28700,12 @@ "value" : "W Lewo" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Влево" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -19199,12 +28734,24 @@ }, "Level" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Niveau" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Level" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nivel" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -19235,6 +28782,12 @@ "value" : "Level" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уровень" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -19263,6 +28816,18 @@ }, "Licensed Operator" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Licenseret operatør" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Operador Licenciado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19275,6 +28840,12 @@ "value" : "ライセンスオペレーター" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лицензированный оператор" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19297,6 +28868,18 @@ }, "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." + } + }, + "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", @@ -19309,6 +28892,12 @@ "value" : "特にテレメトリと位置情報のすべての定期ブロードキャスト間隔を制限します。ホップを増やす必要がある場合は、中央のノードではなく端のノードで行ってください。デューティサイクルが制限されている場合、ゲートウェイノードがすべての作業を行うため、MQTTは推奨されません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ограничьте все интервалы периодической передачи, особенно телеметрию и позицию. Если вам нужно увеличить количество хопов, делайте это на нодах \"на краях\", а не в середине. MQTT не рекомендуется, когда вы ограничены рабочим циклом, потому что тогда нода-шлюз выполняет всю работу." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19331,6 +28920,18 @@ }, "Line Series" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linjeserie" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serie de línea" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19343,6 +28944,12 @@ "value" : "線系列" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Линейная серия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19359,6 +28966,18 @@ }, "Loading Logs. . ." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indlæser logfiler. . ." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cargando registros. . ." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19371,6 +28990,12 @@ "value" : "Loading ログs. . ." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка журналов. . ." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19395,6 +29020,18 @@ "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", + "value" : "Доступ к локальной сети" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19419,12 +29056,24 @@ }, "Location:" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Placering:" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Standort:" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ubicación:" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19437,6 +29086,12 @@ "value" : "場所:" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Местоположение:" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19459,12 +29114,24 @@ }, "Locked" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Låst" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gesperrt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bloqueado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19477,6 +29144,12 @@ "value" : "ロック済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заблокирован" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19499,6 +29172,18 @@ }, "Log Levels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logniveauer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niveles de registro" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19511,6 +29196,12 @@ "value" : "ログレベル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уровни журнала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19533,12 +29224,24 @@ }, "Logging" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logning" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Logging" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -19569,6 +29272,12 @@ "value" : "Rejestracja" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ведение журнала" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -19597,6 +29306,18 @@ }, "Logs" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logfiler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19609,6 +29330,12 @@ "value" : "ログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журналы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19630,7 +29357,20 @@ } }, "Logs:" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logfiler:" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros:" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19643,6 +29383,12 @@ "value" : "ログ:" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журналы:" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19665,12 +29411,24 @@ }, "Long Name" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Langt navn" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Langer Name" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre largo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19683,6 +29441,12 @@ "value" : "長い名前" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длинное имя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19705,12 +29469,24 @@ }, "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", "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", @@ -19723,6 +29499,12 @@ "value" : "長押しで連絡先をお気に入りに追加、ミュート、または会話を削除できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длительное нажатие, чтобы добавить в избранное или отключить звук контакта или удалить беседу." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19744,7 +29526,20 @@ } }, "Long Range - Fast" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lang Rækkevidde - Hurtig" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largo alcance - Rápido" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19757,6 +29552,12 @@ "value" : "長距離 - 高速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long Range - Fast" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19772,7 +29573,20 @@ } }, "Long Range - Moderate" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lang rækkevidde - Moderat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largo alcance - Moderado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19785,6 +29599,12 @@ "value" : "長距離 - 中程度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long Range - Moderate" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19800,7 +29620,20 @@ } }, "Long Range - Slow" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lang rækkevidde - Langsom" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Largo alcance - Lento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19813,6 +29646,12 @@ "value" : "長距離 - 低速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Long Range - Slow" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19829,12 +29668,24 @@ }, "Longitude" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Længdegrad" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Längengrad" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Longitud" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -19847,6 +29698,12 @@ "value" : "経度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Долгота" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19869,12 +29726,24 @@ }, "Longitude in degrees (e.g., -122.4194)" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Longitud en grados (p. ej., -122,4194)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "経度(度単位、例: -122.4194)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Долгота в градусах (например, 37.6173)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19885,12 +29754,24 @@ }, "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", "value" : "経度は-180度から180度の間である必要があります" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Долгота должна быть между -180 и 180 градусами." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -19901,12 +29782,24 @@ }, "LoRa" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoRa" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "LoRa" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "lora" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -19937,6 +29830,12 @@ "value" : "LoRa" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoRa" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -19965,12 +29864,24 @@ }, "LoRa Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoRa-konfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "LoRa Einstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración LoRa" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20001,6 +29912,12 @@ "value" : "Konfiguracja LoRa" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка LoRa" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20029,6 +29946,18 @@ }, "LoRa Config Changes:" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambios en la configuración de LoRa:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменения настройки LoRa:" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20038,13 +29967,26 @@ } }, "LoRa config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoRa konfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "LoRa config empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de LoRa recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20075,6 +30017,12 @@ "value" : "Otrzymano konfigurację LoRa: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация LoRa: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20102,13 +30050,26 @@ } }, "Lost and Found" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mistet og fundet" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Tracker" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objetos perdidos y encontrados" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20121,6 +30082,12 @@ "value" : "落とし物" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Потеряно и найдено" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20143,6 +30110,18 @@ }, "LOW" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LAV" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "BAJO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20155,6 +30134,12 @@ "value" : "低" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "НИЗКИЙ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20177,6 +30162,18 @@ "value" : "Niedriger Akkustand" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batería baja" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Низкий заряд батареи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20186,13 +30183,26 @@ } }, "M5 Stack Card KB / RAK Keypad" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "M5 Stack Card KB / RAK Tastatur" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "M5 Stack Card KB / RAK Tastenfeld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarjeta de pila M5 Teclado KB / RAK" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20211,6 +30221,12 @@ "value" : "M5 Stack Card KB / RAK キーパッド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "M5 Stack Card KB / RAK клавиатура" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20232,7 +30248,20 @@ } }, "Malaysia 433MHz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malaysia 433 MHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malasia 433MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20245,6 +30274,12 @@ "value" : "マレーシア 433MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Малайзия 433MHz" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20260,7 +30295,20 @@ } }, "Malaysia 919MHz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malaysia 919MHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malasia 919MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20273,6 +30321,12 @@ "value" : "マレーシア 919MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Малайзия 919MHz" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20289,12 +30343,24 @@ }, "Manage Channels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrer kanaler" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kanäle verwalten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrar canales" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20325,6 +30391,12 @@ "value" : "Manage Channels" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управление каналами" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20354,6 +30426,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", @@ -20366,6 +30444,12 @@ "value" : "カスタムマップオーバーレイを管理" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управление пользовательскими слоями карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20395,6 +30479,18 @@ "value" : "Kartendaten verwalten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrar datos de mapas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управление данными карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20405,6 +30501,18 @@ }, "Managed Device" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administreret enhed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispositivo administrado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20417,6 +30525,12 @@ "value" : "管理されたデバイス" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управляемое устройство" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20439,6 +30553,18 @@ }, "Manual" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "manuales" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вручную" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20448,13 +30574,26 @@ } }, "Manual Configuration" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel konfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Manuelle Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración manual" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20485,6 +30624,12 @@ "value" : "Konfiguracja ręczna" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ручная настройка" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20513,6 +30658,18 @@ }, "Manual connection string" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cadena de conexión manual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Строка подключения вручную" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20522,10 +30679,35 @@ } }, "Manual Connections" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conexiones manuales" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ручные подключения" + } + } + } }, "Map Data" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos del mapa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20536,12 +30718,24 @@ }, "Map Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kortindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kartenoptionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de mapa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20554,6 +30748,12 @@ "value" : "マップオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20582,6 +30782,12 @@ "value" : "Karten-Overlays" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Superposiciones de mapas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20612,6 +30818,12 @@ "value" : "Nakładki map" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Слои карты" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20639,7 +30851,20 @@ } }, "Map Publish Interval" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kortudgivelsesinterval" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de publicación de mapas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20652,6 +30877,12 @@ "value" : "マップ公開間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал публикации карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20674,6 +30905,18 @@ }, "Map Report" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kortrapport" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informe de mapa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20686,6 +30929,12 @@ "value" : "マップレポート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отчет карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20707,13 +30956,26 @@ } }, "Max Retransmission Reached" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimal Genudsendelse Nået" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Maximale Wiederholungen erreicht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retransmisión máxima alcanzada" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20744,6 +31006,12 @@ "value" : "Osiągnięto limit retransmisji" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Достигнуто максимальное количество повторных передач" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20771,7 +31039,20 @@ } }, "Medium Range - Fast" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mellem rækkevidde - Hurtig" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rango medio - Rápido" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20784,6 +31065,12 @@ "value" : "中距離 - 高速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium Range - Fast" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20799,7 +31086,20 @@ } }, "Medium Range - Slow" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mellem rækkevidde - Langsom" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rango medio - Lento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20812,6 +31112,12 @@ "value" : "中距離 - 低速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium Range - Slow" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20828,6 +31134,18 @@ }, "Mesh activity update" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdatering af meshningsaktivitet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualización de actividad de malla" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -20840,6 +31158,12 @@ "value" : "メッシュアクティビティ更新" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление активности сети" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20856,12 +31180,24 @@ }, "Mesh Live Activity" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mesh Live Aktivitet" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Mesh Live Aktivität" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actividad en vivo de malla" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20892,6 +31228,12 @@ "value" : "Aktywność na Żywo" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущая активность сети" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20920,12 +31262,24 @@ }, "Mesh Map" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mesh-kort" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Mesh Karte" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mapa de malla" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -20956,6 +31310,12 @@ "value" : "Mapa Sieci" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Карта сети" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -20990,6 +31350,18 @@ "value" : "Standort auf der Mesh-Karte" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ubicación del mapa de malla" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Местоположение на карте сети" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -20998,8 +31370,24 @@ } } }, + "Mesh to CoT Converter" : { + "comment" : "A feature that bridges Meshtastic positions, nodes, waypoints, and messages to TAK/CoT format.", + "isCommentAutoGenerated" : true + }, "Meshtastic" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtástico" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21008,17 +31396,46 @@ } } }, + "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" : { + "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", + "value" : "Meshtastic не собирает личную информацию. Мы анонимно собираем данные об использовании и сбоях для улучшения приложения. Вы можете отказаться в настройках приложения." + } + } + } }, "Meshtastic Node %@ has shared channels with you" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic-noden %@ har delt kanaler med dig" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Meshtastic Knoten %@ hat Kanäle mit dir geteilt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic Node %@ ha compartido canales contigo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21031,6 +31448,12 @@ "value" : "Meshtasticノード %@ があなたとチャンネルを共有しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нода Meshtastic %@ поделилась с вами каналами" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21053,6 +31476,18 @@ "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", + "value" : "Meshtastic использует местоположение вашего телефона для включения ряда функций. Вы можете изменить разрешения на определение местоположения в любое время в настройках." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21063,6 +31498,18 @@ }, "Meshtastic® Copyright Meshtastic LLC" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic® er copyright Meshtastic LLC" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic® Copyright Meshtastic LLC" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21075,6 +31522,12 @@ "value" : "Meshtastic® Copyright Meshtastic LLC" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meshtastic® Copyright Meshtastic LLC" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21091,12 +31544,24 @@ }, "Message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Besked" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Nachricht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21109,6 +31574,12 @@ "value" : "メッセージ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21125,12 +31596,24 @@ }, "Message content exceeds 200 bytes." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskedindhold overstiger 200 byte." + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Nachrichteninhalt überschreitet 200 Bytes." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El contenido del mensaje supera los 200 bytes." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21143,6 +31626,12 @@ "value" : "メッセージ内容が200バイトを超えています。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Содержимое сообщения превышает 200 байт." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21159,12 +31648,24 @@ }, "Message Details" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meddelelsesdetaljer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Nachrichtendetails" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalles del mensaje" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -21195,6 +31696,12 @@ "value" : "Szczegóły wiadomości" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Детали сообщения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -21222,13 +31729,26 @@ } }, "Message received from the text message app." : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Besked modtaget fra sms-appen." + } + }, "de" : { "stringUnit" : { "state" : "translated", "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", @@ -21259,6 +31779,12 @@ "value" : "Wiadomość odebrana z aplikacji do wysyłania wiadomości tekstowych." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение получено из приложения текстовых сообщений." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -21288,12 +31814,24 @@ "Message Size" : { "comment" : "VoiceOver label for message size", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamaño del mensaje" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メッセージサイズ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Размер сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21304,6 +31842,18 @@ }, "Message Status Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskedstatusindstillinger" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de estado del mensaje" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21316,6 +31866,12 @@ "value" : "メッセージ Status Options" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры статуса сообщения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21332,12 +31888,24 @@ }, "Messages" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskeder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Nachrichten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensajes" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -21362,6 +31930,12 @@ "value" : "Wiadomości" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -21390,12 +31964,24 @@ }, "Messages separate with |" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beskeder adskilt med |" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Nachrichten getrennt mit |" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los mensajes se separan con |" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21408,6 +31994,12 @@ "value" : "メッセージs separate with |" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщения разделяются |" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21424,12 +32016,24 @@ }, "Messaging" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensajería" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メッセージング" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обмен сообщениями" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21439,7 +32043,20 @@ } }, "Metric" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metrisk" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métrica" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21452,6 +32069,12 @@ "value" : "メトリック" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метрика" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21467,13 +32090,26 @@ } }, "Midday" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Middag" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Mittag" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mediodía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21486,6 +32122,12 @@ "value" : "正午" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Полдень" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21502,12 +32144,24 @@ }, "Minimum Distance" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimum afstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Minimum Distanz" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distancia mínima" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21520,6 +32174,12 @@ "value" : "最小距離" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минимальное расстояние" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21535,13 +32195,26 @@ } }, "Minimum Interval" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimumsinterval" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Minimum Intervall" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo mínimo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21554,6 +32227,12 @@ "value" : "最小間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минимальный интервал" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21569,7 +32248,20 @@ } }, "Minimum time between detection broadcasts" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimum tid mellem detektionsudsendelser" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo mínimo entre transmisiones de detección" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21582,6 +32274,12 @@ "value" : "検出ブロードキャスト間の最小時間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минимальное время между передачами сообщений об обнаружении" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21598,6 +32296,18 @@ }, "Mininum time between detection broadcasts. Default is 45 seconds." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -21610,6 +32320,12 @@ "value" : "検出ブロードキャスト間の最小時間。デフォルトは45秒です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минимальное время между передачами сообщений об обнаружении. Значение по умолчанию - 45 секунд." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21626,12 +32342,24 @@ }, "Mode" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Modus" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -21662,6 +32390,12 @@ "value" : "Tryb" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -21690,6 +32424,18 @@ }, "Model" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "modelo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21702,6 +32448,12 @@ "value" : "モデル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21723,7 +32475,20 @@ } }, "Moderate" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moderat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "moderado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21736,6 +32501,12 @@ "value" : "中程度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Умеренно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21758,12 +32529,24 @@ }, "Module Configuration" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modulkonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Modul Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -21794,6 +32577,12 @@ "value" : "Konfiguracja modułu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация модуля" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -21821,13 +32610,26 @@ } }, "Morning" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Morgen" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Morgen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mañana" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21840,6 +32642,12 @@ "value" : "朝" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Утро" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21856,12 +32664,24 @@ }, "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", "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", @@ -21892,6 +32712,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", @@ -21920,6 +32746,18 @@ }, "MQTT" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -21932,6 +32770,12 @@ "value" : "MQTT" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -21954,12 +32798,24 @@ }, "MQTT Client Proxy" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT-klientproxy" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "MQTT Client Proxy" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proxy de cliente MQTT" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -21990,6 +32846,12 @@ "value" : "Klient Proxy MQTT" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT клиент-прокси" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22018,12 +32880,24 @@ }, "MQTT Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT-konfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "MQTT Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración MQTT" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -22054,6 +32928,12 @@ "value" : "Konfiguracja MQTT" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка MQTT" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22081,13 +32961,26 @@ } }, "MQTT module config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "MQTT-modulkonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "MQTT Modulkonfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo MQTT recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -22118,6 +33011,12 @@ "value" : "Otrzymano konfigurację modułu MQTT: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля MQTT: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22144,14 +33043,27 @@ } } }, + "mTLS" : {}, "Multiplier" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplikator" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Multiplier" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Multiplicador" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -22176,6 +33088,12 @@ "value" : "Multiplier" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Множитель" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22198,6 +33116,18 @@ }, "Must be a single emoji" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skal være en enkelt emoji" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debe ser un solo emoji" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22210,6 +33140,12 @@ "value" : "単一の絵文字である必要があります" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Должен быть один эмодзи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22225,13 +33161,26 @@ } }, "MyInfo received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "MyInfo modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "MyInfo empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mi información recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -22262,6 +33211,12 @@ "value" : "Otrzymano Moje Informacje: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена информация обо мне: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22291,6 +33246,18 @@ "Nag timeout" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Banke-timeout" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo de espera de molestia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22316,18 +33283,27 @@ } } } - }, - "Nag Timeout" : { - }, "Name" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navn" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Name" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22340,6 +33316,12 @@ "value" : "名前" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22362,12 +33344,24 @@ }, "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", "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", @@ -22380,6 +33374,12 @@ "value" : "名前は30バイト未満である必要があります" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Длина имени должна быть не более 30 байт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22402,6 +33402,18 @@ }, "Navigate to node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rutevejvisning til node" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navegar al nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22414,6 +33426,12 @@ "value" : "ノードに移動" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перейти к ноде" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22430,6 +33448,18 @@ }, "Nearby Topics" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nærliggende emner" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temas cercanos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22442,6 +33472,12 @@ "value" : "近くのトピック" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ближайшие темы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22458,12 +33494,24 @@ }, "Network" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netværk" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Netzwerk" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Red" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -22494,6 +33542,12 @@ "value" : "Sieć" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сеть" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22522,12 +33576,24 @@ }, "Network Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netværkskonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Netzwerkeinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de red" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -22558,6 +33624,12 @@ "value" : "Konfiguracja sieci" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка сети" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22585,13 +33657,26 @@ } }, "Network config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netværkskonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Netzwerkkonfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de red recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -22622,6 +33707,12 @@ "value" : "Odebrano konfigurację sieci: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация сети: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22650,6 +33741,18 @@ }, "Network Status Orange" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netværksstatus Orange" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado de la red naranja" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22662,6 +33765,12 @@ "value" : "ネットワーク状態 オレンジ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус сети: Оранжевый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22684,6 +33793,18 @@ }, "Network Status Red" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netværksstatus rød" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado de la red Rojo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22696,6 +33817,12 @@ "value" : "ネットワーク状態 レッド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус сети: Красный" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22717,7 +33844,20 @@ } }, "New Node" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ny node" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuevo nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22730,6 +33870,12 @@ "value" : "新しいノード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новая нода" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22745,7 +33891,20 @@ } }, "New Node has been discovered" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ny node fundet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se ha descubierto un nuevo nodo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22758,6 +33917,12 @@ "value" : "新しいノードが発見されました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обнаружена новая нода" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22780,6 +33945,18 @@ "value" : "Neue Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuevos nodos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новые ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22789,7 +33966,20 @@ } }, "New Zealand 865MHz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Zealand 865MHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nueva Zelanda 865MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22802,6 +33992,12 @@ "value" : "ニュージーランド 865MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новая Зеландия 865MHz" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22818,12 +34014,24 @@ }, "Newer firmware is available" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nyere firmware er tilgængelig" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Neuere Firmware ist verfügbar" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hay un firmware más nuevo disponible" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22836,6 +34044,12 @@ "value" : "新しいファームウェアが利用可能です" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступна новая версия прошивки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22857,13 +34071,26 @@ } }, "Nighttime" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nattetid" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Nacht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noche" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -22876,6 +34103,12 @@ "value" : "夜間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ночное время" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -22891,13 +34124,26 @@ } }, "NMEA Positions" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "NMEA-positioner" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "NMEA Positionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posiciones NMEA" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -22928,6 +34174,12 @@ "value" : "Pozycje NMEA" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиции NMEA" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -22955,13 +34207,26 @@ } }, "No Channel" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen kanal" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Kanal" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin canal" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -22992,6 +34257,12 @@ "value" : "Brak kanału" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет канала" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23020,12 +34291,24 @@ }, "No Connected Node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Ingen forbundne noder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kein verbundener Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ningún nodo conectado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23038,6 +34321,12 @@ "value" : "接続されたノードがありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет подключенной ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23061,6 +34350,12 @@ "value" : "Keine Daten vorhanden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin datos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23073,6 +34368,12 @@ "value" : "データなし" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет данных" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23095,12 +34396,24 @@ }, "No device connected" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen enhed tilsluttet" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Gerät verbunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ningún dispositivo conectado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -23131,6 +34444,12 @@ "value" : "Brak podłączonych urządzeń" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет подключенного устройства" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23159,6 +34478,18 @@ }, "No Device Metrics" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen enhedsdata" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin métricas de dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23171,6 +34502,12 @@ "value" : "デバイスメトリクスがありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет метрик устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23187,6 +34524,18 @@ }, "No Environment Metrics" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen miljødata" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin métricas ambientales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23199,6 +34548,12 @@ "value" : "環境メトリクスがありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет метрик окружения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23227,6 +34582,18 @@ "value" : "Keine Dateien hochgeladen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se subieron archivos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет загруженных файлов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23236,13 +34603,26 @@ } }, "No Interface" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen grænseflade" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Keine Schnittstelle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin interfaz" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -23273,6 +34653,12 @@ "value" : "Brak interfejsu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет интерфейса" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23306,6 +34692,18 @@ "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", + "value" : "Нет загруженных файлов данных карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23316,6 +34714,18 @@ }, "No PAX Counter Logs" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen PAX-logfiler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin registros de contador de PAX" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23328,6 +34738,12 @@ "value" : "PAXカウンターログがありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет журналов счетчика PAX" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23349,13 +34765,26 @@ } }, "No PIN (Just Works)" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen PIN (Bare fungerer)" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Keine PIN (geht einfach)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin PIN (simplemente funciona)" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -23386,6 +34815,12 @@ "value" : "Brak PINu (po prostu działa)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет PIN (Просто работает)" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23414,12 +34849,24 @@ }, "No Positions" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen positioner" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Keine Positionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin posiciones" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23432,6 +34879,12 @@ "value" : "位置情報がありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет позиций" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23448,6 +34901,18 @@ }, "No Power Metrics" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen energidata" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin métricas de energía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23460,6 +34925,12 @@ "value" : "電力メトリクスがありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет метрик питания" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23478,13 +34949,26 @@ }, "No Response" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intet svar" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Keine Antwort" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin respuesta" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -23515,6 +34999,12 @@ "value" : "Brak odpowiedzi" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет ответа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23542,13 +35032,26 @@ } }, "No Route" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen rute" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Keine Route" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin ruta" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -23579,6 +35082,12 @@ "value" : "Brak trasy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет маршрута" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23607,12 +35116,24 @@ }, "Node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23625,6 +35146,12 @@ "value" : "ノード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нода" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23641,12 +35168,24 @@ }, "Node Core Data Backup %@/%@ - %@ - %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Node Core Data-Backup %1$@/%2$@ - %3$@ - %4$@" + } + }, "en" : { "stringUnit" : { "state" : "translated", "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", @@ -23659,6 +35198,12 @@ "value" : "ノードコアデータバックアップ %1$@/%2$@ - %3$@ - %4$@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Резервное копирование данных ядра ноды %1$@/%2$@ - %3$@ - %4$@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23680,13 +35225,26 @@ } }, "Node does not have positions" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noden er ikke positioneret" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten hat keine Position" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El nodo no tiene posiciones" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23699,6 +35257,12 @@ "value" : "ノードに位置情報がありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нода не имеет позиций" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23715,12 +35279,24 @@ }, "Node History" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodehistorik" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten Historie" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historia del nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23733,6 +35309,12 @@ "value" : "Node 履歴" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23748,7 +35330,20 @@ } }, "Node Info Broadcast Interval" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node Info Broadcast Interval" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de transmisión de información de nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23761,6 +35356,12 @@ "value" : "ノード情報ブロードキャスト間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал передачи информации о ноде" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23777,12 +35378,24 @@ }, "Node Map" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Node-kort" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knotenkarte" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mapa de nodos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23795,6 +35408,12 @@ "value" : "Node マップ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Карта нод" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23811,12 +35430,24 @@ }, "Node Number" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodenummer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knotennummer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Número de nodo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -23829,6 +35460,12 @@ "value" : "ノード番号" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Номер ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -23845,12 +35482,24 @@ }, "Nodes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -23875,6 +35524,12 @@ "value" : "ノード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ноды" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23902,13 +35557,26 @@ } }, "Nodes (%@)" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noder (%@)" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten (%@)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nodos (%@)" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -23939,6 +35607,12 @@ "value" : "Węzły (%@)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ноды (%@)" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -23979,13 +35653,26 @@ }, "None" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Keins" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ninguno" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -24016,6 +35703,12 @@ "value" : "Brak" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24044,6 +35737,18 @@ }, "Not a valid route file" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke en gyldig rute-fil" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No es un archivo de ruta válido" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24056,6 +35761,12 @@ "value" : "有効なルートファイルではありません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не допустимый файл маршрута" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24071,13 +35782,26 @@ } }, "Not Authorized" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen adgangsret" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Nicht authorisiert" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No autorizado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -24108,6 +35832,12 @@ "value" : "Nieautoryzowany" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не авторизовано" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24135,7 +35865,20 @@ } }, "Not Present" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke til stede" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No presente" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -24160,6 +35903,12 @@ "value" : "存在しません" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отсутствует" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24188,12 +35937,24 @@ }, "Notes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noter" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24206,6 +35967,12 @@ "value" : "メモ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заметки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24228,6 +35995,18 @@ "value" : "Mitteilungen für Kanal- und Direktnachrichten." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificaciones por canal y mensajes directos." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления о канале и личных сообщениях." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24244,6 +36023,18 @@ "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", + "value" : "Уведомления о низком заряде батареи подключенного устройства." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24260,6 +36051,18 @@ "value" : "Mitteilungen für neu entdeckte Knoten." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificaciones para nodos recién descubiertos." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления о вновь обнаруженных нодах." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24270,12 +36073,24 @@ }, "Number of hops" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antal hop" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Anzahl Hops" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Número de saltos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24288,6 +36103,12 @@ "value" : "ホップ数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Количество хопов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24304,12 +36125,24 @@ }, "Number of records" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antal poster" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Anzahl Einträge" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Número de registros" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24322,6 +36155,12 @@ "value" : "レコード数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Количество записей" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24338,12 +36177,24 @@ }, "Number of satellites" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antal satellitter" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Anzahl Satelliten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Número de satélites" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24356,6 +36207,12 @@ "value" : "衛星数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Количество спутников" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24372,6 +36229,18 @@ }, "Ok" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ok" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ок" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24382,12 +36251,24 @@ }, "OK" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Ok" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24400,6 +36281,12 @@ "value" : "OK" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24416,6 +36303,18 @@ }, "Ok to MQTT" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK til MQTT" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ok para MQTT" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24428,6 +36327,12 @@ "value" : "MQTT OK" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК для MQTT" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24450,12 +36355,24 @@ }, "OLED Type" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OLED-type" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "OLED Typ" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo OLED" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24468,6 +36385,12 @@ "value" : "OLEDタイプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип OLED" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24489,13 +36412,26 @@ } }, "On Boot Only" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kun ved opstart" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Nur beim Starten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sólo en el arranque" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -24526,6 +36462,12 @@ "value" : "Tylko przy uruchomieniu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только при загрузке" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24554,6 +36496,18 @@ }, "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." + } + }, + "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", @@ -24566,6 +36520,12 @@ "value" : "ライセンス取得者のオンボーディングにはファームウェア2.0.20以上が必要です。必ずお住まいの地域の規制を参照し、疑問がある場合は地域のアマチュア周波数コーディネーターにお問い合わせください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Регистрация для лицензированных операторов требует прошивки версии 2.0.20 или выше. Убедитесь, что соблюдаете местные нормативные акты и обращайтесь к местным координаторам любительских частот с вопросами." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24588,12 +36548,24 @@ }, "One Hour" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Én time" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Eine Stunde" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "una hora" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -24624,6 +36596,12 @@ "value" : "Jedna Godzina" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Один час" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24652,12 +36630,24 @@ }, "One Minute" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Et minut" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Eine Minute" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "un minuto" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -24688,6 +36678,12 @@ "value" : "Jedna Minuta" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Одна минута" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24715,13 +36711,26 @@ } }, "One Second" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ét sekund" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Eine Sekunde" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "un segundo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -24752,6 +36761,12 @@ "value" : "Jedna Sekunda" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Одна секунда" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24780,12 +36795,24 @@ }, "Online" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Online" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Online" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "En línea" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24798,6 +36825,12 @@ "value" : "オンライン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Онлайн" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24813,13 +36846,32 @@ } }, "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." + } + }, + "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", "value" : "SENSOR、TRACKER、TAK_TRACKERロールでのみ許可されており、CLIENT_MUTEロールと同様にすべての再ブロードキャストを抑制します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешено только для ролей SENSOR, TRACKER и TAK_TRACKER, это отключит все повторные передачи, как и роль CLIENT_MUTE." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24835,13 +36887,32 @@ } }, "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo retransmite paquetes desde los portnums principales: NodeInfo, Texto, Posición, Telemetría y Enrutamiento." + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "コアポート番号からのパケットのみ再ブロードキャスト: ノード情報、テキスト、位置、テレメトリ、ルーティング。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторно передает только пакеты с основных портов: NodeInfo, Text, Position, Telemetry и Routing." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24857,16 +36928,41 @@ } }, "Open Compass" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir brújula" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть компас" + } + } + } }, "Open Settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åbn indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Einstellungen öffnen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir configuración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24879,6 +36975,12 @@ "value" : "設定を開く" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть настройки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24900,7 +37002,20 @@ } }, "Optimized for 2 color displays" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optimeret til 2-farve skærme" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optimizado para pantallas de 2 colores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -24913,6 +37028,12 @@ "value" : "2色ディスプレイ用に最適化" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оптимизирован для 2-цветных дисплеев" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -24928,13 +37049,26 @@ } }, "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", "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", @@ -24965,6 +37099,12 @@ "value" : "Optimized for ATAK system communication, reduces routine broadcasts." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оптимизирован для связи с системой ATAK, сокращает регулярные передачи." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -24993,6 +37133,18 @@ }, "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" + } + }, + "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", @@ -25005,6 +37157,12 @@ "value" : "位置メッセージを組み立てる際に含めるオプションフィールド。含めるフィールドが多いほどメッセージが大きくなり、通信時間が長くなってパケット損失のリスクが高くなります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Необязательные поля для включения при составлении сообщений о позиции. Чем больше полей включено, тем больше будет сообщение - что приведет к более длительному времени передачи и более высокому риску потери пакета" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25027,6 +37185,18 @@ }, "Optional GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valgfri GPIO" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO opcional" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25039,6 +37209,12 @@ "value" : "オプション GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дополнительный GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25055,12 +37231,24 @@ }, "Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Optionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -25091,6 +37279,12 @@ "value" : "Opcje" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25117,8 +37311,24 @@ } } }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "OS-logindlægdetaljer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalles de entrada de registro del sistema operativo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25131,6 +37341,12 @@ "value" : "OSログエントリ詳細" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Детали записи журнала ОС" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25147,6 +37363,18 @@ }, "OTA Updates are not supported on this NRF Device." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -25159,6 +37387,12 @@ "value" : "このNRFデバイスではOTA更新はサポートされていません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновления OTA не поддерживаются на этом устройстве NRF." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25181,6 +37415,18 @@ }, "OTA Updates are not supported on your platform." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -25193,6 +37439,12 @@ "value" : "お使いのプラットフォームではOTA更新はサポートされていません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновления OTA не поддерживаются на вашей платформе." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25215,6 +37467,18 @@ }, "Other data sources" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Andre datakilder" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otras fuentes de datos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25227,6 +37491,12 @@ "value" : "その他のデータソース" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Другие источники данных" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25243,12 +37513,24 @@ }, "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", "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", @@ -25261,6 +37543,12 @@ "value" : "シリアル経由でライブデバッグログを出力し、Bluetooth経由で位置情報を削除したデバイスログを表示・エクスポート。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вывод отладочного журнала в реальном времени через последовательный порт, просмотр и экспорт журналов устройства со скрытыми позициями через Bluetooth." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25277,6 +37565,18 @@ }, "Output pin buzzer GPIO " : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output pin buzzer GPIO" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zumbador de pin de salida GPIO " + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25289,6 +37589,12 @@ "value" : "出力ピンブザーGPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выходной контакт зуммера GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25305,6 +37611,18 @@ }, "Output pin GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output pin GPIO" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin de salida GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25317,6 +37635,12 @@ "value" : "出力ピンGPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выходной контакт GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25333,6 +37657,18 @@ }, "Output pin vibra GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Output pin vibra GPIO" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin de salida vibración GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25345,6 +37681,12 @@ "value" : "出力ピン振動GPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выходной контакт вибрации GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25360,7 +37702,20 @@ } }, "Overlanding" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overlanding" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por tierra" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25373,6 +37728,12 @@ "value" : "オーバーランド" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перекрывающий" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25395,6 +37756,18 @@ }, "Override automatic OLED screen detection." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -25407,6 +37780,12 @@ "value" : "自動OLEDスクリーン検出をオーバーライド。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переопределить автоматическое обнаружение OLED-экрана." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25423,6 +37802,18 @@ }, "Override default screen layout." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anular el diseño de pantalla predeterminado." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переопределить макет экрана по умолчанию" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25433,6 +37824,18 @@ }, "Packet Count" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recuento de paquetes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Счетчик пакетов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25451,12 +37854,24 @@ }, "Pairing Mode" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parringstilstand" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Pairing Modus" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo de emparejamiento" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -25487,6 +37902,12 @@ "value" : "Tryb parowania" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим сопряжения" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25515,12 +37936,24 @@ }, "Password" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adgangskode" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Passwort" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contraseña" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -25551,6 +37984,12 @@ "value" : "Hasło" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пароль" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25579,12 +38018,24 @@ }, "Pause" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Pause" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pausa" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -25615,6 +38066,12 @@ "value" : "Pause" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пауза" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25643,6 +38100,18 @@ }, "PAX Counter" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX tæller" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contador de pasajeros" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -25667,6 +38136,12 @@ "value" : "PAX Counter" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Счетчик прохожих" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25695,6 +38170,18 @@ }, "PAX Counter Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX-tæller konfiguration" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del contador PAX" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -25713,6 +38200,12 @@ "value" : "PAXカウンター設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка счетчика прохожих" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25740,7 +38233,20 @@ } }, "PAX Counter config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX-tæller konfiguration modtaget: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del contador PAX recibida: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25753,6 +38259,12 @@ "value" : "PAXカウンター設定を受信しました: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Принята конфигурация счетчика прохожих: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25775,6 +38287,18 @@ }, "PAX Counter Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX-tællerlog" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de contador de PAX" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25787,6 +38311,12 @@ "value" : "PAXカウンターログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал счетчика прохожих" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25808,13 +38338,26 @@ } }, "PAX Counter message received from: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX Counter-besked modtaget fra: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "PAX Counter message received for: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensaje del contador de PAX recibido de: %@" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -25839,6 +38382,12 @@ "value" : "PAX Counter packet received for: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщение счетчика прохожих, полученное от: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -25867,12 +38416,30 @@ }, "paxcounter.log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "paxcounter.log" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "paxcounter.log" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "PAXカウンターログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "paxcounter.log" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25889,12 +38456,24 @@ }, "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", "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", @@ -25907,6 +38486,12 @@ "value" : "接続しているノードの工場出荷時リセットを実行" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выполнить сброс к заводским настройкам на ноде, к которой вы подключены" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25922,7 +38507,20 @@ } }, "Philippines 433MHz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filippinerne 433 MHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filipinas 433MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25935,6 +38533,12 @@ "value" : "フィリピン 433MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Филиппины 433МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25950,7 +38554,20 @@ } }, "Philippines 868MHz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filippinerne 868 MHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filipinas 868MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25963,6 +38580,12 @@ "value" : "フィリピン 868MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Филиппины 868МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -25978,7 +38601,20 @@ } }, "Philippines 915MHz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filippinerne 915MHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filipinas 915MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -25991,6 +38627,12 @@ "value" : "フィリピン 915MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Филиппины 915МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26007,12 +38649,24 @@ }, "Phone GPS" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telefon GPS" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Telefon GPS" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS del teléfono" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -26043,6 +38697,12 @@ "value" : "GPS telefonu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPS телефона" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26077,6 +38737,18 @@ "value" : "Standorteinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ubicación del teléfono" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Местоположение телефона" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26087,6 +38759,18 @@ }, "Pin %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fastgør %lld" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin %lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26099,6 +38783,12 @@ "value" : "ピン %lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26115,6 +38805,18 @@ }, "Pin A" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fastgør A" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin A" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26127,6 +38829,12 @@ "value" : "ピンA" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin A" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26143,6 +38851,18 @@ }, "Pin B" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fastgør B" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin B" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26155,6 +38875,12 @@ "value" : "ピンB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin B" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26171,12 +38897,24 @@ }, "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", "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", @@ -26189,6 +38927,12 @@ "value" : "PKIベースのノード管理、ファームウェアバージョン2.5+が必要" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Администрирование ноды на основе PKI, требуется версия прошивки 2.5+" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26211,12 +38955,30 @@ }, "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." + } + }, + "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", "value" : "マップレポートは暗号化されていないため、あなたのデータが第三者によって永続的に保存・表示される可能性があることをご承知ください。Meshtasticは、このようなデータの保存、表示、開示について一切の責任を負いません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имейте в виду, что поскольку отчет карты не зашифрован, ваши данные могут быть постоянно сохранены и отображены третьими сторонами. Meshtastic не несет ответственности за такое хранение, отображение или раскрытие этих данных." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26233,12 +38995,24 @@ }, "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", "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", @@ -26251,6 +39025,12 @@ "value" : "設定を構成するには無線機に接続してください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пожалуйста, подключитесь к радио для настройки параметров." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26272,13 +39052,26 @@ } }, "Please set a region" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angiv venligst en region" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Bitte lege eine Region fest" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por favor establece una región" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26291,6 +39084,12 @@ "value" : "地域を設定してください" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пожалуйста, укажите регион" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26307,6 +39106,18 @@ }, "Points of Interest" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interessante steder" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puntos de interés" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26319,6 +39130,12 @@ "value" : "興味地点" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Точки интереса" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26334,13 +39151,26 @@ } }, "Poop" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afføring" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kacke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "caca" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -26371,6 +39201,12 @@ "value" : "Kupa" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Какашка" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26397,8 +39233,21 @@ } } }, + "Port" : {}, "Position" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Placering" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posición" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -26423,6 +39272,12 @@ "value" : "Pozycja" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиция" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26445,12 +39300,24 @@ }, "Position Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positionskonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Positionseinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de posición" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -26481,6 +39348,12 @@ "value" : "Konfiguracja pozycji" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка позиции" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26508,6 +39381,7 @@ } }, "Position config received: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -26515,6 +39389,12 @@ "value" : "Positionskonfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de posición recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -26545,6 +39425,12 @@ "value" : "Odebrano konfigurację pozycji: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация позиции: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26567,6 +39453,18 @@ }, "Position Exchange Failed" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Placering udveksling mislykkedes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error en el intercambio de posición" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26579,6 +39477,12 @@ "value" : "位置交換に失敗しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обмен позициями не удался" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26595,6 +39499,18 @@ }, "Position Exchange Requested" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positionsudveksling anmodet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intercambio de posición solicitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26607,6 +39523,12 @@ "value" : "位置交換要求" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запрошен обмен позициями" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26623,6 +39545,18 @@ }, "Position Flags" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positionsflag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Banderas de posición" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26635,6 +39569,12 @@ "value" : "位置フラグ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Флаги позиции" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26651,6 +39591,18 @@ }, "Position Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positionslog" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de posición" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26663,6 +39615,12 @@ "value" : "位置ログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал позиций" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26679,6 +39637,18 @@ }, "Position Log %lld Points" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positionslog %lld punkter" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de posición %lld Puntos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26691,6 +39661,12 @@ "value" : "位置ログ %lld ポイント" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал позиций %lld точек" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26707,6 +39683,18 @@ }, "Position Packet" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Position pakke" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paquete de posición" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26719,6 +39707,12 @@ "value" : "位置パケット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пакет позиции" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26735,12 +39729,24 @@ }, "Position Sent" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Position sendt" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Position gesendet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posición enviada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26753,6 +39759,12 @@ "value" : "位置送信済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиция отправлена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26769,6 +39781,18 @@ }, "Positions Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Positioner aktiveret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posiciones Habilitadas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26781,6 +39805,12 @@ "value" : "位置情報有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиции включены" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26803,6 +39833,18 @@ }, "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." + } + }, + "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", @@ -26815,6 +39857,12 @@ "value" : "位置情報はデバイスのGPSによって提供されます。無効または存在しないを選択した場合は、固定位置を設定できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Позиции будут предоставлены GPS вашего устройства. Если вы выберете отключено или отсутствует, вы можете установить фиксированную позицию." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -26837,12 +39885,24 @@ }, "Power" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strøm" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Strom" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "poder" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -26867,6 +39927,12 @@ "value" : "Power" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Питание" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26895,12 +39961,24 @@ }, "Power Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energikonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Stromkonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de energía" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -26925,6 +40003,12 @@ "value" : "Power Config" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки питания" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26952,7 +40036,20 @@ } }, "Power config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strømkonfiguration modtaget: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de energía recibida: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26965,6 +40062,12 @@ "value" : "電源設定を受信しました: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация питания: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -26986,7 +40089,20 @@ } }, "Power Metrics" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energidata" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Métricas de energía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -26999,6 +40115,12 @@ "value" : "電源メトリクス" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Метрики питания" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27015,6 +40137,18 @@ }, "Power Metrics Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energidata-log" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de métricas de energía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27027,6 +40161,12 @@ "value" : "電力メトリクスログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал метрик питания" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27043,6 +40183,18 @@ }, "Power Off" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluk" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27055,6 +40207,12 @@ "value" : "電源オフ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27072,6 +40230,18 @@ "Power Options" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strømindstillinger" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de energía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27100,12 +40270,24 @@ }, "Power Saving" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strømbesparelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Stromsparen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ahorro de energía" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -27130,6 +40312,12 @@ "value" : "Power Saving" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Энергосбережение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -27158,6 +40346,18 @@ }, "Power Screen" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strøm Skærm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pantalla de energía" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27170,6 +40370,12 @@ "value" : "電源画面" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Питание экрана" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27185,16 +40391,41 @@ } }, "Power Sensor Options" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de sensores de potencia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры питания сенсоров" + } + } + } }, "Powered" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Drevet" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Angeschaltet" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desarrollado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27207,6 +40438,12 @@ "value" : "電源供給中" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Питаемый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27223,12 +40460,24 @@ }, "Precise Location" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Præcis placering" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Genaue Position" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ubicación precisa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27241,6 +40490,12 @@ "value" : "精密位置" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Точное местоположение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27257,12 +40512,24 @@ }, "Presets" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forudindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Voreinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preajustes" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27275,6 +40542,12 @@ "value" : "プリセット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пресеты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27297,6 +40570,18 @@ }, "Press Pin" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk fastgør" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin de prensa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27309,6 +40594,12 @@ "value" : "プレスピン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт нажатия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27325,6 +40616,18 @@ }, "Pressure" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27337,6 +40640,12 @@ "value" : "気圧" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Давление" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27353,12 +40662,24 @@ }, "Primary" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primær" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Primär" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primaria" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -27389,6 +40710,12 @@ "value" : "Podstawowy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Первичный" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -27417,12 +40744,24 @@ }, "Primary Admin Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primær administratørnøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Erster Admin-Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave de administrador principal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27435,6 +40774,12 @@ "value" : "プライマリ管理キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Первичный ключ администратора" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27457,6 +40802,18 @@ }, "Primary GPIO" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Primær GPIO" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPIO primario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27469,6 +40826,12 @@ "value" : "プライマリGPIO" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Первичный GPIO" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27485,12 +40848,24 @@ }, "Private Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privat nøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Privater Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave privada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27503,6 +40878,12 @@ "value" : "秘密キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Первичный ключ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27524,13 +40905,26 @@ } }, "Process" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behandl" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Prozess" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proceso" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -27561,6 +40955,12 @@ "value" : "Process" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Процесс" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -27595,6 +40995,12 @@ "value" : "Datei wird verarbeitet…" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Procesando archivo..." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27607,6 +41013,12 @@ "value" : "ファイル処理中..." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработка файла..." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27629,12 +41041,24 @@ }, "Project information" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Projektinformation" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Projektinformationen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Información del proyecto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27647,6 +41071,12 @@ "value" : "プロジェクト情報" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Информация о проекте" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27668,13 +41098,26 @@ } }, "Protobufs" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protobufs" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Protobufs" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protobufs" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -27705,6 +41148,12 @@ "value" : "Protobufy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Протобуферы" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -27739,6 +41188,18 @@ "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", + "value" : "Предоставлять анонимную статистику использования и отчеты о сбоях." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27749,6 +41210,18 @@ }, "Provide Confirmation" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proporcionar confirmación" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предоставить подтверждение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27759,12 +41232,24 @@ }, "Public Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offentlig nøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Öffentlicher Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave pública" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27777,6 +41262,12 @@ "value" : "公開キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Публичный ключ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27799,6 +41290,18 @@ }, "Public Key Encryption" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offentlig nøglekryptering" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cifrado de clave pública" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27811,6 +41314,12 @@ "value" : "公開鍵暗号化" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Шифрование с открытым ключом" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27833,6 +41342,18 @@ }, "Public Key Mismatch" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offentlig nøgle uoverensstemmelse" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La clave pública no coincide" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27845,6 +41366,12 @@ "value" : "公開キー不一致" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Несоответствие открытого ключа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27867,6 +41394,18 @@ }, "PWD" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "PWD" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "PCD" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27879,6 +41418,12 @@ "value" : "パスワード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "PWD" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27894,13 +41439,26 @@ } }, "Question" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spørgsmål" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Fragezeichen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pregunta" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -27931,6 +41489,12 @@ "value" : "Znak zapytania" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вопрос" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -27959,6 +41523,18 @@ }, "Radiation" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stråling" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radiación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -27971,6 +41547,12 @@ "value" : "放射線" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Излучение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -27987,12 +41569,24 @@ }, "Radio Configuration" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radiokonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Geräteeinstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de radio" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28023,6 +41617,12 @@ "value" : "Konfiguracja radia" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка радио" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28050,13 +41650,26 @@ } }, "RAK Rotary Encoder" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "RAK Rotary Encoder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "RAK Drehimpulsgeber Modul" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Codificador rotatorio RAK" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28087,6 +41700,12 @@ "value" : "Kodera obrotowego RAK" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "RAK поворотный энкодер" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28115,12 +41734,24 @@ }, "Range Test" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rækkeviddetest" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Entfernungstest" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prueba de rango" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28151,6 +41782,12 @@ "value" : "Test zasięgu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тест дальности" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28179,12 +41816,24 @@ }, "Range Test Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration for rækkeviddetest" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Entfernungstest Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de prueba de rango" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28215,6 +41864,12 @@ "value" : "Konfiguracja testu zasięgu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка теста дальности" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28242,13 +41897,26 @@ } }, "Range Test module config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interval Test-modulkonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "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", @@ -28279,6 +41947,12 @@ "value" : "Odebrano konfigurację modułu testu zasięgu: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получение конфигурация модуля теста дальности: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28305,14 +41979,30 @@ } } }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genstart" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Neustart" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28343,6 +42033,12 @@ "value" : "Uruchom ponownie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезагрузка" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28371,12 +42067,24 @@ }, "Reboot node?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genstart node?" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten neustarten?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Reiniciar el nodo?" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28407,6 +42115,12 @@ "value" : "Uruchomić ponownie węzeł?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезагрузить ноду?" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28434,13 +42148,32 @@ } }, "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." + } + }, + "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", "value" : "プライベートチャンネル上、または同じLoRaパラメータを持つ他のメッシュからの観測されたメッセージを再ブロードキャストします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторно передать любое наблюдаемое сообщение, если оно было на нашем приватном канале или из другой сети с такими же параметрами LoRa." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28457,6 +42190,18 @@ }, "Rebroadcast Mode" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genudsendelsestilstand" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo de retransmisión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28469,6 +42214,12 @@ "value" : "再ブロードキャストモード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим повторной передачи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28491,6 +42242,18 @@ }, "Receive data (rxd) GPIO pin" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtage data (rxd) GPIO-pin" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recibir datos (rxd) pin GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28503,6 +42266,12 @@ "value" : "受信データ(RXD)GPIOピン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO приема данных (rxd)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28518,13 +42287,26 @@ } }, "Received a negative acknowledgment" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Modtog en negativ bekræftelse" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Negative Empfangsbestätigung empfangen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recibí un reconocimiento negativo." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28555,6 +42337,12 @@ "value" : "Otrzymano negatywne potwierdzenie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получено отрицательное подтверждение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28584,12 +42372,24 @@ "Received Ack" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtaget kvittering" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Empfangsbestätigung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmación recibida" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28647,17 +42447,42 @@ } }, "Received Ack: %@" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmación recibida: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получено подтверждение: %@" + } + } + } }, "Recipient Ack" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modtagerkvittering" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Recipient Ack" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmación del destinatario" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28715,16 +42540,41 @@ } }, "Recipient Ack: %@" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmación del destinatario: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтверждение получателя: %@" + } + } + } }, "Recording route" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optager rute" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Route aufzeichnen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de grabación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28737,6 +42587,12 @@ "value" : "ルート記録中" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запись маршрута" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28753,6 +42609,18 @@ }, "Refresh device metadata" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdater enhedsmetadata" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizar metadatos del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28765,6 +42633,12 @@ "value" : "デバイスメタデータを更新" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновить метаданные устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28781,12 +42655,24 @@ }, "Regenerate Private Key" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regenerar clave privada" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "プライベートキーを再生成" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Создать заново закрытый ключ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28797,12 +42683,24 @@ }, "Region" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Region" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Región" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28815,6 +42713,12 @@ "value" : "地域" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Регион" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28836,13 +42740,26 @@ } }, "Regional Duty Cycle Limit Reached" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Regional driftcyklusgrænse er nået" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Regionale Einschaltdauergrenze erreicht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se alcanzó el límite del ciclo de trabajo regional" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -28873,6 +42790,12 @@ "value" : "Osiągnięto regionalny limit cyklu pracy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Достигнут региональный лимит рабочего цикла" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -28909,6 +42832,18 @@ "state" : "new", "value" : "Relayed by %1$d %2$@" } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retransmitido por %d %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Передано %1$d %2$@" + } } } }, @@ -28917,6 +42852,18 @@ }, "Release Notes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udgivelsesnoter" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notas de la versión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28929,6 +42876,12 @@ "value" : "リリースノート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Примечания к выпуску" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28943,8 +42896,21 @@ } } }, + "Reload Bundled Certificates" : {}, "Remote administration for: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjernadministration for: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administración remota para: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28957,6 +42923,12 @@ "value" : "%@ のリモート管理" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаленное администрирование для: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -28973,6 +42945,18 @@ }, "Remote Legacy Admin: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern Legacy Admin: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrador remoto heredado: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -28985,6 +42969,12 @@ "value" : "リモートレガシー管理: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаленное администрирование устаревших систем: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29001,6 +42991,18 @@ }, "Remote PKI Admin: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern-PKI-Admin: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrador remoto de PKI: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29013,6 +43015,12 @@ "value" : "リモートPKI管理: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаленное администрирование PKI: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29029,12 +43037,24 @@ }, "Remove" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Entfernen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29047,6 +43067,12 @@ "value" : "削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29063,12 +43089,24 @@ }, "Remove from favorites" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern fra foretrukne" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Von Favoriten entfernen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitar de favoritos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29081,6 +43119,12 @@ "value" : "お気に入りから削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить из избранного" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29097,6 +43141,18 @@ }, "Remove from ignored" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern fra ignoreret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitar de ignorado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29109,6 +43165,12 @@ "value" : "無視リストから削除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить из игнорируемых" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29130,13 +43192,26 @@ } }, "Repeater" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gentager" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Repeater" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "repetidor" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29149,6 +43224,12 @@ "value" : "リピーター" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ретранслятор" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29171,6 +43252,18 @@ }, "Replace Channels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erstat kanaler" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reemplazar canales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29183,6 +43276,12 @@ "value" : "チャンネル置換" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заменить каналы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29199,12 +43298,24 @@ }, "Reply" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Svar" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Antworten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Responder" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -29235,6 +43346,12 @@ "value" : "Odpowiedz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ответить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29263,6 +43380,18 @@ }, "Request Legacy Admin: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmod om administrator (gammel): %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solicitar administrador heredado: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29275,6 +43404,12 @@ "value" : "レガシー管理者要求: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запросить администрирование устаревшей системы: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29295,6 +43430,18 @@ }, "Request PKI Admin: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmod om PKI Admin: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solicitar administrador de PKI: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29307,6 +43454,12 @@ "value" : "PKI管理者要求: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запросить администрирование PKI: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29322,7 +43475,20 @@ } }, "Requested Canned Messages Module Messages for node: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -29353,6 +43519,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", @@ -29381,6 +43553,18 @@ }, "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requiere que haya un acelerómetro en su dispositivo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29393,6 +43577,12 @@ "value" : "デバイスに加速度計が必要です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется наличие акселерометра на вашем устройстве." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29409,12 +43599,24 @@ }, "Reset App Settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nulstil appindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "App-Einstellungen zurücksetzen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer la configuración de la aplicación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29427,6 +43629,12 @@ "value" : "アプリ設定をリセット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сброс настроек приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29449,12 +43657,24 @@ }, "Reset NodeDB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tøm node-database" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knotendatenbank zurücksetzen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer NodeDB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29467,6 +43687,12 @@ "value" : "NodeDBをリセット" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сброс NodeDB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29481,14 +43707,27 @@ } } }, + "Reset to Default" : {}, "Restart" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genstart" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Neustarten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29501,6 +43740,12 @@ "value" : "再起動" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезапуск" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29515,14 +43760,27 @@ } } }, + "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", "value" : "Verbundenen Knoten neustarten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reinicie en el nodo al que está conectado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29535,6 +43793,12 @@ "value" : "接続しているノードを再起動" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перезагрузить ноду, к которой вы подключены" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29551,12 +43815,24 @@ }, "Restore" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurar" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "復元" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстановить" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29567,12 +43843,24 @@ }, "Resume" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genoptag" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Fortsetzen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Currículum" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -29603,6 +43891,12 @@ "value" : "Resume" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29631,6 +43925,18 @@ }, "Retreiving nodes . ." : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recuperando nodos. ." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка нод . ." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29640,7 +43946,20 @@ } }, "Retreiving nodes %lld" : { + "extractionState" : "stale", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recuperando nodos %lld" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка нод: %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29651,6 +43970,18 @@ }, "Retrieving nodes" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recuperando nodos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка нод" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29661,6 +43992,18 @@ }, "Retrieving nodes %lld" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recuperando nodos %lld" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка нод: %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29671,6 +44014,18 @@ }, "Retrying (attempt %lld)" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reintentando (intento %lld)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторная попытка (попытка %lld)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29681,12 +44036,24 @@ }, "Review the app" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gennemgå appen" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "App bewerten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Revisa la aplicación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29699,6 +44066,12 @@ "value" : "アプリをレビュー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оценить приложение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -29720,13 +44093,26 @@ } }, "Right" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Højre" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Rechts" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Derecha" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -29757,6 +44143,12 @@ "value" : "W Prawo" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вправо" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29785,12 +44177,24 @@ }, "Ringtone" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ringetone" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Klingelton" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tono de llamada" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -29821,6 +44225,12 @@ "value" : "Dzwonek" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рингтон" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29849,12 +44259,24 @@ }, "Ringtone Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ringetonekonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Klingelton Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de tono de llamada" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -29879,6 +44301,12 @@ "value" : "Ringtone Config" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка рингтона" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29907,6 +44335,18 @@ }, "Ringtone Transfer Language" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprog til overførsel af ringetoner" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Idioma de transferencia de tono de llamada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -29919,6 +44359,12 @@ "value" : "着信音転送言語" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Язык передачи рингтона" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -29947,6 +44393,18 @@ }, "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" + } + }, + "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", @@ -29971,6 +44429,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", @@ -29999,12 +44463,24 @@ }, "Role" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rolle" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Rolle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rol" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30017,6 +44493,12 @@ "value" : "役割" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Роль" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30032,13 +44514,26 @@ } }, "Role: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rolle: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Rolle: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rol: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30051,6 +44546,12 @@ "value" : "役割: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Роль: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30067,12 +44568,24 @@ }, "Roles" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Roller" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Rollen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Roles" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30085,6 +44598,12 @@ "value" : "役割" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Роли" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30101,6 +44620,18 @@ }, "Root Topic" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hovedemne" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tema raíz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30113,6 +44644,12 @@ "value" : "ルートトピック" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Основная тема" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30129,6 +44666,18 @@ }, "Rotary 1" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rotary 1" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giratorio 1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30141,6 +44690,12 @@ "value" : "ロータリー1" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поворотный 1" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30163,6 +44718,18 @@ }, "Route Back: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Returrute: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de regreso: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30175,6 +44742,12 @@ "value" : "ルート(復路): %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Маршрут обратно: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30191,6 +44764,18 @@ }, "Route Lines" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruteliner" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Líneas de ruta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30203,6 +44788,12 @@ "value" : "ルートライン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Линии маршрута" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30225,6 +44816,18 @@ "value" : "Routenliste" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lista de rutas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Список маршрутов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30235,12 +44838,24 @@ }, "Route Recorder" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruteoptager" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Route aufzeichnen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grabador de ruta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30253,6 +44868,12 @@ "value" : "ルートレコーダー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Записывающее устройство маршрутов" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30269,12 +44890,24 @@ }, "Route recording paused" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruteoptagelse sat på pause" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Routenaufzeichnung pausiert" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grabación de ruta en pausa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30287,6 +44920,12 @@ "value" : "ルート記録を一時停止" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запись маршрута приостановлена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30303,12 +44942,24 @@ }, "Route: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rute: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Route: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30321,6 +44972,12 @@ "value" : "ルート: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Маршрут: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30336,13 +44993,26 @@ } }, "Router" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Router" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Router" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enrutador" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30355,6 +45025,12 @@ "value" : "ルーター" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Маршрутизатор" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30376,13 +45052,26 @@ } }, "Router Late" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Router forsinket" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Router mit Verzögerung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enrutador tarde" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30395,6 +45084,12 @@ "value" : "ルーター遅延" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Маршрутизатор с задержкой" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30411,12 +45106,24 @@ }, "Routes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruter" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Routenliste" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rutas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30429,6 +45136,12 @@ "value" : "ルート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Маршруты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30444,13 +45157,26 @@ } }, "Routing received for RequestID: %@ Ack Status: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruting modtaget for RequestID: %@ Kvitteringsstatus: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "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", @@ -30481,6 +45207,12 @@ "value" : "Odebrano trasowanie dla RequestID: %@ Ack Status: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена маршрутизация для RequestID: %@ Статус подтверждения: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -30509,6 +45241,18 @@ }, "RSSI %@ dBm" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %@ dBm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %@ dBm" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30521,6 +45265,12 @@ "value" : "RSSI %@ dBm" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %@ dBm" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30537,6 +45287,18 @@ }, "RSSI %ddB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %ddB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %ddB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30549,6 +45311,12 @@ "value" : "RSSI %ddB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %ddB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30564,7 +45332,20 @@ } }, "RSSI %llddB" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %llddB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %llddB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30577,6 +45358,12 @@ "value" : "RSSI %llddB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "RSSI %llddB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30592,13 +45379,26 @@ } }, "RTTTL Ringtone config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "RTTTL Ringtone-konfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "RTTTL Klingeltonkonfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de tono de llamada RTTTL recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -30629,6 +45429,12 @@ "value" : "Odebrano konfigurację dzwonka RTTTL: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация мелодии RTTTL: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -30656,13 +45462,32 @@ } }, "Russia" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rusland" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rusia" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ロシア" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Россия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30679,6 +45504,18 @@ }, "RX Boosted Gain" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forstærket RX-forstærkning" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ganancia impulsada por RX" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30691,6 +45528,12 @@ "value" : "RX ブーストゲイン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усиление приема" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30706,13 +45549,32 @@ } }, "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." + } + }, + "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", "value" : "ALLと同じ動作ですが、パケットのデコードをスキップして単純に再ブロードキャストします。リピーター役割でのみ利用可能です。他の役割で設定するとALLの動作になります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Такое же поведение, как ALL, но пропускает декодирование пакета и просто повторно передает его. Доступно только в роли Repeater. Установка этого для любых других ролей приведет к поведению ALL." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30728,13 +45590,26 @@ } }, "Satellite" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Satellit" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Satellit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Satélite" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -30753,6 +45628,12 @@ "value" : "Satelita" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спутник" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -30780,7 +45661,20 @@ } }, "Satellite Flyover" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Satellitoverflyvning" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sobrevuelo satelital" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -30811,6 +45705,12 @@ "value" : "Przelot satelity" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пролёт спутника" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -30839,12 +45739,24 @@ }, "Sats" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sats" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Satelliten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sábados" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30857,6 +45769,12 @@ "value" : "衛星" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спутников" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30873,12 +45791,24 @@ }, "Sats Estimate %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sats anslå %lld" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Satelliten Schätzung %lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estimación de satélites %lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30891,6 +45821,12 @@ "value" : "衛星推定数 %lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оценка спутников %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30907,12 +45843,24 @@ }, "Sats in view: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Satellitter i sigte: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Satelliten in Sicht: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sats a la vista: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -30925,6 +45873,12 @@ "value" : "視野内衛星数: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спутников в зоне видимости: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -30941,12 +45895,24 @@ }, "Save" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Speichern" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -30977,6 +45943,12 @@ "value" : "Zapisz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -31005,6 +45977,18 @@ }, "Save Channel Settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem kanalindstillinger" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar configuración del canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31017,6 +46001,12 @@ "value" : "チャンネル設定を保存" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранение настроек канала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31032,13 +46022,26 @@ } }, "Save Config for %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem konfiguration for %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Speichere Konfiguration für %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar configuración para %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -31069,6 +46072,12 @@ "value" : "Zapisz konfigurację dla %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранение конфигурации для %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -31097,12 +46106,24 @@ }, "Save User Config to %@?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem brugeropsætning på %@?" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Benutzerkonfiguration nach %@ speichern?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Guardar configuración de usuario en %@?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31115,6 +46136,12 @@ "value" : "ユーザー設定を %@ に保存しますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить конфигурацию пользователя в %@?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31137,6 +46164,18 @@ }, "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" + } + }, + "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", @@ -31149,6 +46188,12 @@ "value" : "レンジテストメッセージの詳細をCSVで保存します。現在、Webサーバーを持つESP32デバイスでのみ利用可能です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохраняет CSV с деталями сообщений тестирования дальности, в настоящее время доступно только на устройствах ESP32 с веб-сервером." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31171,12 +46216,24 @@ }, "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", "value" : "このQRコードをスキャンして、%@ を別のデバイスに追加してください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отсканируйте этот QR-код, чтобы добавить %@ на другое устройство." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31193,6 +46250,18 @@ }, "Screen on for" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Skærm tændt i " + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pantalla encendida para" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31205,6 +46274,12 @@ "value" : "画面オン時間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экран включен на" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31221,12 +46296,24 @@ }, "Search" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Søg" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Suchen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31239,6 +46326,12 @@ "value" : "検索" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31255,6 +46348,18 @@ }, "Second" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sekund" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "segundo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31267,6 +46372,12 @@ "value" : "秒" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Второй" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31283,12 +46394,24 @@ }, "Secondary" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sekundær" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sekundär" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secundaria" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -31319,6 +46442,12 @@ "value" : "Wtórny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Второй" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -31347,12 +46476,24 @@ }, "Secondary Admin Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sekundær administratortasten" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zweiter Admin-Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave de administrador secundario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31365,6 +46506,12 @@ "value" : "副管理者キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Второй ключ админа" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31385,14 +46532,32 @@ } } }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sikkerhed" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sicherheit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguridad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31405,6 +46570,12 @@ "value" : "セキュリティ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Безопасность" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31427,12 +46598,24 @@ }, "Security Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sikkerhedsindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sicherheitskonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de seguridad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31445,6 +46628,12 @@ "value" : "セキュリティ設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки безопасности" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31467,12 +46656,24 @@ }, "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", "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", @@ -31485,6 +46686,12 @@ "value" : "セキュリティ設定にはファームウェアバージョン2.5以上が必要です" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки безопасности требуют версии прошивки 2.5+" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31506,13 +46713,26 @@ } }, "Select" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Auswählen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccionar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -31543,6 +46763,12 @@ "value" : "Wybierz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -31571,12 +46797,24 @@ }, "Select a channel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg en kanal" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kanal wählen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione un canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31589,6 +46827,12 @@ "value" : "チャンネルを選択" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбрать канал" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31605,6 +46849,18 @@ }, "Select a conversation" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg en samtale" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione una conversación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31617,6 +46873,12 @@ "value" : "会話を選択" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите беседу" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31633,6 +46895,18 @@ }, "Select a conversation type" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg en samtaletype" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione un tipo de conversación" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31645,6 +46919,12 @@ "value" : "会話タイプを選択" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите тип беседы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31660,16 +46940,47 @@ } }, "Select a Node" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione un nodo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите ноду" + } + } + } }, "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione un nodo del menú desplegable para administrar dispositivos conectados o remotos." + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ドロップダウンからノードを選択して、接続済みまたはリモートデバイスを管理してください。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите ноду из выпадающего списка для управления подключенными или удаленными устройствами." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31686,6 +46997,18 @@ }, "Select a Trace Route" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Vælg en rutesporing (Trace route)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione una ruta de seguimiento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31698,6 +47021,12 @@ "value" : "トレースルートを選択" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите маршрут трассировки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31713,10 +47042,35 @@ } }, "Select an emoji" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecciona un emoji" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите эмодзи" + } + } + } }, "Select Channel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg kanal" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccionar canal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31729,6 +47083,12 @@ "value" : "チャンネル選択" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите канал" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31751,6 +47111,18 @@ "value" : "Datei auswählen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccionar archivo de mapa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите файл карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31767,6 +47139,18 @@ "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", + "value" : "Выберите пакеты, отправленные как критические, они будут игнорировать переключатель отключения звука и настройки «Не беспокоить» в центре уведомлений ОС." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31777,12 +47161,24 @@ }, "Send" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Senden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31795,6 +47191,12 @@ "value" : "送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31811,12 +47213,24 @@ }, "Send ${messageContent} to ${channelNumber}" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send ${messageContent} til ${channelNumber}" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sende ${messageContent} an ${channelNumber}" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar ${messageContent} a ${channelNumber}" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31829,6 +47243,12 @@ "value" : "${messageContent} をチャンネル ${channelNumber} に送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить ${messageContent} в ${channelNumber}" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31845,6 +47265,18 @@ }, "Send ${messageContent} to ${nodeNumber}" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send Send ${messageContent} til ${nodeNumber}" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar ${messageContent} a ${nodeNumber}" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31857,6 +47289,12 @@ "value" : "${messageContent} をノード ${nodeNumber} に送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить ${messageContent} на ${nodeNumber}" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31873,6 +47311,24 @@ }, "Send a Direct Message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send en direkte besked" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar un mensaje directo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить личное сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31883,12 +47339,24 @@ }, "Send a Group Message" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send en gruppebesked" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gruppennachricht senden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar un mensaje grupal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -31901,6 +47369,12 @@ "value" : "グループメッセージを送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить групповое сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31917,6 +47391,18 @@ }, "Send a heartbeat to advertise the server's presence." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -31929,6 +47415,12 @@ "value" : "サーバーの存在を通知するためのハートビートを送信します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить heartbeat для оповещения о присутствии сервера." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31945,6 +47437,18 @@ }, "Send a message to a certain meshtastic channel" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -31957,6 +47461,12 @@ "value" : "特定のMeshtasticチャンネルにメッセージを送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить сообщение на определенный канал Meshtastic" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31973,6 +47483,24 @@ }, "Send a message to a certain meshtastic node" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", + "value" : "Отправить сообщение на определенную ноду Meshtastic" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -31983,6 +47511,18 @@ }, "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." + } + }, + "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", @@ -31995,6 +47535,12 @@ "value" : "ユーザーボタンが3回クリックされたときにプライマリチャンネルで位置を送信します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить позицию на первичном канале при тройном нажатии пользовательской кнопки." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32017,12 +47563,24 @@ }, "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", "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", @@ -32035,6 +47593,12 @@ "value" : "接続しているノードにシャットダウン信号を送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправьте сообщение о завершении работы на ноду, к которой вы подключены" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32051,12 +47615,24 @@ }, "Send a Waypoint" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send et viapunkt" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Wegpunkt senden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar un punto de referencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32069,6 +47645,12 @@ "value" : "ウェイポイントを送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить путевую точку" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32085,6 +47667,18 @@ }, "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." + } + }, + "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", @@ -32097,6 +47691,12 @@ "value" : "アラートメッセージ付きASCIIベルを送信。ベルでの外部通知のトリガーに便利です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправьте ASCII колокольчик с предупреждающим сообщением. Полезно для срабатывания внешнего уведомления на колокольчик." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32119,12 +47719,24 @@ }, "Send Bell" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Send ASCII-klokke" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sende Glocke" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar campana" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32137,6 +47749,12 @@ "value" : "ベル送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить колокол" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32159,12 +47777,24 @@ }, "Send Heartbeat" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send hjerteslag" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Herzschlag senden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar latido" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32195,6 +47825,12 @@ "value" : "Send Heartbeat" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить сердцебиение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32229,6 +47865,18 @@ "value" : "Mitteilungen senden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar notificaciones" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить уведомления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32239,6 +47887,18 @@ }, "Send Reboot OTA" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send genstart OTA" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar Reiniciar OTA" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32251,6 +47911,12 @@ "value" : "OTA再起動送信" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить перезагрузку OTA" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32272,7 +47938,20 @@ } }, "Sender Interval" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afsenderinterval" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo del remitente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32285,6 +47964,12 @@ "value" : "送信者間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал отправителя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32306,13 +47991,26 @@ } }, "Sensor" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensor" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sensor" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sensores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32325,6 +48023,12 @@ "value" : "センサー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Датчик" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32347,6 +48051,18 @@ }, "Sensor options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensorindstillinger" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de sensores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32359,6 +48075,12 @@ "value" : "センサーオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры датчика" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32376,6 +48098,18 @@ "Sensor Options" : { "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensorindstillinger" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de sensores" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32403,7 +48137,20 @@ } }, "Sent a Channel for: %@ Channel Index %d" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -32434,6 +48181,12 @@ "value" : "Wysłano kanał dla: %@ Indeks kanału %d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлен канал для: %@ Индекс канала %d" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32461,13 +48214,26 @@ } }, "Sent a LoRa.Config for: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sendte en LoRa.Config for: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "LoRa.Config gesendet für: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envió un LoRa.Config para: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32498,6 +48264,12 @@ "value" : "Wysłano konfigurację LoRa dla: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлена конфигурация LoRa для: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32527,12 +48299,24 @@ "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", "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", @@ -32563,6 +48347,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", @@ -32590,13 +48380,26 @@ } }, "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", "value" : "Sende Traceroute Anforderung zu Knoten: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envió una solicitud de ruta de seguimiento al nodo: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32627,6 +48430,12 @@ "value" : "Wysłano żądanie śledzenia trasy do węzła: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлен запрос трассировки маршрута на ноду: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32654,13 +48463,26 @@ } }, "Sent a Waypoint Packet from: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viapunkt-pakke afsendt fra: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Wegpunkt gesendet von: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviado un paquete de waypoint desde: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32691,6 +48513,12 @@ "value" : "Wysłano pakiet punktu orientacyjnego z: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлен пакет путевой точки от: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32718,13 +48546,26 @@ } }, "Sent message %@ from %@ to %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sendte besked %@ fra %@ til %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sende Nachricht %@ von %@ an %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensaje enviado %@ de %@ a %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32755,6 +48596,12 @@ "value" : "Wysłano wiadomość %@ od %@ do %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправлено сообщение %@ от %@ к %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32783,12 +48630,24 @@ }, "Sequence number" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sekvensnummer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sequenznummer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Número de secuencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32801,6 +48660,12 @@ "value" : "シーケンス番号" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Порядковый номер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32817,12 +48682,24 @@ }, "Sequence: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sekvens: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sequenz: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secuencia: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32835,6 +48712,12 @@ "value" : "シーケンス: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последовательность: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -32851,6 +48734,18 @@ }, "Serial" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seriel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serie" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32881,6 +48776,12 @@ "value" : "Seryjny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последовательный порт" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32909,12 +48810,24 @@ }, "Serial Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seriel-konfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Serial Konfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración en serie" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -32945,6 +48858,12 @@ "value" : "Konfiguracja seryjna" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка последовательного порта" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -32973,12 +48892,24 @@ }, "Serial Console" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seriel-konsol" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Serielle Konsole" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consola serie" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -32991,6 +48922,12 @@ "value" : "シリアルコンソール" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Консоль последовательного порта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33007,12 +48944,24 @@ }, "Serial Console over the Stream API." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seriekonsol over Stream-API'en." + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Serielle Konsole über die Stream-API." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consola serial a través de Stream API." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33025,6 +48974,12 @@ "value" : "Stream API経由のシリアルコンソール。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Консоль последовательного порта через Stream API." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33040,13 +48995,26 @@ } }, "Serial module config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seriemodulkonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Serial Modul Konfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo serie recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -33077,6 +49045,12 @@ "value" : "Odebrano konfigurację modułu szeregowego: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля последовательного порта: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33105,6 +49079,18 @@ }, "Series" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serier" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serie" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33117,6 +49103,12 @@ "value" : "系列" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Серии" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33133,12 +49125,24 @@ }, "Server" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Server" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servidor" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33151,6 +49155,12 @@ "value" : "サーバー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сервер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33167,12 +49177,24 @@ }, "Server Address" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serveradresse" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Serveradresse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dirección del servidor" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33185,6 +49207,12 @@ "value" : "サーバーアドレス" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Адрес сервера" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33199,8 +49227,21 @@ } } }, + "Server Certificate" : {}, "Server Option" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serverindstilling" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opción de servidor" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33213,6 +49254,12 @@ "value" : "サーバーオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры сервера" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33227,8 +49274,21 @@ } } }, + "Server Status" : {}, "Set" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indstil" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conjunto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33241,6 +49301,12 @@ "value" : "設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устан" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33257,12 +49323,24 @@ }, "Set LoRa Region" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg LoRa-region" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Setze LoRa Region" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Establecer región LoRa" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -33293,6 +49371,12 @@ "value" : "Ustaw region LoRa" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить регион LoRa" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33321,6 +49405,18 @@ }, "Set the GPIO pins for RXD and TXD." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -33333,6 +49429,12 @@ "value" : "RXDとTXDのGPIOピンを設定します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установите контакты GPIO для RXD и TXD." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33349,12 +49451,24 @@ }, "Set to current location" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Establecer en la ubicación actual" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "現在の位置に設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Установить текущее местоположение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33363,8 +49477,20 @@ } } }, - "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" : { + "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." + } + }, + "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", @@ -33377,6 +49503,12 @@ "value" : "最大ホップ数を設定します。デフォルトは3です。ホップ数を増やすと輻輳も増加するため、慎重に使用してください。0ホップのブロードキャストメッセージはACKを受信しません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устанавливает максимальное количество хопов, по умолчанию 3. Увеличение хопов также увеличивает перегрузку и должно использоваться осторожно. Широковещательные сообщения с 0 хопов не получат подтверждений." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33393,12 +49525,24 @@ }, "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", "value" : "画面の時計表示を12時間形式に設定します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устанавливает 12-часовой формат экранных часов." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33415,6 +49559,18 @@ "value" : "Einstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ajustes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "настройки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33425,12 +49581,24 @@ }, "Settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Einstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -33461,6 +49629,12 @@ "value" : "Ustawienia" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33488,13 +49662,26 @@ } }, "Seventy Two Hours" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tooghalvfjerds timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zweiundsiebzig Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setenta y dos horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -33525,6 +49712,12 @@ "value" : "Siedemdziesiąt Dwie Godziny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Семьдесят два часа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33553,12 +49746,24 @@ }, "Share Contact QR" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir Contacto QR" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "連絡先QRを共有" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться QR контакта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33581,6 +49786,18 @@ "value" : "Standort teilen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir ubicación" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться местоположением" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33591,12 +49808,24 @@ }, "Share QR Code" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Del QR-kode" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kanal QR Code teilen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir código QR" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -33627,6 +49856,12 @@ "value" : "Udostępnij kod QR kanałów" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться QR-кодом" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33655,12 +49890,24 @@ }, "Share QR Code & Link" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Del QR-kode og link" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "QR Code & Link teilen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir código QR y enlace" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33673,6 +49920,12 @@ "value" : "QRコードとリンクを共有" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поделиться QR-кодом и ссылкой" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33695,6 +49948,18 @@ "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", + "value" : "Делитесь своим местоположением в реальном времени и поддерживайте координацию группы с интегрированными функциями GPS." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33705,12 +49970,24 @@ }, "Shared Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fælles krypteringsnøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gemeinsamer Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave compartida" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33723,6 +50000,12 @@ "value" : "共有キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Общий ключ" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33739,12 +50022,24 @@ }, "Sharing Meshtastic Channels" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deling af Meshtastic-kanaler" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Meshtastic Kanäle teilen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir canales Meshtastic" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -33775,6 +50070,12 @@ "value" : "Sharing Meshtastic Channels" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Совместное использование каналов Meshtastic" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -33803,12 +50104,24 @@ }, "Short Name" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kort navn" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Kurzname" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre corto" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33821,6 +50134,12 @@ "value" : "短い名前" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Короткое имя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33842,7 +50161,20 @@ } }, "Short Range - Fast" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kort Rækkevidde - Hurtig" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corto alcance - Rápido" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33855,6 +50187,12 @@ "value" : "短距離 - 高速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Short Range - Fast" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33870,7 +50208,20 @@ } }, "Short Range - Slow" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kort rækkevidde - Langsom" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corto alcance - Lento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33883,6 +50234,12 @@ "value" : "短距離 - 低速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Short Range - Slow" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33898,7 +50255,20 @@ } }, "Short Range - Turbo" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Kort rækkevidde - Turbo" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corto alcance - Turbo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33911,6 +50281,12 @@ "value" : "短距離 - ターボ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Short Range - Turbo" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33927,6 +50303,18 @@ }, "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", + "value" : "Показать диалог подтверждения перед выполнением сброса к заводским настройкам" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33937,12 +50325,24 @@ }, "Show alerts" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis alarmer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zeige Alarme" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar alertas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33955,6 +50355,12 @@ "value" : "アラート表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать оповещения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -33971,12 +50377,24 @@ }, "Show Alerts" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis alarmer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zeige Alarme" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar alertas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -33989,6 +50407,12 @@ "value" : "アラート表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать оповещения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34005,12 +50429,24 @@ }, "Show nodes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis noder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zeige Knoten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar nodos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34023,6 +50459,12 @@ "value" : "ノード表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать ноды" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34039,12 +50481,24 @@ }, "Show on device screen" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis på enhedsskærm" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zeige auf dem Gerätebildschirm" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar en la pantalla del dispositivo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34057,6 +50511,12 @@ "value" : "デバイス画面に表示" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать на экране устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34073,12 +50533,24 @@ }, "Show on the mesh map." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vis på mesh-kort" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zeige auf der Netzwerkkarte." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar en el mapa de malla." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34091,6 +50563,12 @@ "value" : "メッシュマップに表示。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать на карте сети." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34114,6 +50592,12 @@ "value" : "Zeige Wegpunkte" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar puntos de ruta" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34142,6 +50626,18 @@ }, "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", + "value" : "Показывает информацию о подключенном радио LoRa. Вы можете провести влево, чтобы отключить радио, и нажать долго, чтобы запустить текущую активность." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34152,12 +50648,24 @@ }, "Shut Down" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luk ned" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Herunterfahren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34170,6 +50678,12 @@ "value" : "シャットダウン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34186,12 +50700,24 @@ }, "Shut Down Node?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluk node?" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten herunterfahren?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Cerrar el nodo?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34204,6 +50730,12 @@ "value" : "ノードをシャットダウンしますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключить ноду?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34220,12 +50752,24 @@ }, "Shutdown Node?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluk node?" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Knoten herunterfahren?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Apagar el nodo?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34238,6 +50782,12 @@ "value" : "ノードをシャットダウンしますか?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключить ноду?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34254,12 +50804,24 @@ }, "Shutdown on Power Loss" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luk ned ved strømtab" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Herunterfahren bei Stromunterbruch" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagado por pérdida de energía" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -34284,6 +50846,12 @@ "value" : "Shutdown on Power Loss" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключение при потере питания" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -34312,6 +50880,18 @@ }, "Signal %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signal %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Señal %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34324,6 +50904,12 @@ "value" : "信号 %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сигнал %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34339,13 +50925,26 @@ } }, "Simple" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enkel" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Einfach" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sencillo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -34376,6 +50975,12 @@ "value" : "Prosty" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Простой" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -34403,7 +51008,20 @@ } }, "Singapore 923MHz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Singapore 923 MHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Singapur 923MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34416,6 +51034,12 @@ "value" : "シンガポール 923MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сингапур 923 МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34431,13 +51055,26 @@ } }, "Six Hours" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seks timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sechs Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seis horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -34468,6 +51105,12 @@ "value" : "Sześć Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Шесть часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -34495,13 +51138,26 @@ } }, "Skiing" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skiløb" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Skifahren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "esquiar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34514,6 +51170,12 @@ "value" : "スキー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Катание на лыжах" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34536,6 +51198,18 @@ }, "Smart Position" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Smart Position" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posición inteligente" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34548,6 +51222,12 @@ "value" : "スマート位置" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Умное позиционирование" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34564,6 +51244,18 @@ }, "SNR" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34576,6 +51268,12 @@ "value" : "SNR" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34592,6 +51290,18 @@ }, "SNR %@ dB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@ dB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@ dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34604,6 +51314,12 @@ "value" : "SNR %@ dB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@ dB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34620,6 +51336,18 @@ }, "SNR %@dB" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@dB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@dB" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34632,6 +51360,12 @@ "value" : "SNR %@dB" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SNR %@dB" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34648,6 +51382,18 @@ }, "Soil Moisture" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jordfugtighed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Humedad del suelo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34660,6 +51406,12 @@ "value" : "土壌水分" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Влажность почвы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34676,6 +51428,18 @@ }, "Soil Temp" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jordtemperatur" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperatura del suelo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34688,6 +51452,12 @@ "value" : "土壌温度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Температура почвы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34705,6 +51475,18 @@ "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Especifica cuánto tiempo debe emitir el GPIO monitoreado." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34733,12 +51515,24 @@ }, "Speed" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hastighed" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Geschwindigkeit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34751,6 +51545,12 @@ "value" : "速度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34767,12 +51567,24 @@ }, "Speed %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hastighed %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Geschwindigkeit %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidad %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34785,6 +51597,12 @@ "value" : "速度 %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34801,12 +51619,24 @@ }, "Speed: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hastighed: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Geschwindigkeit: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidad: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34819,6 +51649,12 @@ "value" : "速度: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34841,12 +51677,24 @@ "value" : "App-Entwicklung unterstützen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desarrollo de aplicaciones para patrocinadores" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "アプリ開発をスポンサー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Спонсор разработки приложения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34857,6 +51705,18 @@ }, "Spread Factor" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spredningsfaktor" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Factor de dispersión" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -34869,6 +51729,12 @@ "value" : "拡散係数" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Коэф. распространения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34885,12 +51751,24 @@ }, "SSID" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSID" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "SSID" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSID" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -34921,6 +51799,12 @@ "value" : "SSID" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSID" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -34948,7 +51832,20 @@ } }, "Standard" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estándar" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -34973,6 +51870,12 @@ "value" : "Standardowy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стандартный" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -34994,7 +51897,20 @@ } }, "Standard Muted" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard dæmpet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estándar silenciado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -35025,6 +51941,12 @@ "value" : "Standardowy wyłączony" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Стандартный без звука" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35053,12 +51975,24 @@ }, "Start" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Start" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empezar" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -35089,6 +52023,12 @@ "value" : "Start" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начало" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35116,7 +52056,20 @@ } }, "State Broadcast Interval" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "State Broadcast Interval" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de transmisión estatal" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35129,6 +52082,12 @@ "value" : "状態ブロードキャスト間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал передачи состояния" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35143,6 +52102,7 @@ } } }, + "Status" : {}, "Stay Connected Anywhere" : { "localizations" : { "de" : { @@ -35151,6 +52111,18 @@ "value" : "Überall in Verbindung bleiben" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manténgase conectado en cualquier lugar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оставайтесь на связи везде" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35161,6 +52133,18 @@ }, "Store & Forward" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem og videresend" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Almacenar y reenviar" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35173,6 +52157,12 @@ "value" : "蓄積転送" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить и переслать" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35195,6 +52185,18 @@ }, "Store & Forward Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurer Opbevaring og Videreformidling" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Almacenar y reenviar configuración" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35207,6 +52209,12 @@ "value" : "蓄積転送設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка сохранения и пересылки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35228,7 +52236,20 @@ } }, "Store & Forward module config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Store & Forward-modulkonfiguration modtaget: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo Store & Forward recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -35259,6 +52280,12 @@ "value" : "Store & Forward module config received: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля сохранения и пересылки: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35287,6 +52314,18 @@ }, "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" + } + }, + "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", @@ -35299,6 +52338,12 @@ "value" : "蓄積転送サーバーには、PSRAM搭載のESP32デバイスまたはLinux Nativeが必要です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Серверы сохранения и пересылки требуют устройство ESP32 с PSRAM или Linux Native." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35315,6 +52360,18 @@ }, "Subscribed" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abonneret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suscrito" + } + }, "he" : { "stringUnit" : { "state" : "translated", @@ -35333,6 +52390,12 @@ "value" : "購読済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подписан" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35348,7 +52411,20 @@ } }, "Subsystem" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Undersystem" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subsistema" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35361,6 +52437,12 @@ "value" : "サブシステム" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подсистема" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35389,6 +52471,18 @@ "value" : "Successfully uploaded '%1$@' with %2$lld overlays" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' subido correctamente con superposiciones %lld" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Успешно загружено '%1$@' с заменой %2$lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35399,12 +52493,24 @@ }, "Supported" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Understøttet" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Unterstützt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apoyado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35417,6 +52523,12 @@ "value" : "サポート済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поддерживаемый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35433,6 +52545,18 @@ }, "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." + } + }, + "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", @@ -35445,6 +52569,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", @@ -35466,7 +52596,20 @@ } }, "Table" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesa" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35479,6 +52622,12 @@ "value" : "テーブル" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Таблица" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35494,13 +52643,32 @@ } }, "Taiwan" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taiwan" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taiwán" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "台湾" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тайвань" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35517,12 +52685,24 @@ }, "TAK" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAK" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "TAK" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAK" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35535,6 +52715,12 @@ "value" : "TAK" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAK" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35554,15 +52740,30 @@ } } } + }, + "TAK Server" : {}, "TAK Tracker" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAK-sporer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "TAK Tracker" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rastreador TAK" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35575,6 +52776,12 @@ "value" : "TAKトラッカー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "TAK трекер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35597,6 +52804,18 @@ }, "Takes a Meshtastic channel URL and saves the channel settings." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -35609,6 +52828,12 @@ "value" : "MeshtasticチャンネルURLを取得し、チャンネル設定を保存します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Принимает URL канала Meshtastic и сохраняет настройки канала." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35625,12 +52850,24 @@ }, "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", "value" : "Meshtastic連絡先URLを取得し、ノードデータベースに保存します" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Принимает URL контакта Meshtastic и сохраняет его в базу данных нод" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35646,16 +52883,41 @@ } }, "Tap to enter emoji" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca para ingresar emoji" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тапните для ввода эмодзи" + } + } + } }, "Tapback" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tapback" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Tapback Antwort" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tapback" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -35686,6 +52948,12 @@ "value" : "Odpowiedź na stuknięcie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обратная связь" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35712,17 +52980,26 @@ } } }, - "TCP" : { - "shouldTranslate" : false - }, "Telemetry" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telemetri" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Telemetrie (Sensoren)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telemetria" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -35753,6 +53030,12 @@ "value" : "Telemetria (czujniki)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Телеметрия" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35781,12 +53064,24 @@ }, "Telemetry Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telemetrikonfiguration" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Telemetrie Einstellungen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de telemetría" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -35817,6 +53112,12 @@ "value" : "Konfiguracja telemetrii" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки телеметрии" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35844,13 +53145,26 @@ } }, "Telemetry module config received: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telemetrimodulkonfiguration modtaget: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Telemetrie Modul Konfiguration empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración del módulo de telemetría recibida: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -35881,6 +53195,12 @@ "value" : "Odebrano konfigurację modułu telemetrii: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Получена конфигурация модуля телеметрии: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -35908,13 +53228,26 @@ } }, "Temp" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temp" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Temp" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "temperatura" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35927,6 +53260,12 @@ "value" : "温度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Темп" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35943,12 +53282,24 @@ }, "Temperature" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperatur" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Temperatur" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperatura" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -35961,6 +53312,12 @@ "value" : "温度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Температура" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -35977,12 +53334,24 @@ }, "Ten Minutes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ti minutter" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zehn Minuten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "diez minutos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36013,6 +53382,12 @@ "value" : "Dziesięć Minut" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Десять минут" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -36040,13 +53415,26 @@ } }, "Ten Seconds" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ti sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zehn Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diez segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36077,6 +53465,12 @@ "value" : "Dziesięć Sekund" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Десять секунд" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -36105,12 +53499,24 @@ }, "Tertiary Admin Key" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tertiær admin-nøgle" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Dritter Admin-Schlüssel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave de administrador terciario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36123,6 +53529,12 @@ "value" : "第三管理者キー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Третичный ключ администратора" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36144,13 +53556,26 @@ } }, "Text Message" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "SMS-besked" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Textnachricht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mensaje de texto" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36181,6 +53606,12 @@ "value" : "Wiadomość tekstowa" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текстовое сообщение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -36208,7 +53639,20 @@ } }, "TFT Full Color Displays" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TFT-farvedisplays" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pantallas TFT a todo color" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36221,6 +53665,12 @@ "value" : "TFTフルカラーディスプレイ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Полноцветные TFT-дисплеи" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36236,7 +53686,20 @@ } }, "Thailand" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thailand" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tailandia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36249,6 +53712,12 @@ "value" : "タイ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тайланд" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36265,6 +53734,18 @@ }, "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." + } + }, + "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", @@ -36277,6 +53758,12 @@ "value" : "パケットが完了したと見なすまでの待機時間。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время ожидания, прежде чем мы сочтем ваш пакет завершенным." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36293,6 +53780,18 @@ }, "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." + } + }, + "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", @@ -36305,6 +53804,12 @@ "value" : "画面上の円の外側にあるコンパスの方位は常に北を指します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Направление компаса на экране вне круга всегда будет указывать на север." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36321,12 +53826,24 @@ }, "The dew point is %@ right now." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dugpunktet er %@ lige nu." + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Der Taupunkt ist gerade %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El punto de rocío es %@ en este momento." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36339,6 +53856,12 @@ "value" : "現在の露点は %@ です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Точка росы: %@." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36355,6 +53878,18 @@ }, "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." + } + }, + "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", @@ -36367,6 +53902,12 @@ "value" : "最小距離条件が満たされた場合の位置更新送信の最短間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимальная частота, с которой будут отправлены обновления позиции, если минимальное расстояние соблюдено" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36383,12 +53924,24 @@ }, "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", "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", @@ -36401,6 +53954,12 @@ "value" : "デバイスのBLE名を設定するため、MACアドレスの末尾4桁が短縮名に追加されます。短縮名は最大4バイトまでです。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последние 4 символа MAC-адреса устройства будут добавлены к короткому имени для установки имени BLE устройства. Короткое имя может быть длиной до 4 байт." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36423,6 +53982,18 @@ }, "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" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El intervalo máximo que puede transcurrir sin que un nodo transmita una posición." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36435,6 +54006,12 @@ "value" : "ノードが位置をブロードキャストしない最大間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимальный интервал, который может пройти без передачи нодой позиции" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36451,6 +54028,18 @@ }, "The Meshtastic Apple apps support firmware version %@ and above." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -36463,6 +54052,12 @@ "value" : "Meshtastic Appleアプリはファームウェアバージョン %@ 以上をサポートしています。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приложения Meshtastic для Apple поддерживают версию прошивки %@ и выше." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36485,6 +54080,18 @@ }, "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." + } + }, + "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", @@ -36497,6 +54104,12 @@ "value" : "スマート位置ブロードキャストで考慮される最小距離変化(メートル)。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Минимальное изменение расстояния в метрах, которое будет учитываться для интеллектуальной передачи позиции." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36518,13 +54131,26 @@ } }, "The packet is too large" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pakken er for stor" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Das Paket ist zu groß" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El paquete es demasiado grande." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36555,6 +54181,12 @@ "value" : "Pakiet jest zbyt duży" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пакет слишком большой" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -36583,12 +54215,24 @@ }, "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", "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", @@ -36601,6 +54245,12 @@ "value" : "このノードに管理メッセージを送信する権限を持つプライマリ公開キー。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Основной открытый ключ, авторизованный для отправки административных сообщений этой ноде." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36623,12 +54273,24 @@ }, "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", "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", @@ -36641,6 +54303,12 @@ "value" : "無線機を使用する地域。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Регион, в котором вы будете использовать ваше устройство" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36663,6 +54331,18 @@ }, "The root topic to use for MQTT." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -36675,6 +54355,12 @@ "value" : "MQTTに使用するルートトピック。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Корневая тема для использования MQTT." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36696,7 +54382,20 @@ } }, "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", + "value" : "Роли Router предназначены только для высоких точек обзора, таких как вершины гор и башни с небольшим количеством близлежащих нод, не для использования в городских районах. Неправильное использование повредит вашей локальной сети." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36707,12 +54406,24 @@ }, "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", "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", @@ -36725,6 +54436,12 @@ "value" : "このノードに管理メッセージを送信する権限を持つセカンダリ公開キー。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вторичный открытый ключ, авторизованный для отправки административных сообщений этой ноде." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36747,12 +54464,24 @@ }, "The state of the LED (on/off)" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilstanden for LED'en (tændt/slukket)" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Status der LED (an/aus)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El estado del LED (encendido/apagado)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36765,6 +54494,12 @@ "value" : "LEDの状態(オン/オフ)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Состояние LED (вкл/выкл)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36781,12 +54516,24 @@ }, "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", "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", @@ -36799,6 +54546,12 @@ "value" : "このノードに管理メッセージを送信する権限を持つ三次公開キー。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Третичный открытый ключ, авторизованный для отправки административных сообщений этой ноде." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36821,6 +54574,18 @@ }, "The URL for the channel settings" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL'en for kanalindstillingerne" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La URL para la configuración del canal." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -36833,6 +54598,12 @@ "value" : "チャンネル設定のURL" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL настроек канала" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36849,12 +54620,24 @@ }, "The URL for the node to add" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La URL del nodo a agregar." + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "追加するノードのURL" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL ноды для добавления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36871,6 +54654,18 @@ }, "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", + "value" : "Не было ответа на запрос метаданных устройства через PKC admin для этой ноды." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36881,6 +54676,18 @@ }, "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", + "value" : "Возникла проблема с открытым ключом этого контакта." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -36891,11 +54698,37 @@ }, "These settings will %@" : { "comment" : "A paragraph below the title that explains what the user is about to do.", - "isCommentAutoGenerated" : true + "isCommentAutoGenerated" : true, + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estas configuraciones %@" + } + }, + "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", "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" + } + }, + "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", @@ -36924,12 +54757,24 @@ }, "Thirty Minutes" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tredive minutter" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Dreißig Minuten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "treinta minutos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -36960,6 +54805,12 @@ "value" : "Trzydzieści Minut" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тридцать минут" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -36987,13 +54838,26 @@ } }, "Thirty Seconds" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tredive sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Dreißig Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "treinta segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37024,6 +54888,12 @@ "value" : "Trzydzieści Sekund" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тридцать секунд" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37051,13 +54921,26 @@ } }, "Thirty Six Hours" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seksogtredive timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Sechsunddreissig Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "treinta y seis horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37088,6 +54971,12 @@ "value" : "Trzydzieści Sześć Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тридцать шесть часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37116,6 +55005,18 @@ }, "This conversation will be deleted." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denne samtale vil blive slettet." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esta conversación será eliminada." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37128,6 +55029,12 @@ "value" : "この会話は削除されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эта беседа будет удалена." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37144,6 +55051,18 @@ }, "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." + } + }, + "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", @@ -37156,6 +55075,12 @@ "value" : "これには時間がかかる場合があります。応答は送信先ノードのトレースルートログに表示されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это может занять некоторое время, ответ появится в журнале трассировки маршрута для ноды, которой он был отправлен." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37172,6 +55097,18 @@ }, "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este dispositivo enviará mensajes de prueba de alcance en el intervalo seleccionado." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37184,6 +55121,12 @@ "value" : "このデバイスは選択した間隔でレンジテストメッセージを送信します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это устройство будет отправлять сообщения тестирования дальности с выбранным интервалом." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37206,12 +55149,24 @@ }, "This message was likely not delivered." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denne besked blev sandsynligvis ikke leveret." + } + }, "de" : { "stringUnit" : { "state" : "translated", "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", @@ -37224,6 +55179,12 @@ "value" : "このメッセージは配信されなかった可能性があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это сообщение, вероятно, не было доставлено." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37240,6 +55201,18 @@ }, "This node does not support any configurable modules." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -37252,6 +55225,12 @@ "value" : "このノードは設定可能なモジュールをサポートしていません。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эта нода не поддерживает никаких настраиваемых модулей." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37266,8 +55245,24 @@ } } }, + "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" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -37280,6 +55275,12 @@ "value" : "これにより固定位置が無効になり、現在設定されている位置が削除されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это отключит фиксированную позицию и удалит текущую установленную позицию." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37296,6 +55297,18 @@ }, "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" + } + }, + "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", @@ -37308,6 +55321,12 @@ "value" : "これにより、お使いの携帯電話から現在位置を送信し、固定位置を有効にします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Это отправит текущую позицию с вашего телефона и включит фиксированную позицию." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37329,13 +55348,26 @@ } }, "Three Hours" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tre timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Drei Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tres horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37366,6 +55398,12 @@ "value" : "Trzy Godziny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Три часа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37393,13 +55431,26 @@ } }, "Three Seconds" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tre sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Drei Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tres segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37430,6 +55481,12 @@ "value" : "Trzy Sekundy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Три секунды" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37457,13 +55514,26 @@ } }, "Thumbs Down" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tommel ned" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Daumen runter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulgar hacia abajo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37494,6 +55564,12 @@ "value" : "Kciuk w dół" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Палец вниз" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37521,13 +55597,26 @@ } }, "Thumbs Up" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tommel op" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Daumen hoch" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulgar arriba" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37558,6 +55647,12 @@ "value" : "Kciuk w górę" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Палец вверх" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37586,12 +55681,24 @@ }, "Time" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tid" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zeit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiempo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37604,6 +55711,12 @@ "value" : "時刻" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37620,12 +55733,24 @@ }, "Time Stamp" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsstempel" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zeitstempel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marca de tiempo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37638,6 +55763,12 @@ "value" : "タイムスタンプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отметка времени" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37654,12 +55785,24 @@ }, "Time Zone" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidszone" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zeitzone" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zona horaria" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37672,6 +55815,12 @@ "value" : "タイムゾーン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Часовой пояс" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37688,12 +55837,24 @@ }, "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", "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", @@ -37706,6 +55867,12 @@ "value" : "デバイス画面とログの日付用タイムゾーン。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Часовой пояс для отображения дат на экране устройства и в журнале." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37722,12 +55889,24 @@ }, "Timeout" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeout" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zeitlimit erreicht" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo de espera" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37758,6 +55937,12 @@ "value" : "Limit czasu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Таймаут" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37786,12 +55971,24 @@ }, "Timestamp" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsstempel" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zeitstempel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Marca de tiempo" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -37822,6 +56019,12 @@ "value" : "Znacznik czasu" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отметка времени" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -37850,6 +56053,18 @@ }, "Timing and Overrides" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temporización y anulaciones" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выбор времени и переопределения" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37858,8 +56073,21 @@ } } }, + "TLS Certificates" : {}, "TLS Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TLS-kryptering aktiveret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "TLS habilitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37872,6 +56100,12 @@ "value" : "TLS有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "TLS включен" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37894,12 +56128,30 @@ }, "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." + } + }, + "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", "value" : "CCPAやGDPRなどのプライバシー法に準拠するため、正確な位置データの共有は避けています。代わりに、あなたのプライバシーを保護するために匿名化または近似(不正確)の位置情報を使用します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Для соблюдения законов о конфиденциальности, таких как CCPA и GDPR, мы избегаем передачи точных данных о местоположении. Вместо этого мы используем анонимизированную или приблизительную (неточную) информацию о местоположении для защиты вашей конфиденциальности." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37916,6 +56168,18 @@ }, "To Radio (TX): %lld" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "A la radio (TX): %lld" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "К радио (TX): %lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37925,7 +56189,20 @@ } }, "Topic: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emne: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temas: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37938,6 +56215,12 @@ "value" : "トピック: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37954,12 +56237,24 @@ }, "Total" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Total" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "totales" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -37972,6 +56267,12 @@ "value" : "合計" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -37988,6 +56289,18 @@ }, "Total PAX" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sum af personer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAX TOTALES" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38000,6 +56313,12 @@ "value" : "総PAX" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Всего PAX" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38022,6 +56341,18 @@ }, "Trace Route" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporing (trace route)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de seguimiento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38034,6 +56365,12 @@ "value" : "トレースルート" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38050,12 +56387,24 @@ }, "Trace Route (in %@s)" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de seguimiento (en %@)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "トレースルート(%@秒)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута (через %@ сек)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38072,6 +56421,18 @@ }, "Trace Route Log" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporingslog" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de ruta de seguimiento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38084,6 +56445,12 @@ "value" : "トレースルートログ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Журнал трассировки маршрута" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38099,13 +56466,26 @@ } }, "Trace Route request returned: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporingen returnerede: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Traceroute Ergebnis: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solicitud de ruta de seguimiento devuelta: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -38136,6 +56516,12 @@ "value" : "Żądanie śledzenia trasy zwrócone: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запрос трассировки маршрута вернул: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38164,6 +56550,18 @@ }, "Trace Route Sent" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporing igangsat" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de seguimiento enviada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38176,6 +56574,12 @@ "value" : "トレースルート送信済み" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута отправлена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38192,6 +56596,18 @@ }, "Trace route sent to %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporing (trace route) sendt til %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruta de seguimiento enviada a %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38204,6 +56620,12 @@ "value" : "%@ にトレースルートを送信しました" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута отправлена на %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38220,6 +56642,18 @@ }, "Trace route to %@ was not sent." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Rutesporing %@ blev ikke igangsat." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se envió la ruta de seguimiento a %@." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38232,6 +56666,12 @@ "value" : "%@ へのトレースルートは送信されませんでした。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута до %@ не была отправлена." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38248,6 +56688,18 @@ }, "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." + } + }, + "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", @@ -38260,6 +56712,12 @@ "value" : "トレースルートの送信レートが制限されました。トレースルートは30秒ごとに最大1回送信できます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трассировка маршрута была ограничена по частоте. Вы можете отправлять трассировку маршрута максимум один раз в тридцать секунд." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38282,6 +56740,18 @@ "value" : "Standorte verfolgen und teilen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguimiento y compartir ubicaciones" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отслеживание и обмен местоположением" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38291,13 +56761,32 @@ } }, "Tracker" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sporingsprogram" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rastreador" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "トラッカー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трекер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38320,12 +56809,24 @@ }, "Traffic" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trafik" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Verkehr" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tráfico" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38338,6 +56839,12 @@ "value" : "トラフィック" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Трафик" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38354,6 +56861,18 @@ }, "Transmit data (txd) GPIO pin" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmitter data (txd) GPIO-pin" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmitir datos (txd) pin GPIO" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38366,6 +56885,12 @@ "value" : "送信データ(TXD)GPIOピン" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Контакт GPIO передачи данных (txd)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38382,6 +56907,18 @@ }, "Transmit Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overførsel aktiveret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmisión habilitada" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38394,6 +56931,12 @@ "value" : "送信有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Передача включена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38416,6 +56959,18 @@ }, "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." + } + }, + "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", @@ -38428,6 +56983,12 @@ "value" : "サポートされている加速度計でのダブルタップをユーザーボタン押下として扱います。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Считать двойное нажатие на поддерживаемых акселерометрах как нажатие пользовательской кнопки." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38450,6 +57011,18 @@ }, "TriggerType" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TriggerType" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo de disparador" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38462,6 +57035,12 @@ "value" : "トリガータイプ" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип триггера" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38478,6 +57057,18 @@ }, "Triple Click Ad Hoc Ping" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Triple Klik Ad Hoc Ping" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ping ad hoc de triple clic" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38490,6 +57081,12 @@ "value" : "トリプルクリック アドホックPing" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тройное нажатие для Ad Hoc Ping" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38506,12 +57103,24 @@ }, "Try Again" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prøv igen" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Erneut versuchen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inténtalo de nuevo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38524,6 +57133,12 @@ "value" : "再試行" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Попробовать снова" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38539,13 +57154,26 @@ } }, "Twelve Hours" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tolv timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zwölf Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doce horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -38576,6 +57204,12 @@ "value" : "Dwanaście Godzin" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Двенадцать часов" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38603,13 +57237,26 @@ } }, "Twenty Four Hours" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fireogtyve timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Vierundzwanzig Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "veinticuatro horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -38640,6 +57287,12 @@ "value" : "Dwadzieścia Cztery Godziny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Двадцать четыре часа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38668,12 +57321,24 @@ }, "Two Hours" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "To timer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zwei Stunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dos horas" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -38704,6 +57369,12 @@ "value" : "Dwie Godziny" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Два часа" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38731,13 +57402,26 @@ } }, "Two Minutes" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "To minutter" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zwei Minutes" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dos minutos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -38768,6 +57452,12 @@ "value" : "Dwie Minuty" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Две минуты" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38795,13 +57485,26 @@ } }, "Two Seconds" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "To sekunder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Zwei Sekunden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dos segundos" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -38832,6 +57535,12 @@ "value" : "Dwie Sekundy" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Две секунды" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -38860,6 +57569,18 @@ }, "UDP Broadcast" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "UDP-udsendelse" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmisión UDP" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38872,6 +57593,12 @@ "value" : "UDPブロードキャスト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "UDP-трансляция" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38887,7 +57614,20 @@ } }, "Ukraine 433MHz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukraine 433 MHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ucrania 433MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38900,6 +57640,12 @@ "value" : "ウクライナ 433MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Украина 433 МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38915,7 +57661,20 @@ } }, "Ukraine 868MHz" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukraine 868 MHz" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ucrania 868MHz" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38928,6 +57687,12 @@ "value" : "ウクライナ 868MHz" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Украина 868МГц" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38944,6 +57709,18 @@ }, "Un-Favorite" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern foretrukken" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No favorito" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38956,6 +57733,12 @@ "value" : "お気に入りを解除" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизбранный" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38971,7 +57754,20 @@ } }, "Unhealthy" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usund" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insalubre" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -38984,6 +57780,12 @@ "value" : "不健康" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нездоровый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -38999,7 +57801,20 @@ } }, "Unhealthy for Sensitive Groups" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usundt for følsomme grupper" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No saludable para grupos sensibles" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39012,6 +57827,12 @@ "value" : "敏感なグループには不健康" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Может быть небезопасно" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39027,7 +57848,20 @@ } }, "United States" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "USA" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estados Unidos" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39040,6 +57874,12 @@ "value" : "アメリカ合衆国" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "США" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39056,6 +57896,18 @@ }, "Units displayed on the device screen" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enheder vist på enhedens skærm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unidades mostradas en la pantalla del dispositivo." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39068,6 +57920,12 @@ "value" : "デバイス画面に表示される単位" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Единицы измерения, отображаемые на экране устройства" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39083,7 +57941,14 @@ } }, "unknown" : { + "extractionState" : "stale", "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "desconocido" + } + }, "it" : { "stringUnit" : { "state" : "needs_review", @@ -39096,6 +57961,12 @@ "value" : "不明" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "неизвестный" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39112,6 +57983,18 @@ }, "Unknown" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukendt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconocido" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -39142,6 +58025,12 @@ "value" : "Nieznany" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестный" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39170,12 +58059,24 @@ }, "Unknown Age" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukendt alder" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Unbekanntes alter" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edad desconocida" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -39206,6 +58107,12 @@ "value" : "Nieznany wiek" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестный возраст" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39240,12 +58147,24 @@ "value" : "Nicht benachrichtigbar" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "inmensable" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "メッセージ不可" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Невозможно отправить сообщение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39256,12 +58175,24 @@ }, "Unmonitored" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No monitoreado" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "監視なし" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неконтролируемый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39272,12 +58203,24 @@ }, "Unset" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjern indstilling" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Unset" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desarmado" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -39308,6 +58251,12 @@ "value" : "Nieustawiony" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не задан" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39336,6 +58285,18 @@ }, "Unsupported" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke understøttet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No compatible" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39348,6 +58309,12 @@ "value" : "サポート対象外" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неподдерживаемый" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39363,13 +58330,26 @@ } }, "Up" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Op" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Hoch" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "arriba" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -39400,6 +58380,12 @@ "value" : "W Górę" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вверх" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39428,6 +58414,18 @@ }, "Up Down 1" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Op Ned 1" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "arriba abajo 1" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39440,6 +58438,12 @@ "value" : "上下 1" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вверх вниз 1" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39462,6 +58466,18 @@ }, "Update Interval" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdateringsinterval" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervalo de actualización" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39474,6 +58490,12 @@ "value" : "更新間隔" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Интервал обновления" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39490,12 +58512,24 @@ }, "Update Your Firmware" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdater din firmware" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Firmware aktualisieren" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualice su firmware" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -39526,6 +58560,12 @@ "value" : "Zaktualizuj firmware" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновите прошивку" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39554,6 +58594,18 @@ }, "Updated Node Stats Data." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdaterede statistikker for noden." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos de estadísticas de nodos actualizados." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39566,6 +58618,12 @@ "value" : "ノード統計データを更新しました。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновлены данные статистики ноды." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39582,12 +58640,24 @@ }, "Updated: %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opdateret: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Aktualisiert: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizado: %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39600,6 +58670,12 @@ "value" : "更新日時: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновлено: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39616,6 +58692,18 @@ }, "Uplink Enabled" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uplink aktiveret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enlace ascendente habilitado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39628,6 +58716,12 @@ "value" : "アップリンク有効" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включена восходящая связь" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39656,6 +58750,12 @@ "value" : "Hochladen fehlgeschlagen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error de carga" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39668,6 +58768,12 @@ "value" : "アップロードエラー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка загрузки" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39696,6 +58802,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", @@ -39708,6 +58820,12 @@ "value" : "カスタムマップオーバーレイを表示するためにGeoJSONファイルをアップロードしてください。ファイルはローカルに保存され、最大10MBまでです。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузите файлы GeoJSON для отображения пользовательских слоев карты. Файлы хранятся локально и могут быть размером до 10 МБ." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39731,6 +58849,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", @@ -39743,6 +58867,12 @@ "value" : "マップデータをアップロード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузить данные карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39772,6 +58902,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", @@ -39784,6 +58920,12 @@ "value" : "オーバーレイを有効にするにはマップデータをアップロード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузите данные карты для включения слоев" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39812,6 +58954,18 @@ "value" : "Kartendaten hochladen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cargar superposiciones de mapas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузить слои карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39828,6 +58982,12 @@ "value" : "Hochladen erfolgreich" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subir con éxito" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39840,6 +59000,12 @@ "value" : "アップロード成功" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Успешная загрузка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39868,6 +59034,18 @@ "value" : "Hochgeladene Kartendaten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Superposiciones de mapas cargados" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загруженные слои карты" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39878,6 +59056,18 @@ }, "Uptime" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oppetid" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiempo de actividad" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39890,6 +59080,12 @@ "value" : "稼働時間" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время работы" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -39918,6 +59114,18 @@ "value" : "Telemetriedaten erfassen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos de uso y fallos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные об использовании и сбоях" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39926,8 +59134,24 @@ } } }, + "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" : { + "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." + } + }, + "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", @@ -39940,6 +59164,12 @@ "value" : "オン/オフ出力ではなく、PWM出力(RAKブザーなど)をチューンに使用してください。これにより、出力、出力時間、アクティブ設定は無視され、代わりにデバイス設定のブザーGPIOオプションが使用されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используйте выход PWM (например, RAK Buzzer) для мелодий вместо вывода вкл/выкл. Это будет игнорировать вывод, длительность вывода и активные настройки и использовать опцию GPIO зуммера в конфигурации устройства." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39962,6 +59192,18 @@ }, "Use I2S As Buzzer" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brug I2S som buzzer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilice I2S como zumbador" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -39974,6 +59216,12 @@ "value" : "I2Sをブザーとして使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать I2S как зуммер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -39996,12 +59244,24 @@ "value" : "Standort verwenden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar mi ubicación" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "自分の位置を使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать мое местоположение" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40012,12 +59272,24 @@ }, "Use Preset" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brug forudindstilling" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Voreinstellung verwenden" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar preajuste" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40030,6 +59302,12 @@ "value" : "プリセットを使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать пресет" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40052,6 +59330,18 @@ }, "Use PWM Buzzer" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brug PWM-summer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar zumbador PWM" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40064,6 +59354,12 @@ "value" : "PWMブザーを使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать PWM зуммер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40092,6 +59388,18 @@ "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", + "value" : "Используйте GPS вашего телефона для отправки местоположения на вашу ноду вместо использования аппаратного GPS на вашей ноде." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40102,6 +59410,18 @@ }, "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se utiliza para crear una clave compartida con un dispositivo remoto." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40114,6 +59434,12 @@ "value" : "リモート デバイスとの共有キーを作成するために使用されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используется для создания общего ключа с удаленным устройством." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40136,12 +59462,24 @@ "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", "value" : "監視されていないまたはインフラストラクチャノードを識別するために使用され、応答しないノードにはメッセージング機能が利用できないようにします。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используется для идентификации неотслеживаемых или инфраструктурных нод, чтобы обмен сообщениями был недоступен для нод, которые никогда не ответят." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40152,12 +59490,24 @@ }, "User" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Benutzer" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuario" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -40188,6 +59538,12 @@ "value" : "Użytkownik" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользователь" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -40216,12 +59572,24 @@ }, "User Config" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brugerindstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Benutzerkonfiguration" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración de usuario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40234,6 +59602,12 @@ "value" : "ユーザー設定" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки пользователя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40256,12 +59630,24 @@ }, "User Details" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brugerdetaljer" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Benutzerdaten" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalles del usuario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40274,6 +59660,12 @@ "value" : "ユーザー詳細" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сведения о пользователе" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40296,6 +59688,18 @@ }, "User Id" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruger-ID" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Identificación de usuario" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40308,6 +59712,12 @@ "value" : "ユーザーID" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ID пользователя" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40329,13 +59739,52 @@ } }, "User Info Exchange Failed" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error en el intercambio de información del usuario" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Произошел сбой обмена информацией о пользователе" + } + } + } }, "User Info Sent" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Información de usuario enviada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инфа о пользователе отпр-на" + } + } + } }, "User Privacy" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacidad del usuario" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша конфиденциальность" + } + } + } }, "User Uploaded" : { "comment" : "Data source label for user uploaded files", @@ -40346,6 +59795,12 @@ "value" : "Daten verfügbar" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuario subido" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40358,6 +59813,12 @@ "value" : "ユーザーがアップロード" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загруженный пользователем" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40380,12 +59841,24 @@ }, "Username" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brugernavn" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Benutzername" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre de usuario" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -40416,6 +59889,12 @@ "value" : "Nazwa użytkownika" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя пользователя" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -40444,6 +59923,18 @@ }, "Uses pullup resistor" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruger pullup-modstand" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliza resistencia pullup" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40456,6 +59947,12 @@ "value" : "プルアップ抵抗を使用" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использует подтягивающий резистор" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40472,6 +59969,18 @@ }, "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" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliza la conexión de red de su teléfono para conectarse a MQTT." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40484,6 +59993,12 @@ "value" : "スマートフォンのネットワーク接続を利用してMQTTに接続します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использует сетевое подключение вашего телефона для подключения к MQTT." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40506,12 +60021,24 @@ }, "Vehicle heading" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Køretøjets retning" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Fahrzeugsteuerkurs" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rumbo del vehículo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40524,6 +60051,12 @@ "value" : "車両方位" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Направление транспорта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40540,12 +60073,24 @@ }, "Vehicle speed" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Køretøjets hastighed" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Fahrzeuggeschwindigkeit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidad del vehículo" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40558,6 +60103,12 @@ "value" : "車両速度" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость транспорта" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40574,6 +60125,18 @@ }, "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", + "value" : "Проверьте, с кем вы общаетесь, сравнив открытые ключи при личной встрече или по телефону. Самый последний открытый ключ для этой ноды не совпадает с ранее записанным ключом. Вы можете удалить ноду и позволить ей снова обменяться ключами, если смена ключа произошла из-за сброса настроек или другого преднамеренного действия, но это также может указывать на более серьезную проблему безопасности." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40584,12 +60147,24 @@ }, "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", "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", @@ -40602,6 +60177,12 @@ "value" : "バージョン%1$@には、ネットワークの大幅な最適化と、デバイスおよびクライアントアプリへの広範な変更が含まれています。サポートされるノードはバージョン%2$@以降のみです。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия %1$@ содержит существенные оптимизации сети и значительные изменения в устройствах и клиентских приложениях. Поддерживаются только ноды версии %2$@ и выше." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40624,18 +60205,36 @@ }, "Version: %@ (%@)" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version: %1$@ (%2$@)" + } + }, "en" : { "stringUnit" : { "state" : "new", "value" : "Version: %1$@ (%2$@)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión: %@ (%@)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "バージョン: %@ (%@)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия: %1$@ (%2$@)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40651,13 +60250,26 @@ } }, "Version: %1$@ (%2$@)" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version: %1$@ (%2$@)" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Version: %1$@ (%2$@) " } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión: %1$@ (%2$@)" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40670,6 +60282,12 @@ "value" : "バージョン: %1$@ (%2$@)" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия: %1$@ (%2$@)" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40691,7 +60309,20 @@ } }, "Very Unhealthy" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meget usund" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "muy poco saludable" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40704,6 +60335,12 @@ "value" : "非常に不健康" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очень вредно для здоровья" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40720,12 +60357,24 @@ }, "Via Lora" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via Lora" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Via Lora" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vía Lora" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40738,6 +60387,12 @@ "value" : "LoRa経由" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Через Lora" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40754,12 +60409,24 @@ }, "Via Mqtt" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via Mqtt" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Via Mqtt" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vía Mqtt" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40772,6 +60439,12 @@ "value" : "MQTT経由" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Через MQTT" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40794,12 +60467,24 @@ }, "Voltage" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spænding" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Voltage" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "voltaje" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -40830,6 +60515,12 @@ "value" : "Napięcie" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Напряжение" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -40858,6 +60549,18 @@ }, "Volts %@" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volt %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltios %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40870,6 +60573,12 @@ "value" : "Volts %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вольт: %@" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40886,12 +60595,24 @@ }, "Waiting" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Venter" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Warte..." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "esperando" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -40922,6 +60643,12 @@ "value" : "Czekam. . ." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ожидайте" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -40950,6 +60677,18 @@ }, "Waiting to be acknowledged. . ." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afventer bekræftelse…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esperando ser reconocido. . ." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -40962,6 +60701,12 @@ "value" : "承認待ち. . ." } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ожидайте подтверждения. . ." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -40978,6 +60723,18 @@ }, "Wake Screen on tap or motion" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -40990,6 +60747,12 @@ "value" : "タップまたはモーションで画面を起動" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экран пробуждения при нажатии или движении" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41005,13 +60768,26 @@ } }, "Walking" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gåtur" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gehen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caminando" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41024,6 +60800,12 @@ "value" : "歩行" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прогулка" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41044,14 +60826,31 @@ } } }, + "Warning" : { + "comment" : "The header text for the \"Warning\" section in the TAKServerConfig view.", + "isCommentAutoGenerated" : true + }, "Wave" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bølge" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Welle" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ola" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41064,6 +60863,12 @@ "value" : "波" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Волна" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -41086,12 +60891,24 @@ }, "Waypoint Failed to Send" : { "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El punto de referencia no se pudo enviar" + } + }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ウェイポイントの送信に失敗" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось отправить путевую точку" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41102,12 +60919,24 @@ }, "Waypoint Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viapunkt-indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Wegpunktoptionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones de punto de referencia" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41120,6 +60949,12 @@ "value" : "ウェイポイントオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры путевых точек" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41135,13 +60970,26 @@ } }, "Waypoint Packet received from node: %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viapunkt-pakke modtaget fra node: %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Wegpunkt von Knoten empfangen: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paquete de waypoint recibido del nodo: %@" + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -41172,6 +61020,12 @@ "value" : "Odebrano pakiet punktu orientacyjnego od węzła: %@" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пакет путевой точки, полученный от ноды: %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -41199,16 +61053,41 @@ } }, "Waypoints" : { - + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puntos de ruta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Путевые точки" + } + } + } }, "Weather Conditions" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vejrforhold" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Wetterverhältnisse" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Condiciones climáticas" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41221,6 +61100,12 @@ "value" : "気象条件" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Погодные условия" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41243,6 +61128,18 @@ }, "Web Flasher" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Web Flasher" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intermitente web" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41255,6 +61152,12 @@ "value" : "ウェブフラッシャー" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Веб-флешер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41271,6 +61174,18 @@ }, "Website" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Websted" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitio web" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41283,6 +61198,12 @@ "value" : "ウェブサイト" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сайт" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41305,6 +61226,18 @@ }, "Weight" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vægt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peso" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41317,6 +61250,12 @@ "value" : "重量" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вес" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41339,6 +61278,18 @@ "value" : "Willkommen bei" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bienvenido a" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добро пожаловать" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41349,12 +61300,24 @@ }, "What does the lock mean?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvad betyder låsen?" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Was bedeutet das Schloß?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Qué significa la cerradura?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41367,6 +61330,12 @@ "value" : "鍵マークの意味は?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Что означает этот замок?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41389,12 +61358,24 @@ }, "What is Meshtastic?" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvad er Meshtastic?" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Was ist Meshtastic?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Qué es Meshtastic?" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41407,6 +61388,12 @@ "value" : "Meshtasticとは?" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Что такое Meshtastic?" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41429,6 +61416,18 @@ }, "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" + } + }, + "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", @@ -41441,6 +61440,12 @@ "value" : "ライセンス操作者モードの機能:\n* ノード名をコールサインに設定\n* 10分ごとにノード情報をブロードキャスト\n* 周波数、デューティサイクル、送信電力をオーバーライド\n* 暗号化を無効化" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Что делает режим лицензированного оператора:\n* Устанавливает название ноды в соответствии с вашим позывным \n* Передает информацию о ноде каждые 10 минут \n* Изменяет частоту, режим работы и мощность линии связи \n* Отключает шифрование" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41463,6 +61468,18 @@ }, "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." + } + }, + "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", @@ -41481,6 +61498,12 @@ "value" : "PAXカウンターモジュールを有効にすると、WiFiとBluetoothを使用して通過する人数をカウントします。PAXカウンターを動作させるには、WiFiとBluetoothの両方を無効にする必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При включении модуль прохожих подсчитывает количество людей, проходящих мимо, используя Wi-Fi и Bluetooth. Для работы счетчика количества человек как Wi-Fi, так и Bluetooth должны быть отключены." + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -41509,6 +61532,18 @@ }, "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." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuando lo use en modo GPIO, mantenga la salida encendida durante este tiempo. " + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41521,6 +61556,12 @@ "value" : "GPIOモードで使用する際、この期間出力をオンに保ちます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При использовании в режиме GPIO держите вывод включенным в течение этого времени." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41543,6 +61584,18 @@ }, "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" + } + }, + "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", @@ -41555,6 +61608,12 @@ "value" : "GPIOピンでINPUT_PULLUPモードを使用するかどうか。ボードがピンでプルアップ抵抗を使用している場合のみ適用されます" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Независимо от того, используется ли режим INPUT_PULLUP для вывода GPIO. Применимо только в том случае, если на выводе платы используются подтягивающие резисторы" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41571,6 +61630,18 @@ }, "WiFi" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "WiFi" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wi-Fi" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41583,6 +61654,12 @@ "value" : "WiFi" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "WiFi" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -41605,12 +61682,24 @@ }, "WiFi Options" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "WiFi-indstillinger" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "WiFi Optionen" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opciones WiFi" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41623,6 +61712,12 @@ "value" : "WiFiオプション" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Параметры WiFi" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41639,6 +61734,18 @@ }, "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." + } + }, + "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", @@ -41663,6 +61770,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", @@ -41691,6 +61804,18 @@ }, "Wind" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vind" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "viento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41703,6 +61828,12 @@ "value" : "風" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ветер" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41718,13 +61849,26 @@ } }, "Wind Direction" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vindretning" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Windrichtung" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dirección del viento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41737,6 +61881,12 @@ "value" : "風向" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Направление ветра" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41752,13 +61902,26 @@ } }, "Wind Speed" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vindhastighed" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Windgeschwindigkeit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocidad del viento" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41771,6 +61934,12 @@ "value" : "風速" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорость ветра" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41786,13 +61955,26 @@ } }, "Within %@" : { + "extractionState" : "stale", "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inden for %@" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Innerhalb %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dentro de %@" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41805,6 +61987,12 @@ "value" : "%@ 以内" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внутри %@" + } + }, "se" : { "stringUnit" : { "state" : "translated", @@ -41827,6 +62015,18 @@ }, "x" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "x" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "x" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41839,6 +62039,12 @@ "value" : "x" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "x" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41855,12 +62061,24 @@ }, "X: %@, Y: %d" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$d" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "X: %1$@, Y: %2$d" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$d" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41873,6 +62091,12 @@ "value" : "X: %1$@, Y: %2$d" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$d" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41896,12 +62120,24 @@ }, "X: %@, Y: %f" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$f" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "X: %1$@, Y: %2$f" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$f" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41914,6 +62150,12 @@ "value" : "X: %1$@, Y: %2$f" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$f" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41937,12 +62179,24 @@ }, "X: %@, Y: %lld" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$lld" + } + }, "en" : { "stringUnit" : { "state" : "translated", "value" : "X: %1$@, Y: %2$lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$lld" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41955,6 +62209,12 @@ "value" : "X: %1$@, Y: %2$lld" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "X: %1$@, Y: %2$lld" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -41978,6 +62238,18 @@ }, "y" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "j" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "y" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -41990,6 +62262,12 @@ "value" : "y" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "y" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42012,16 +62290,42 @@ }, "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" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sí, controlo este nodo." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Да, я управляю этой нодой" + } + } + } }, "Yesterday" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I går" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Gestern" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ayer" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42034,6 +62338,12 @@ "value" : "昨日" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вчера" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42050,6 +62360,18 @@ }, "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." + } + }, + "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", @@ -42062,6 +62384,12 @@ "value" : "Nordic DFUアプリを使用してBluetoothでMeshtasticデバイスを更新することもできます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы также можете обновить свое устройство Meshtastic по Bluetooth с помощью приложения Nordic DFU." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42082,14 +62410,30 @@ } } }, + "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" : { + "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", "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", @@ -42120,6 +62464,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", @@ -42146,8 +62496,24 @@ } } }, + "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" : { + "stringUnit" : { + "state" : "translated", + "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", @@ -42160,6 +62526,12 @@ "value" : "現在の位置が固定位置として設定され、位置間隔でメッシュネットワーク上にブロードキャストされます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваше текущее местоположение будет установлено как фиксированная позиция и транслироваться по сетке на интервале определения местоположения." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42182,12 +62554,24 @@ }, "Your Firmware is up to date" : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhedens firmware er opdateret" + } + }, "de" : { "stringUnit" : { "state" : "translated", "value" : "Deine Firmware ist aktuell" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su firmware está actualizado" + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42200,6 +62584,12 @@ "value" : "ファームウェアは最新です" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша прошивка обновлена" + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42222,6 +62612,18 @@ }, "Your MQTT Server must support TLS." : { "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din MQTT-server skal understøtte TLS" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su servidor MQTT debe admitir TLS." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42234,6 +62636,12 @@ "value" : "MQTTサーバーはTLSをサポートする必要があります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш MQTT-сервер должен поддерживать протокол TLS." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42250,12 +62658,30 @@ }, "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." + } + }, + "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", "value" : "ノードは設定されたMQTTサーバーに定期的に暗号化されていないマップレポートパケットを送信します。これにはID、短縮名と長い名前、おおよその位置、ハードウェアモデル、役割、ファームウェアバージョン、LoRa地域、モデムプリセット、プライマリチャンネル名が含まれます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша нода будет периодически отправлять незашифрованный пакет местоположения на настроенный сервер MQTT, включающий идентификатор, короткое и длинное имя, приблизительное местоположение, модель оборудования, роль, версию встроенного ПО, регион LoRa, предустановку модема и название основного канала." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42272,6 +62698,18 @@ }, "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." + } + }, + "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", @@ -42284,6 +62722,12 @@ "value" : "ノードの動作周波数は、地域、モデムプリセット、およびこのフィールドに基づいて計算されます。0の場合、スロットはプライマリチャンネル名に基づいて自動的に計算されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рабочая частота вашей ноды рассчитывается на основе региона, настроек модема и этого поля. При значении 0 интервал автоматически рассчитывается на основе названия основного канала." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42300,6 +62744,18 @@ }, "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." + } + }, + "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", @@ -42312,6 +62768,12 @@ "value" : "位置情報が位置の返信要求と共に送信されました。位置が返信されると通知を受け取ります。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша позиция была отправлена с запросом на ответ с указанием их позиции. Вы получите уведомление, когда позиция будет возвращена." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42332,11 +62794,40 @@ } } }, + "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" : { + "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", + "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" : { + "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." + } + }, + "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", @@ -42349,6 +62840,12 @@ "value" : "お住まいの地域はデューティサイクルが%lld%%です。デューティサイクル制限がある場合、MQTTの使用は推奨されません。追加のトラフィックによってLoRaメッシュがすぐに圧迫されます。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В вашем регионе действует режим работы %lld%%. Не рекомендуется использовать MQTT, если у вас ограничен режим работы, так как дополнительный трафик быстро перегрузит вашу сеть LoRa." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42371,6 +62868,18 @@ }, "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." + } + }, + "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", @@ -42383,6 +62892,12 @@ "value" : "お住まいの地域は時間あたり%lld%%のデューティサイクル制限があります。無線機が時間制限に達すると、パケットの送信を停止します。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В вашем регионе установлен часовой режим работы %lld%%, ваша радиостанция прекратит отправку пакетов, когда достигнет часового лимита." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42399,6 +62914,18 @@ }, "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" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su archivo de ruta debe tener columnas y encabezados de Latitud y Longitud." + } + }, "it" : { "stringUnit" : { "state" : "translated", @@ -42411,6 +62938,12 @@ "value" : "ルートファイルには緯度と経度の列とヘッダーの両方が必要です。" } }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш файл маршрута должен содержать столбцы широты и долготы, а также заголовки." + } + }, "sr" : { "stringUnit" : { "state" : "translated", @@ -42426,7 +62959,102 @@ } }, "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", + "value" : "Ваша пользовательская информация была отправлена с запросом на получение ответа с их пользовательской информацией." + } + } + } + }, + ": %@" : { + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %@" + } + } + }, + "shouldTranslate" : false + }, + ": %d" : { + "localizations" : { + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "sr" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + }, + "zh-Hant-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : ": %d" + } + } + }, + "shouldTranslate" : false } }, "version" : "1.1" diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 2de0a388..aaf83221 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -83,18 +83,30 @@ 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 */; }; 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 */; }; @@ -299,6 +311,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 */ @@ -333,6 +347,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 = ""; }; @@ -397,17 +414,28 @@ 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 = ""; }; 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 = ""; }; @@ -794,6 +822,7 @@ 23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */, 23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */, 23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */, + 82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */, ); path = "Accessory Manager"; sourceTree = ""; @@ -876,6 +905,7 @@ 25F5D5C82C4375A8008036E3 /* MeshtasticTests */ = { isa = PBXGroup; children = ( + AA00010022E2730EC0060000 /* ConnectViewTests.swift */, 25F5D5D02C4375DF008036E3 /* RouterTests.swift */, ); path = MeshtasticTests; @@ -900,6 +930,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 = ( @@ -987,6 +1034,7 @@ DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */, DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */, ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */, + 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */, ); path = Settings; sourceTree = ""; @@ -1240,6 +1288,7 @@ DDB75A192A05EB67006ED576 /* alpha.png */, DDC2E15B26CE248F0042C5E4 /* Assets.xcassets */, DD0E21002B8A6BC500F2D100 /* DeviceHardware.json */, + 518D504DED9874EBF9D76578 /* Certificates */, ); path = Resources; sourceTree = ""; @@ -1304,6 +1353,7 @@ DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */, 6D825E612C34786C008DBEE4 /* CommonRegex.swift */, 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */, + C37572859BC745C4284A9B42 /* TAK */, ); path = Helpers; sourceTree = ""; @@ -1573,6 +1623,7 @@ DDC2E15C26CE248F0042C5E4 /* Assets.xcassets in Resources */, DD0E21012B8A6F1300F2D100 /* DeviceHardware.json in Resources */, DDDBC87B2BC62E4E001E8DF7 /* Settings.bundle in Resources */, + 8E587743574CE17703E892C6 /* Certificates in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1612,6 +1663,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */, 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1881,6 +1933,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; }; @@ -2097,7 +2161,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"; @@ -2114,8 +2177,11 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.6; - PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; + MARKETING_VERSION = 2.7.9; + OTHER_LDFLAGS = ( + "-weak_framework", + SwiftUI, + ); PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -2132,7 +2198,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"; @@ -2149,8 +2214,11 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.6; - PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; + MARKETING_VERSION = 2.7.9; + OTHER_LDFLAGS = ( + "-weak_framework", + SwiftUI, + ); PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -2181,7 +2249,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.6; + MARKETING_VERSION = 2.7.9; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2214,7 +2282,7 @@ "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 2.7.6; + MARKETING_VERSION = 2.7.9; 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 ae01c092..01a2c541 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 bd8494db..5d46abe8 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -55,1041 +55,1049 @@ 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.noiseFloor = telemetryMessage.localStats.noiseFloor - telemetry.metricsType = 4 - Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Noise Floor: \(telemetryMessage.localStats.noiseFloor, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") - } else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { - Logger.data.info("📈 [Telemetry] Power Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") - telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage) - 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.noiseFloor = telemetryMessage.localStats.noiseFloor + telemetry.metricsType = 4 + Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Noise Floor: \(telemetryMessage.localStats.noiseFloor, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") + } else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { + Logger.data.info("📈 [Telemetry] Power Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") + telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage) + 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( @@ -1098,134 +1106,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..68a6b063 --- /dev/null +++ b/Meshtastic/Helpers/TAK/CoTMessage.swift @@ -0,0 +1,546 @@ +// +// 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, + remarks: String? = nil + ) -> 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), + remarks: remarks + ) + } + + /// 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).. 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/TAKCertificateManager.swift b/Meshtastic/Helpers/TAK/TAKCertificateManager.swift new file mode 100644 index 00000000..e77f3ad0 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKCertificateManager.swift @@ -0,0 +1,788 @@ +// +// TAKCertificateManager.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +import Security +import OSLog + +/// Manages TLS certificates for the TAK server +/// Handles server identity (PKCS#12) and client CA certificates (PEM) +final class TAKCertificateManager { + + static let shared = TAKCertificateManager() + + // Keychain tags for certificate storage + private let serverIdentityTag = "com.meshtastic.tak.server.identity" + private let serverIdentityCustomTag = "com.meshtastic.tak.server.identity.custom" + private let clientCATag = "com.meshtastic.tak.client.ca" + + // Bundled certificate password + private let bundledPassword = "meshtastic" + + // Storage keys for custom P12 data (for data package generation) + private let customServerP12DataKey = "tak.custom.server.p12.data" + private let customServerP12PasswordKey = "tak.custom.server.p12.password" + private let customClientP12DataKey = "tak.custom.client.p12.data" + private let customClientP12PasswordKey = "tak.custom.client.p12.password" + + private init() { + // Load bundled defaults on first launch if no custom cert exists + loadBundledDefaultsIfNeeded() + } + + /// Force reload all bundled certificates (useful after app update with new certs) + func reloadBundledCertificates() { + Logger.tak.info("Reloading bundled certificates...") + + // Clear custom certificate data + clearCustomCertificateData() + + // Delete existing certificates + deleteServerIdentity() + deleteClientCACertificates() + + // Reload bundled defaults + loadBundledServerIdentity() + loadBundledClientCA() + + Logger.tak.info("Bundled certificates reloaded") + } + + // MARK: - Bundled Default Certificates + + /// Load bundled default certificates if no custom certificates are configured + private func loadBundledDefaultsIfNeeded() { + // Only load if no custom server identity exists + if !hasCustomServerCertificate() && getServerIdentity() == nil { + loadBundledServerIdentity() + } + + // Only load if no client CA exists + if !hasClientCACertificate() { + loadBundledClientCA() + } + } + + /// Load the bundled server identity (p12) + private func loadBundledServerIdentity() { + // Try subdirectory first, then root level (Xcode may flatten folder structure) + 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 { + Logger.tak.warning("Bundled server.p12 not found in app bundle") + return + } + + do { + _ = try importServerIdentity(from: p12Data, password: bundledPassword, isCustom: false) + Logger.tak.info("Loaded bundled default server certificate") + } catch { + Logger.tak.error("Failed to load bundled server certificate: \(error.localizedDescription)") + } + } + + /// Load the bundled client CA certificate (pem) + private func loadBundledClientCA() { + // Try subdirectory first, then root level (Xcode may flatten folder structure) + 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 { + Logger.tak.warning("Bundled ca.pem not found in app bundle") + return + } + + do { + _ = try importClientCACertificate(from: pemData) + Logger.tak.info("Loaded bundled default CA certificate") + } catch { + Logger.tak.error("Failed to load bundled CA certificate: \(error.localizedDescription)") + } + } + + /// Check if using custom (user-imported) server certificate + func hasCustomServerCertificate() -> 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..8985accf --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift @@ -0,0 +1,1430 @@ +// +// 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 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 + } + + guard accessoryManager.isConnected else { + Logger.tak.warning("Cannot send to mesh: Not connected to Meshtastic device") + return + } + + let channel = UInt32(TAKServerManager.shared.channel) + + // 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, channel: channel) + 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, 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)") + } + } + } + + /// 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))" + // 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, + 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 + remarks: remarks + ) + } + + // MARK: - Broadcast All Mesh Nodes to TAK + + /// 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 } + + // 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() + 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 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)") + } + } + + // MARK: - Helper Methods + + private func lookupNodeInfo(nodeNum: UInt32) -> NodeInfoEntity? { + // 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 == %lld", 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 + } + } + + // 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 new file mode 100644 index 00000000..b619af98 --- /dev/null +++ b/Meshtastic/Helpers/TAK/TAKServerManager.swift @@ -0,0 +1,692 @@ +// +// TAKServerManager.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import Foundation +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 +@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? + @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) + + @AppStorage("takServerChannel") var channel: Int = 0 + + @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: - 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) + func start() async throws { + guard !isRunning else { + Logger.tak.info("TAK Server already running") + return + } + + checkPrimaryChannelValidity() + + 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)") + + // 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 + 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) + } + } + } + + /// 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 { + 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: - 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 + 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" + } + } +} 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..32d9bc99 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -8,263 +8,404 @@ 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 { + // 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)") } } -} - -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 +413,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 00000000..2f27bff2 Binary files /dev/null and b/Meshtastic/Resources/Certificates/client.p12 differ diff --git a/Meshtastic/Resources/Certificates/server.p12 b/Meshtastic/Resources/Certificates/server.p12 new file mode 100644 index 00000000..88b9fcba Binary files /dev/null and b/Meshtastic/Resources/Certificates/server.p12 differ diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift index 48a97b93..ca828478 100644 --- a/Meshtastic/Router/NavigationState.swift +++ b/Meshtastic/Router/NavigationState.swift @@ -52,6 +52,7 @@ enum SettingsNavigationState: String { case debugLogs case appFiles case firmwareUpdates + case tak } struct NavigationState: Hashable { diff --git a/Meshtastic/Views/Connect/Connect.swift b/Meshtastic/Views/Connect/Connect.swift index bb43ae04..b66b1b59 100644 --- a/Meshtastic/Views/Connect/Connect.swift +++ b/Meshtastic/Views/Connect/Connect.swift @@ -511,23 +511,23 @@ struct ManualConnectionMenu: View { }) }.confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) { Button("Connect to new radio?", role: .destructive) { - if let device = deviceForManualConnection { - UserDefaults.preferredPeripheralId = device.id.uuidString - UserDefaults.preferredPeripheralNum = 0 - if accessoryManager.allowDisconnect { - Task { try await accessoryManager.disconnect() } - } - clearCoreDataDatabase(context: context, includeRoutes: false) - clearNotifications() - Task { - try await selectedTransport?.transport.manuallyConnect(toDevice: device) - } - - // Clean up just in case - deviceForManualConnection = nil - } - } - } + Task { + if let device = deviceForManualConnection { + UserDefaults.preferredPeripheralId = device.id.uuidString + UserDefaults.preferredPeripheralNum = 0 + if accessoryManager.allowDisconnect { + try await accessoryManager.disconnect() + } + await MeshPackets.shared.clearCoreDataDatabase(includeRoutes: false) + clearNotifications() + try await selectedTransport?.transport.manuallyConnect(toDevice: device) + + // Clean up just in case + deviceForManualConnection = nil + } + } + } + } } } @@ -593,15 +593,17 @@ struct DeviceConnectRow: View { }.padding([.bottom, .top]) .confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) { Button("Connect to new radio?", role: .destructive) { - UserDefaults.preferredPeripheralId = device.id.uuidString - UserDefaults.preferredPeripheralNum = 0 - if accessoryManager.allowDisconnect { - Task { try await accessoryManager.disconnect() } - } - clearCoreDataDatabase(context: context, includeRoutes: false) - clearNotifications() Task { + UserDefaults.preferredPeripheralId = device.id.uuidString + UserDefaults.preferredPeripheralNum = 0 + if accessoryManager.allowDisconnect { + try await accessoryManager.disconnect() + } + await MeshPackets.shared.clearCoreDataDatabase(includeRoutes: false) + clearNotifications() + try await accessoryManager.connect(to: device) + } } } diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index 16660203..bf3c2752 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -160,9 +160,16 @@ struct ChannelList: View { titleVisibility: .visible ) { Button(role: .destructive) { - deleteChannelMessages(channel: channelToDeleteMessages!, context: context) - context.refresh(myInfo, mergeChanges: true) - channelToDeleteMessages = nil + Task { + await MeshPackets.shared.deleteChannelMessages(channel: channelToDeleteMessages!) + await MainActor.run { + context.refresh(channel, mergeChanges: true) + context.refresh(myInfo, mergeChanges: true) + + // Reset state + channelToDeleteMessages = nil + } + } } label: { Text("Delete") } diff --git a/Meshtastic/Views/Messages/MessageText.swift b/Meshtastic/Views/Messages/MessageText.swift index afdda5a3..7427e14a 100644 --- a/Meshtastic/Views/Messages/MessageText.swift +++ b/Meshtastic/Views/Messages/MessageText.swift @@ -27,9 +27,8 @@ struct MessageText: View { // State for handling channel URL sheet @State private var saveChannelLink: SaveChannelLinkData? @State private var isShowingDeleteConfirmation = false - - @FocusState private var isTapbackInputFocused: Bool @State private var tapbackText = "" + @FocusState private var isTapbackInputFocused: Bool var body: some View { SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) { diff --git a/Meshtastic/Views/Messages/TapbackInputView.swift b/Meshtastic/Views/Messages/TapbackInputView.swift index bf6de5a6..36a1e9b0 100644 --- a/Meshtastic/Views/Messages/TapbackInputView.swift +++ b/Meshtastic/Views/Messages/TapbackInputView.swift @@ -78,3 +78,16 @@ struct TapbackInputView: View { return nil } } + +extension UIView { + var firstResponder: UIView? { + guard !isFirstResponder else { return self } + for subview in subviews { + if let firstResponder = subview.firstResponder { + return firstResponder + } + } + return nil + } +} + diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index 16ba0b7b..ba07a9bf 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -224,8 +224,10 @@ fileprivate struct FilteredUserList: View { titleVisibility: .visible ) { Button(role: .destructive) { - deleteUserMessages(user: userToDeleteMessages!, context: context) - context.refresh(node!.user!, mergeChanges: true) + Task { + await MeshPackets.shared.deleteUserMessages(user: userToDeleteMessages!) + context.refresh(node!.user!, mergeChanges: true) + } } label: { Text("Delete") } diff --git a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift index 80d47fbd..90e8d119 100644 --- a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift +++ b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift @@ -199,10 +199,12 @@ struct DeviceMetricsLog: View { titleVisibility: .visible ) { Button("Delete all device metrics?", role: .destructive) { - if clearTelemetry(destNum: node.num, metricsType: 0, context: context) { - Logger.data.notice("Cleared Device Metrics for \(node.num, privacy: .public)") - } else { - Logger.data.error("Clear Device Metrics Log Failed") + Task { + if await MeshPackets.shared.clearTelemetry(destNum: node.num, metricsType: 0) { + Logger.data.notice("Cleared Device Metrics for \(node.num, privacy: .public)") + } else { + Logger.data.error("Clear Device Metrics Log Failed") + } } } } diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index 6eec29f3..84148d2e 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -128,8 +128,10 @@ struct EnvironmentMetricsLog: View { titleVisibility: .visible ) { Button("Delete all environment metrics?", role: .destructive) { - if clearTelemetry(destNum: node.num, metricsType: 1, context: context) { - Logger.services.error("Clear Environment Metrics Log Failed") + Task { + if await MeshPackets.shared.clearTelemetry(destNum: node.num, metricsType: 1) { + Logger.services.error("Clear Environment Metrics Log Failed") + } } } } diff --git a/Meshtastic/Views/Nodes/PaxCounterLog.swift b/Meshtastic/Views/Nodes/PaxCounterLog.swift index 34285ddb..482e0d69 100644 --- a/Meshtastic/Views/Nodes/PaxCounterLog.swift +++ b/Meshtastic/Views/Nodes/PaxCounterLog.swift @@ -175,10 +175,12 @@ struct PaxCounterLog: View { titleVisibility: .visible ) { Button("Delete all pax data?", role: .destructive) { - if clearPax(destNum: node.num, context: context) { - Logger.services.info("Cleared Pax Counter for \(node.num, privacy: .public)") - } else { - Logger.services.error("Clear Pax Counter Log Failed") + Task { + if await MeshPackets.shared.clearPax(destNum: node.num) { + Logger.services.info("Cleared Pax Counter for \(node.num, privacy: .public)") + } else { + Logger.services.error("Clear Pax Counter Log Failed") + } } } } diff --git a/Meshtastic/Views/Nodes/PositionLog.swift b/Meshtastic/Views/Nodes/PositionLog.swift index b2be785d..af307f20 100644 --- a/Meshtastic/Views/Nodes/PositionLog.swift +++ b/Meshtastic/Views/Nodes/PositionLog.swift @@ -131,10 +131,12 @@ struct PositionLog: View { titleVisibility: .visible ) { Button("Delete all positions?", role: .destructive) { - if clearPositions(destNum: node.num, context: context) { - Logger.services.info("Successfully Cleared Position Log") - } else { - Logger.services.error("Clear Position Log Failed") + Task { + if await MeshPackets.shared.clearPositions(destNum: node.num) { + Logger.services.info("Successfully Cleared Position Log") + } else { + Logger.services.error("Clear Position Log Failed") + } } } } diff --git a/Meshtastic/Views/Nodes/PowerMetricsLog.swift b/Meshtastic/Views/Nodes/PowerMetricsLog.swift index a555effd..b4578a59 100644 --- a/Meshtastic/Views/Nodes/PowerMetricsLog.swift +++ b/Meshtastic/Views/Nodes/PowerMetricsLog.swift @@ -242,10 +242,12 @@ struct PowerMetricsLog: View { titleVisibility: .visible ) { Button("Delete Power metrics?", role: .destructive) { - if clearTelemetry(destNum: node.num, metricsType: 2, context: context) { - Logger.data.notice("Cleared Power Metrics for \(node.num, privacy: .public)") - } else { - Logger.data.error("Clear Power Metrics Log Failed") + Task { + if await MeshPackets.shared.clearTelemetry(destNum: node.num, metricsType: 2) { + Logger.data.notice("Cleared Power Metrics for \(node.num, privacy: .public)") + } else { + Logger.data.error("Clear Power Metrics Log Failed") + } } } } diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index 243325b1..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") @@ -138,30 +138,31 @@ struct AppSettings: View { Button("Erase all app data?", role: .destructive) { Task { try await accessoryManager.disconnect() - } - /// Delete any database backups too - if var url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { - url = url.appendingPathComponent("backup").appendingPathComponent(String(UserDefaults.preferredPeripheralNum)) - do { - try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite")) - /// Delete -shm file + + /// Delete any database backups too + if var url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + url = url.appendingPathComponent("backup").appendingPathComponent(String(UserDefaults.preferredPeripheralNum)) do { - try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite-wal")) + try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite")) + /// Delete -shm file do { - try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite-shm")) + try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite-wal")) + do { + try FileManager.default.removeItem(at: url.appendingPathComponent("Meshtastic.sqlite-shm")) + } catch { + Logger.services.error("🗄 Error Deleting Meshtastic.sqlite-shm file \(error, privacy: .public)") + } } catch { - Logger.services.error("🗄 Error Deleting Meshtastic.sqlite-shm file \(error, privacy: .public)") + Logger.services.error("🗄 Error Deleting Meshtastic.sqlite-wal file \(error, privacy: .public)") } } catch { - Logger.services.error("🗄 Error Deleting Meshtastic.sqlite-wal file \(error, privacy: .public)") + Logger.services.error("🗄 Error Deleting Meshtastic.sqlite file \(error, privacy: .public)") } - } catch { - Logger.services.error("🗄 Error Deleting Meshtastic.sqlite file \(error, privacy: .public)") } + await MeshPackets.shared.clearCoreDataDatabase(includeRoutes: true) + clearNotifications() + context.refreshAllObjects() } - clearCoreDataDatabase(context: context, includeRoutes: true) - clearNotifications() - context.refreshAllObjects() } } Button { diff --git a/Meshtastic/Views/Settings/Config/DeviceConfig.swift b/Meshtastic/Views/Settings/Config/DeviceConfig.swift index a03c8a22..6bfd711b 100644 --- a/Meshtastic/Views/Settings/Config/DeviceConfig.swift +++ b/Meshtastic/Views/Settings/Config/DeviceConfig.swift @@ -175,7 +175,7 @@ struct DeviceConfig: View { try await accessoryManager.sendNodeDBReset(fromUser: node!.user!, toUser: node!.user!) try await Task.sleep(for: .seconds(1)) try await accessoryManager.disconnect() - clearCoreDataDatabase(context: context, includeRoutes: false) + await MeshPackets.shared.clearCoreDataDatabase(includeRoutes: false) clearNotifications() } catch { Logger.mesh.error("NodeDB Reset Failed") @@ -200,7 +200,7 @@ struct DeviceConfig: View { try await accessoryManager.sendFactoryReset(fromUser: node!.user!, toUser: node!.user!) try await Task.sleep(for: .seconds(1)) try await accessoryManager.disconnect() - clearCoreDataDatabase(context: context, includeRoutes: false) + await MeshPackets.shared.clearCoreDataDatabase(includeRoutes: false) clearNotifications() } catch { Logger.mesh.error("Factory Reset Failed") @@ -213,7 +213,7 @@ struct DeviceConfig: View { try await accessoryManager.sendFactoryReset(fromUser: node!.user!, toUser: node!.user!, resetDevice: true) try? await Task.sleep(for: .seconds(1)) try await accessoryManager.disconnect() - clearCoreDataDatabase(context: context, includeRoutes: false) + await MeshPackets.shared.clearCoreDataDatabase(includeRoutes: false) clearNotifications() } catch { Logger.mesh.error("Factory Reset Failed") 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) } diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index d3d15a66..449efc6c 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -327,6 +327,18 @@ struct Settings: View { } } + var takSection: some View { + Section(header: Text("TAK")) { + NavigationLink(value: SettingsNavigationState.tak) { + Label { + Text("TAK Server") + } icon: { + Image(systemName: "target") + } + } + } + } + var body: some View { NavigationStack( path: Binding<[SettingsNavigationState]>( @@ -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..cb8e3666 --- /dev/null +++ b/Meshtastic/Views/Settings/TAKServerConfig.swift @@ -0,0 +1,567 @@ +// +// TAKServerConfig.swift +// Meshtastic +// +// Created by niccellular 12/26/25 +// + +import SwiftUI +import UniformTypeIdentifiers +import OSLog +import CoreData + +enum CertificateImportType { + case p12 + case pem +} + +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 + @Environment(\.dismiss) private var dismiss + @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? + @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], + 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") + } + .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) }, + 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 + } + .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 + + 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) + } + } + + 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") + } + } + + // 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) + } + + 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 + channelLabel(channel) + .tag(Int(channel.index)) + } + } label: { + Label("TAK Channel Index", systemImage: "bubble.left.and.bubble.right") + } + } + + 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. TAK Channel Index selects the channel index where TAK messages will be sent.") + } + } + + // 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: - 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>) { + 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 + } + } + + 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() { + 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/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) } } 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) diff --git a/itak-example-data-package/iphone.p12 b/itak-example-data-package/iphone.p12 new file mode 100644 index 00000000..7f836b2f Binary files /dev/null and b/itak-example-data-package/iphone.p12 differ 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 00000000..913f4692 Binary files /dev/null and b/itak-example-data-package/server.p12 differ diff --git a/itak-example-data-package/taky-server.pref b/itak-example-data-package/taky-server.pref new file mode 100644 index 00000000..5fb204fa --- /dev/null +++ b/itak-example-data-package/taky-server.pref @@ -0,0 +1,16 @@ + + + + 1 + Win10 Taky Server + true + 172.30.254.210:8089:ssl + + + true + server.p12 + YOURPASSWORD + YOURPASSWORD + iphone.p12 + +