mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge origin/main into noise-floor, resolve conflicts
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
commit
436ba7a03a
64 changed files with 32969 additions and 2960 deletions
341
.github/workflows/bug-report-analyzer.yml
vendored
Normal file
341
.github/workflows/bug-report-analyzer.yml
vendored
Normal file
|
|
@ -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 = '<!-- meshtastic-bug-analyzer -->';
|
||||
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 <a href="https://meshtastic.org/docs/software/apple/ios-debug/">Debug Log</a> 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,
|
||||
});
|
||||
}
|
||||
20740
Localizable.xcstrings
20740
Localizable.xcstrings
File diff suppressed because it is too large
Load diff
|
|
@ -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 = "<group>"; };
|
||||
0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = "<group>"; };
|
||||
09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = "<group>"; };
|
||||
108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = "<group>"; };
|
||||
108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = "<group>"; };
|
||||
230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = "<group>"; };
|
||||
|
|
@ -397,17 +414,28 @@
|
|||
25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = "<group>"; };
|
||||
2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = "<group>"; };
|
||||
3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = "<group>"; };
|
||||
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = "<group>"; };
|
||||
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayConfig.swift; sourceTree = "<group>"; };
|
||||
3D3417D12E2DC260006A988B /* MapDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataManager.swift; sourceTree = "<group>"; };
|
||||
3D3417D32E2DC293006A988B /* MapDataFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapDataFiles.swift; sourceTree = "<group>"; };
|
||||
3F203877F307073096C89179 /* FountainCodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FountainCodec.swift; sourceTree = "<group>"; };
|
||||
4AA216CF50721EE1AE7D7251 /* CoTMessage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTMessage.swift; sourceTree = "<group>"; };
|
||||
518D504DED9874EBF9D76578 /* Certificates */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = Certificates; sourceTree = "<group>"; };
|
||||
6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = "<group>"; };
|
||||
6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = "<group>"; };
|
||||
6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectionSensorLog.swift; sourceTree = "<group>"; };
|
||||
6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageEntityExtension.swift; sourceTree = "<group>"; };
|
||||
748E4806582595DE80D455CD /* CoTXMLParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoTXMLParser.swift; sourceTree = "<group>"; };
|
||||
7F1B62B5CB54395476C3A924 /* GenericCoTHandler.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GenericCoTHandler.swift; sourceTree = "<group>"; };
|
||||
82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+TAK.swift"; sourceTree = "<group>"; };
|
||||
87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKMeshtasticBridge.swift; sourceTree = "<group>"; };
|
||||
8D3F8A3D2D44B137009EAAA4 /* MeshtasticDataModelV 49.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 49.xcdatamodel"; sourceTree = "<group>"; };
|
||||
8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetrics.swift; sourceTree = "<group>"; };
|
||||
8D3F8A402D44C2A6009EAAA4 /* PowerMetricsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMetricsLog.swift; sourceTree = "<group>"; };
|
||||
9155703C39B55FC9DDF3E4C1 /* TAKDataPackageGenerator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKDataPackageGenerator.swift; sourceTree = "<group>"; };
|
||||
ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconButton.swift; sourceTree = "<group>"; };
|
||||
ABB99DEA2E2EA1C500CFBD05 /* AppIconPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconPicker.swift; sourceTree = "<group>"; };
|
||||
B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -794,6 +822,7 @@
|
|||
23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */,
|
||||
23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */,
|
||||
23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */,
|
||||
82232A3CF2DD284ED5B9B8ED /* AccessoryManager+TAK.swift */,
|
||||
);
|
||||
path = "Accessory Manager";
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -876,6 +905,7 @@
|
|||
25F5D5C82C4375A8008036E3 /* MeshtasticTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA00010022E2730EC0060000 /* ConnectViewTests.swift */,
|
||||
25F5D5D02C4375DF008036E3 /* RouterTests.swift */,
|
||||
);
|
||||
path = MeshtasticTests;
|
||||
|
|
@ -900,6 +930,23 @@
|
|||
path = AppIntents;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
D9C9839E2B79D0C600BDBE6A /* TextMessageField */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -987,6 +1034,7 @@
|
|||
DD3CC6B428E33FD100FA9159 /* ShareChannels.swift */,
|
||||
DDCE4E2B2869F92900BE9F8F /* UserConfig.swift */,
|
||||
ABA8E63F2E2F2A2300E27791 /* AppIconButton.swift */,
|
||||
0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1240,6 +1288,7 @@
|
|||
DDB75A192A05EB67006ED576 /* alpha.png */,
|
||||
DDC2E15B26CE248F0042C5E4 /* Assets.xcassets */,
|
||||
DD0E21002B8A6BC500F2D100 /* DeviceHardware.json */,
|
||||
518D504DED9874EBF9D76578 /* Certificates */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1304,6 +1353,7 @@
|
|||
DD3619142B1EF9F900C41C8C /* LocationsHandler.swift */,
|
||||
6D825E612C34786C008DBEE4 /* CommonRegex.swift */,
|
||||
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */,
|
||||
C37572859BC745C4284A9B42 /* TAK */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)..<UInt32.max)
|
||||
meshPacket.decoded = dataMessage
|
||||
|
||||
Logger.tak.debug("MeshPacket:")
|
||||
Logger.tak.debug(" to: \(String(format: "0x%08X", meshPacket.to))")
|
||||
Logger.tak.debug(" from: \(String(format: "0x%08X", meshPacket.from))")
|
||||
Logger.tak.debug(" channel: \(meshPacket.channel)")
|
||||
Logger.tak.debug(" id: \(meshPacket.id)")
|
||||
Logger.tak.debug(" portnum: \(dataMessage.portnum.rawValue)")
|
||||
Logger.tak.debug(" payload size: \(serialized.count)")
|
||||
|
||||
var toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
try await send(toRadio, debugDescription: "Sending TAKPacket to mesh")
|
||||
|
||||
Logger.tak.info("Sent TAKPacket to mesh (portnum=\(PortNum.atakPlugin.rawValue), channel=\(channel), size=\(serialized.count) bytes)")
|
||||
Logger.tak.debug("=== End Sending TAKPacket ===")
|
||||
}
|
||||
|
||||
/// Send a CoT message to the mesh by converting it to TAKPacket first
|
||||
func sendCoTToMesh(_ cotMessage: CoTMessage, channel: UInt32 = 0) async throws {
|
||||
let bridge = TAKServerManager.shared.bridge
|
||||
|
||||
guard let takPacket = bridge?.convertToTAKPacket(cot: cotMessage) else {
|
||||
throw AccessoryError.ioFailed("Failed to convert CoT to TAKPacket")
|
||||
}
|
||||
|
||||
try await sendTAKPacket(takPacket, channel: channel)
|
||||
}
|
||||
|
||||
// MARK: - Receive TAK Packet from Mesh
|
||||
|
||||
/// Handle incoming ATAK Plugin packet from the mesh network
|
||||
/// Forwards to connected TAK clients via the bridge
|
||||
func handleATAKPluginPacket(_ packet: MeshPacket) {
|
||||
guard case let .decoded(data) = packet.payloadVariant else {
|
||||
Logger.tak.warning("Received ATAK packet without decoded payload")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.tak.debug("Received ATAK packet: \(data.payload.count) bytes from node \(packet.from)")
|
||||
|
||||
// Check if packet is compressed (first bytes 08 01 indicate is_compressed = true)
|
||||
// Compressed packets are sent as duplicates of uncompressed ones, so we ignore them
|
||||
let payload = data.payload
|
||||
if payload.count >= 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)..<UInt32.max)
|
||||
meshPacket.priority = MeshPacket.Priority.reliable
|
||||
meshPacket.priority = MeshPacket.Priority.reliable
|
||||
meshPacket.wantAck = true
|
||||
meshPacket.channel = 0
|
||||
guard let adminData: Data = try? adminPacket.serializedData() else {
|
||||
|
||||
guard let adminData = try? adminPacket.serializedData() else {
|
||||
throw AccessoryError.ioFailed("saveChannelSet: Unable to serialize Admin packet")
|
||||
}
|
||||
|
||||
var dataMessage = DataMessage()
|
||||
dataMessage.payload = adminData
|
||||
dataMessage.portnum = PortNum.adminApp
|
||||
meshPacket.decoded = dataMessage
|
||||
var toRadio: ToRadio!
|
||||
toRadio = ToRadio()
|
||||
|
||||
var toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
let logString = String.localizedStringWithFormat("Sent a Channel for: %@ Channel Index %d".localized, String(deviceNum), chan.index)
|
||||
try await send(toRadio, debugDescription: logString)
|
||||
await MeshPackets.shared.channelPacket(channel: chan, fromNum: self.activeDeviceNum ?? 0)
|
||||
}
|
||||
if !addChannels {
|
||||
// Save the LoRa Config and the device will reboot
|
||||
|
|
@ -856,7 +864,7 @@ extension AccessoryManager {
|
|||
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertAmbientLightingModuleConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertAmbientLightingModuleConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
|
||||
|
|
@ -912,7 +920,7 @@ extension AccessoryManager {
|
|||
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertCannedMessagesModuleConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertCannedMessagesModuleConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -995,7 +1003,7 @@ extension AccessoryManager {
|
|||
let messageDescription = "🛟 Saved Detection Sensor Module Config for \(toUser.longName ?? "Unknown".localized)"
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertDetectionSensorModuleConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertDetectionSensorModuleConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1049,7 +1057,7 @@ extension AccessoryManager {
|
|||
let messageDescription = "🛟 Saved External Notification Module Config for \(toUser.longName ?? "Unknown".localized)"
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertExternalNotificationModuleConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertExternalNotificationModuleConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1079,7 +1087,7 @@ extension AccessoryManager {
|
|||
let messageDescription = "🛟 Saved PAX Counter Module Config for \(toUser.longName ?? "Unknown".localized)"
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertPaxCounterModuleConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertPaxCounterModuleConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1109,7 +1117,7 @@ extension AccessoryManager {
|
|||
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertRtttlConfigPacket(ringtone: ringtone, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertRtttlConfigPacket(ringtone: ringtone, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1140,7 +1148,7 @@ extension AccessoryManager {
|
|||
let messageDescription = "🛟 Saved MQTT Config for \(toUser.longName ?? "Unknown".localized)"
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertMqttModuleConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertMqttModuleConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1170,7 +1178,7 @@ extension AccessoryManager {
|
|||
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertRangeTestModuleConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertRangeTestModuleConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1200,7 +1208,7 @@ extension AccessoryManager {
|
|||
let messageDescription = "🛟 Saved Serial Module Config for \(toUser.longName ?? "Unknown".localized)"
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertSerialModuleConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertSerialModuleConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1379,7 +1387,7 @@ extension AccessoryManager {
|
|||
let messageDescription = "🛟 Saved Store & Forward Module Config for \(toUser.longName ?? "Unknown".localized)"
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertStoreForwardModuleConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertStoreForwardModuleConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1615,7 +1623,7 @@ extension AccessoryManager {
|
|||
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertPositionConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
try await MeshPackets.shared.upsertPositionConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1669,7 +1677,7 @@ extension AccessoryManager {
|
|||
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertPowerConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertPowerConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1725,7 +1733,7 @@ extension AccessoryManager {
|
|||
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertNetworkConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertNetworkConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1756,7 +1764,7 @@ extension AccessoryManager {
|
|||
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertSecurityConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertSecurityConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1890,7 +1898,7 @@ extension AccessoryManager {
|
|||
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertBluetoothConfigPacket(config: config, nodeNum: toUser.num, sessionPasskey: toUser.userNode?.sessionPasskey, context: context)
|
||||
await MeshPackets.shared.upsertBluetoothConfigPacket(config: config, nodeNum: toUser.num, sessionPasskey: toUser.userNode?.sessionPasskey)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1920,7 +1928,7 @@ extension AccessoryManager {
|
|||
let messageDescription = "Saved Telemetry Module Config for \(toUser.longName ?? "Unknown".localized)"
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertTelemetryModuleConfigPacket(config: config, nodeNum: toUser.num, context: context)
|
||||
await MeshPackets.shared.upsertTelemetryModuleConfigPacket(config: config, nodeNum: toUser.num)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -1973,7 +1981,7 @@ extension AccessoryManager {
|
|||
let messageDescription = "🛟 Saved Display Config for \(toUser.longName ?? "Unknown".localized)"
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertDisplayConfigPacket(config: config, nodeNum: toUser.num, sessionPasskey: toUser.userNode?.sessionPasskey, context: context)
|
||||
await MeshPackets.shared.upsertDisplayConfigPacket(config: config, nodeNum: toUser.num, sessionPasskey: toUser.userNode?.sessionPasskey)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -2052,7 +2060,7 @@ extension AccessoryManager {
|
|||
let messageDescription = "🛟 Saved Device Config for \(toUser.longName ?? "Unknown".localized)"
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertDeviceConfigPacket(config: config, nodeNum: toUser.num, sessionPasskey: toUser.userNode?.sessionPasskey, context: context)
|
||||
await MeshPackets.shared.upsertDeviceConfigPacket(config: config, nodeNum: toUser.num, sessionPasskey: toUser.userNode?.sessionPasskey)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
@ -2081,7 +2089,7 @@ extension AccessoryManager {
|
|||
|
||||
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
|
||||
|
||||
upsertLoRaConfigPacket(config: config, nodeNum: toUser.num, sessionPasskey: toUser.userNode?.sessionPasskey, context: context)
|
||||
await MeshPackets.shared.upsertLoRaConfigPacket(config: config, nodeNum: toUser.num, sessionPasskey: toUser.userNode?.sessionPasskey)
|
||||
|
||||
return Int64(meshPacket.id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
@Published var lastConnectionError: Error?
|
||||
@Published var isConnected: Bool = false
|
||||
@Published var isConnecting: Bool = false
|
||||
@Published var isInBackground: Bool = false
|
||||
|
||||
var activeConnection: (device: Device, connection: any Connection)?
|
||||
|
||||
|
|
@ -143,8 +144,9 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
// Config
|
||||
public var wantRangeTestPackets = false
|
||||
var wantStoreAndForwardPackets = false
|
||||
var shouldAutomaticallyConnectToPreferredPeripheral = true
|
||||
|
||||
var shouldAutomaticallyConnectToPreferredPeripheralAfterError = true
|
||||
var userRequestedConnectionCancellation = false
|
||||
|
||||
// Conncetion process
|
||||
var connectionSteps: SequentialSteps?
|
||||
|
||||
|
|
@ -179,10 +181,12 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
return transports.first(where: {$0.type == type })
|
||||
}
|
||||
|
||||
func connectToPreferredDevice() {
|
||||
func connectToPreferredDevice(device: Device? = nil) {
|
||||
if !self.isConnected && !self.isConnecting,
|
||||
let preferredDevice = self.devices.first(where: { $0.id.uuidString == UserDefaults.preferredPeripheralId }) {
|
||||
Task { try await self.connect(to: preferredDevice) }
|
||||
let preferredDevice = device ?? self.devices.first(where: { $0.id.uuidString == UserDefaults.preferredPeripheralId }) {
|
||||
Task {
|
||||
try await self.connect(to: preferredDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +201,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
_ = clearStaleNodes(nodeExpireDays: Int(UserDefaults.purgeStaleNodeDays), context: self.context)
|
||||
_ = await MeshPackets.shared.clearStaleNodes(nodeExpireDays: Int(UserDefaults.purgeStaleNodeDays))
|
||||
|
||||
try await withTaskCancellationHandler {
|
||||
var toRadio: ToRadio = ToRadio()
|
||||
|
|
@ -289,6 +293,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
|
||||
// Should only be called by UI-facing callers.
|
||||
func disconnect() async throws {
|
||||
self.userRequestedConnectionCancellation = true
|
||||
// Cancel ongoing connection task if it exists
|
||||
await self.connectionStepper?.cancel()
|
||||
|
||||
|
|
@ -369,13 +374,13 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
func didReceive(_ event: ConnectionEvent) {
|
||||
func didReceive(_ event: ConnectionEvent) async {
|
||||
packetsReceived += 1
|
||||
|
||||
switch event {
|
||||
case .data(let fromRadio):
|
||||
// Logger.transport.info("✅ [Accessory] didReceive: \(fromRadio.payloadVariant.debugDescription)")
|
||||
self.processFromRadio(fromRadio)
|
||||
await self.processFromRadio(fromRadio)
|
||||
Task {
|
||||
await self.heartbeatResponseTimer?.cancel(withReason: "Data packet received")
|
||||
await self.heartbeatTimer?.reset(delay: .seconds(15.0))
|
||||
|
|
@ -399,19 +404,19 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
Task {
|
||||
// Figure out if we'll reconnect
|
||||
if case .errorWithoutReconnect = event {
|
||||
shouldAutomaticallyConnectToPreferredPeripheral = false
|
||||
shouldAutomaticallyConnectToPreferredPeripheralAfterError = false
|
||||
} else {
|
||||
shouldAutomaticallyConnectToPreferredPeripheral = true
|
||||
shouldAutomaticallyConnectToPreferredPeripheralAfterError = true
|
||||
}
|
||||
|
||||
Logger.transport.info("🚨 [Accessory] didReceive with failure: \(error.localizedDescription, privacy: .public) (willReconnect = \(self.shouldAutomaticallyConnectToPreferredPeripheral, privacy: .public))")
|
||||
Logger.transport.info("🚨 [Accessory] didReceive with failure: \(error.localizedDescription, privacy: .public) (willReconnect = \(self.shouldAutomaticallyConnectToPreferredPeripheralAfterError, privacy: .public))")
|
||||
|
||||
lastConnectionError = error
|
||||
|
||||
if let connectionStepper = self.connectionStepper {
|
||||
// If we're in the midst of a connection process, tell the stepper that something happened
|
||||
// This cancels retry connection attempts if we've been asked not to reconnect
|
||||
await connectionStepper.cancelCurrentlyExecutingStep(withError: error, cancelFullProcess: !shouldAutomaticallyConnectToPreferredPeripheral)
|
||||
await connectionStepper.cancelCurrentlyExecutingStep(withError: error, cancelFullProcess: !shouldAutomaticallyConnectToPreferredPeripheralAfterError)
|
||||
} else {
|
||||
// Normal processing. Expose the error and disconnect
|
||||
try? await self.closeConnection()
|
||||
|
|
@ -427,7 +432,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
case .disconnected:
|
||||
Task {
|
||||
// This is user-initatied, so don't reconnect
|
||||
shouldAutomaticallyConnectToPreferredPeripheral = false
|
||||
shouldAutomaticallyConnectToPreferredPeripheralAfterError = false
|
||||
try? await self.closeConnection()
|
||||
updateState(.discovering)
|
||||
}
|
||||
|
|
@ -483,7 +488,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
private func processFromRadio(_ decodedInfo: FromRadio) {
|
||||
private func processFromRadio(_ decodedInfo: FromRadio) async {
|
||||
switch decodedInfo.payloadVariant {
|
||||
case .mqttClientProxyMessage(let mqttClientProxyMessage):
|
||||
handleMqttClientProxyMessage(mqttClientProxyMessage)
|
||||
|
|
@ -492,12 +497,12 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
handleClientNotification(clientNotification)
|
||||
|
||||
case .myInfo(let myNodeInfo):
|
||||
handleMyInfo(myNodeInfo)
|
||||
await handleMyInfo(myNodeInfo)
|
||||
|
||||
case .packet(let packet):
|
||||
// All received packets get passed through updateAnyPacketFrom to update lastHeard, rxSnr, etc. (like firmware's NodeDB::updateFrom).
|
||||
if let connectedNodeNum = self.activeDeviceNum {
|
||||
updateAnyPacketFrom(packet: packet, activeDeviceNum: connectedNodeNum, context: context)
|
||||
await MeshPackets.shared.updateAnyPacketFrom(packet: packet, activeDeviceNum: connectedNodeNum)
|
||||
} else {
|
||||
Logger.mesh.error("🕸️ Unable to determine connectedNodeNum for updateAnyPacketFrom. Skipping.")
|
||||
}
|
||||
|
|
@ -506,20 +511,58 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
if case let .decoded(data) = packet.payloadVariant {
|
||||
switch data.portnum {
|
||||
case .textMessageApp, .detectionSensorApp, .alertApp:
|
||||
handleTextMessageAppPacket(packet)
|
||||
await handleTextMessageAppPacket(packet)
|
||||
// Broadcast text message to TAK clients
|
||||
if let text = String(bytes: data.payload, encoding: .utf8) {
|
||||
Logger.tak.debug("Text message received, calling broadcast")
|
||||
let server = TAKServerManager.shared
|
||||
if server.ensureBridgeReadyForMeshToCot() {
|
||||
await server.bridge?.broadcastMeshTextMessageToTAK(text: text, from: packet.from, channel: packet.channel, to: packet.to)
|
||||
}
|
||||
}
|
||||
case .remoteHardwareApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .positionApp:
|
||||
upsertPositionPacket(packet: packet, context: context)
|
||||
await MeshPackets.shared.upsertPositionPacket(packet: packet)
|
||||
// Broadcast position to TAK clients
|
||||
if let position = try? Position(serializedBytes: data.payload) {
|
||||
Logger.tak.debug("Position received, calling broadcast")
|
||||
let server = TAKServerManager.shared
|
||||
if server.ensureBridgeReadyForMeshToCot() {
|
||||
await server.bridge?.broadcastMeshPositionToTAK(position: position, from: packet.from)
|
||||
}
|
||||
}
|
||||
case .waypointApp:
|
||||
waypointPacket(packet: packet, context: context)
|
||||
Logger.tak.info("WAYPOINT APP CASE REACHED")
|
||||
await MeshPackets.shared.waypointPacket(packet: packet)
|
||||
// Broadcast waypoint to TAK clients
|
||||
if let waypoint = try? Waypoint(serializedBytes: data.payload) {
|
||||
Logger.tak.info("WAYPOINT PARSED: \(waypoint.name)")
|
||||
// Ensure bridge is initialized before calling (not optional chaining, or lazy init won't run)
|
||||
let server = TAKServerManager.shared
|
||||
if server.meshToCotEnabled && server.isRunning && !server.connectedClients.isEmpty {
|
||||
// Force bridge initialization if needed
|
||||
if server.bridge == nil {
|
||||
Logger.tak.info("Initializing bridge on demand")
|
||||
let bridge = TAKMeshtasticBridge(
|
||||
accessoryManager: AccessoryManager.shared,
|
||||
takServerManager: server
|
||||
)
|
||||
bridge.context = AccessoryManager.shared.context
|
||||
server.bridge = bridge
|
||||
}
|
||||
await server.bridge?.broadcastMeshWaypointToTAK(waypoint: waypoint, from: packet.from)
|
||||
} else {
|
||||
Logger.tak.info("Waypoint broadcast skipped: server not ready or no clients")
|
||||
}
|
||||
}
|
||||
case .nodeinfoApp:
|
||||
guard let connectedNodeNum = self.activeDeviceNum else {
|
||||
Logger.mesh.error("🕸️ Unable to determine connectedNodeNum for node info upsert.")
|
||||
return
|
||||
}
|
||||
if packet.from != connectedNodeNum {
|
||||
upsertNodeInfoPacket(packet: packet, context: context)
|
||||
await MeshPackets.shared.upsertNodeInfoPacket(packet: packet)
|
||||
} else {
|
||||
Logger.mesh.error("🕸️ Received a node info packet from ourselves over the mesh. Dropping.")
|
||||
}
|
||||
|
|
@ -528,16 +571,16 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for routingPacket.")
|
||||
return
|
||||
}
|
||||
routingPacket(packet: packet, connectedNodeNum: deviceNum, context: context)
|
||||
await MeshPackets.shared.routingPacket(packet: packet, connectedNodeNum: deviceNum)
|
||||
case .adminApp:
|
||||
adminAppPacket(packet: packet, context: context)
|
||||
await MeshPackets.shared.adminAppPacket(packet: packet)
|
||||
case .replyApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Reply App handling as a text message")
|
||||
guard let deviceNum = activeConnection?.device.num else {
|
||||
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for replyApp.")
|
||||
return
|
||||
}
|
||||
textMessageAppPacket(packet: packet, wantRangeTestPackets: wantRangeTestPackets, connectedNode: deviceNum, context: context, appState: appState)
|
||||
await MeshPackets.shared.textMessageAppPacket(packet: packet, wantRangeTestPackets: wantRangeTestPackets, connectedNode: deviceNum, appState: appState)
|
||||
case .ipTunnelApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for IP Tunnel App UNHANDLED UNHANDLED")
|
||||
case .serialApp:
|
||||
|
|
@ -554,11 +597,10 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
return
|
||||
}
|
||||
if wantRangeTestPackets {
|
||||
textMessageAppPacket(
|
||||
await MeshPackets.shared.textMessageAppPacket(
|
||||
packet: packet,
|
||||
wantRangeTestPackets: true,
|
||||
connectedNode: deviceNum,
|
||||
context: context,
|
||||
appState: appState
|
||||
)
|
||||
} else {
|
||||
|
|
@ -569,7 +611,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for telemetryApp.")
|
||||
return
|
||||
}
|
||||
telemetryPacket(packet: packet, connectedNode: deviceNum, context: context)
|
||||
await MeshPackets.shared.telemetryPacket(packet: packet, connectedNode: deviceNum)
|
||||
case .textMessageCompressedApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Text Message Compressed App UNHANDLED")
|
||||
case .zpsApp:
|
||||
|
|
@ -577,13 +619,15 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
case .privateApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Private App UNHANDLED UNHANDLED")
|
||||
case .atakForwarder:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for ATAK Forwarder App UNHANDLED UNHANDLED")
|
||||
handleATAKForwarderPacket(packet)
|
||||
case .simulatorApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Simulator App UNHANDLED UNHANDLED")
|
||||
case .storeForwardPlusplusApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for SFPP App UNHANDLED UNHANDLED")
|
||||
case .audioApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Audio App UNHANDLED UNHANDLED")
|
||||
case .nodeStatusApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Node Status App UNHANDLED")
|
||||
case .tracerouteApp:
|
||||
handleTraceRouteApp(packet)
|
||||
case .neighborinfoApp:
|
||||
|
|
@ -591,7 +635,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
Logger.mesh.info("🕸️ MESH PACKET received for Neighbor Info App UNHANDLED \((try? neighborInfo.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
}
|
||||
case .paxcounterApp:
|
||||
paxCounterPacket(packet: decodedInfo.packet, context: context)
|
||||
await MeshPackets.shared.paxCounterPacket(packet: decodedInfo.packet)
|
||||
case .mapReportApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received Map Report App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .UNRECOGNIZED:
|
||||
|
|
@ -599,7 +643,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
case .max:
|
||||
Logger.services.info("MAX PORT NUM OF 511")
|
||||
case .atakPlugin:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for ATAK Plugin App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
handleATAKPluginPacket(packet)
|
||||
case .powerstressApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Power Stress App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .reticulumTunnelApp:
|
||||
|
|
@ -614,19 +658,19 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
|||
}
|
||||
|
||||
case .nodeInfo(let nodeInfo):
|
||||
handleNodeInfo(nodeInfo)
|
||||
await handleNodeInfo(nodeInfo)
|
||||
|
||||
case .channel(let channel):
|
||||
handleChannel(channel)
|
||||
await handleChannel(channel)
|
||||
|
||||
case .config(let config):
|
||||
handleConfig(config)
|
||||
await handleConfig(config)
|
||||
|
||||
case .moduleConfig(let moduleConfig):
|
||||
handleModuleConfig(moduleConfig)
|
||||
await handleModuleConfig(moduleConfig)
|
||||
|
||||
case .metadata(let metadata):
|
||||
handleDeviceMetadata(metadata)
|
||||
await handleDeviceMetadata(metadata)
|
||||
|
||||
case .deviceuiConfig:
|
||||
#if DEBUG
|
||||
|
|
|
|||
|
|
@ -191,20 +191,26 @@ actor BLEConnection: Connection {
|
|||
}
|
||||
|
||||
func connect() async throws -> AsyncStream<ConnectionEvent> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
546
Meshtastic/Helpers/TAK/CoTMessage.swift
Normal file
546
Meshtastic/Helpers/TAK/CoTMessage.swift
Normal file
|
|
@ -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: "<actual_device_callsign>|<messageId>" or just "<actual_device_callsign>"
|
||||
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 = "<?xml version='1.0' encoding='UTF-8' standalone='yes'?>"
|
||||
cot += "<event version='2.0' uid='\(uid.xmlEscaped)' "
|
||||
cot += "type='\(type)' "
|
||||
cot += "time='\(dateFormatter.string(from: time))' "
|
||||
cot += "start='\(dateFormatter.string(from: start))' "
|
||||
cot += "stale='\(dateFormatter.string(from: stale))' "
|
||||
cot += "how='\(how)'>"
|
||||
cot += "<point lat='\(latitude)' lon='\(longitude)' "
|
||||
cot += "hae='\(hae)' ce='\(ce)' le='\(le)'/>"
|
||||
cot += "<detail>"
|
||||
|
||||
// Contact element
|
||||
if let contact {
|
||||
cot += "<contact endpoint='\(contact.endpoint ?? "0.0.0.0:4242:tcp")' "
|
||||
cot += "callsign='\(contact.callsign.xmlEscaped)'/>"
|
||||
cot += "<uid Droid='\(contact.callsign.xmlEscaped)'/>"
|
||||
}
|
||||
|
||||
// Group element
|
||||
if let group {
|
||||
cot += "<__group role='\(group.role.xmlEscaped)' name='\(group.name.xmlEscaped)'/>"
|
||||
}
|
||||
|
||||
// Status element
|
||||
if let status {
|
||||
cot += "<status battery='\(status.battery)'/>"
|
||||
}
|
||||
|
||||
// Track element
|
||||
if let track {
|
||||
cot += "<track course='\(track.course)' speed='\(track.speed)'/>"
|
||||
}
|
||||
|
||||
// Chat elements (for b-t-f messages)
|
||||
if let chat {
|
||||
// Derive sender UID and messageId from GeoChat UID when possible, with safe fallbacks
|
||||
let senderUid: String
|
||||
let messageId: String
|
||||
|
||||
if uid.hasPrefix("GeoChat.") {
|
||||
let components = uid.split(separator: ".")
|
||||
if components.count >= 3 {
|
||||
// Expected GeoChat format: GeoChat.<senderUid>.<messageId>
|
||||
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 += "<chatgrp uid0='\(senderUid.xmlEscaped)' "
|
||||
cot += "uid1='\(chat.chatroom.xmlEscaped)' id='\(chat.chatroom.xmlEscaped)'/>"
|
||||
cot += "</__chat>"
|
||||
cot += "<link uid='\(senderUid.xmlEscaped)' type='a-f-G-U-C' relation='p-p'/>"
|
||||
cot += "<__serverdestination destinations='0.0.0.0:4242:tcp:\(senderUid.xmlEscaped)'/>"
|
||||
cot += "<remarks source='BAO.F.ATAK.\(senderUid.xmlEscaped)' "
|
||||
cot += "to='\(chat.chatroom.xmlEscaped)' "
|
||||
cot += "time='\(dateFormatter.string(from: time))'>"
|
||||
cot += "\(chat.message.xmlEscaped)</remarks>"
|
||||
} else if let remarks, !remarks.isEmpty {
|
||||
cot += "<remarks>\(remarks.xmlEscaped)</remarks>"
|
||||
}
|
||||
|
||||
// Include raw detail XML for generic CoT elements (colors, shapes, labels, etc.)
|
||||
// This preserves elements we don't explicitly parse
|
||||
if let rawDetailXML, !rawDetailXML.isEmpty {
|
||||
cot += rawDetailXML
|
||||
}
|
||||
|
||||
cot += "</detail></event>"
|
||||
|
||||
return cot
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
/// Contact information for a CoT event
|
||||
struct CoTContact: Sendable, Equatable {
|
||||
var callsign: String
|
||||
var endpoint: String?
|
||||
var phone: String?
|
||||
|
||||
init(callsign: String, endpoint: String? = nil, phone: String? = nil) {
|
||||
self.callsign = callsign
|
||||
self.endpoint = endpoint
|
||||
self.phone = phone
|
||||
}
|
||||
}
|
||||
|
||||
/// Group/team assignment for a CoT event
|
||||
struct CoTGroup: Sendable, Equatable {
|
||||
/// Team color name (e.g., "Cyan", "Green", "Red")
|
||||
var name: String
|
||||
/// Role name (e.g., "Team Member", "Team Lead")
|
||||
var role: String
|
||||
|
||||
init(name: String, role: String) {
|
||||
self.name = name
|
||||
self.role = role
|
||||
}
|
||||
}
|
||||
|
||||
/// Device status for a CoT event
|
||||
struct CoTStatus: Sendable, Equatable {
|
||||
var battery: Int
|
||||
|
||||
init(battery: Int) {
|
||||
self.battery = battery
|
||||
}
|
||||
}
|
||||
|
||||
/// Movement track for a CoT event
|
||||
struct CoTTrack: Sendable, Equatable {
|
||||
var speed: Double
|
||||
var course: Double
|
||||
|
||||
init(speed: Double, course: Double) {
|
||||
self.speed = speed
|
||||
self.course = course
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat message details for a CoT event
|
||||
struct CoTChat: Sendable, Equatable {
|
||||
var message: String
|
||||
var senderCallsign: String?
|
||||
var chatroom: String
|
||||
|
||||
init(message: String, senderCallsign: String? = nil, chatroom: String = "All Chat Rooms") {
|
||||
self.message = message
|
||||
self.senderCallsign = senderCallsign
|
||||
self.chatroom = chatroom
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String Extension for XML Escaping
|
||||
|
||||
extension String {
|
||||
/// Escape special XML characters
|
||||
var xmlEscaped: String {
|
||||
self.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", 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
|
||||
}
|
||||
}
|
||||
}
|
||||
335
Meshtastic/Helpers/TAK/CoTXMLParser.swift
Normal file
335
Meshtastic/Helpers/TAK/CoTXMLParser.swift
Normal file
|
|
@ -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<String> = [
|
||||
"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 <detail> 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 += "</\(elementName)>"
|
||||
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()
|
||||
}
|
||||
}
|
||||
148
Meshtastic/Helpers/TAK/EXICodec.swift
Normal file
148
Meshtastic/Helpers/TAK/EXICodec.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
630
Meshtastic/Helpers/TAK/FountainCodec.swift
Normal file
630
Meshtastic/Helpers/TAK/FountainCodec.swift
Normal file
|
|
@ -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<Int>
|
||||
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..<blocksToSend {
|
||||
let seed = generateSeed(transferId: transferId, blockIndex: i)
|
||||
|
||||
// Generate indices - must match Android's algorithm exactly
|
||||
let indices = generateBlockIndices(seed: seed, K: K, blockIndex: i)
|
||||
|
||||
Logger.tak.debug("Fountain block \(i): seed=\(seed), degree=\(indices.count), indices=\(indices.sorted())")
|
||||
|
||||
// XOR selected source blocks together
|
||||
var blockPayload = Data(repeating: 0, count: FountainConstants.blockSize)
|
||||
for idx in indices {
|
||||
let before = blockPayload.prefix(4).map { String(format: "%02X", $0) }.joined()
|
||||
blockPayload = xor(blockPayload, sourceBlocks[idx])
|
||||
let after = blockPayload.prefix(4).map { String(format: "%02X", $0) }.joined()
|
||||
Logger.tak.debug(" XOR with sourceBlock[\(idx)]: \(before) → \(after)")
|
||||
}
|
||||
|
||||
// Log final payload hash
|
||||
let payloadHash = blockPayload.prefix(8).map { String(format: "%02X", $0) }.joined()
|
||||
Logger.tak.debug(" Final payload first 8 bytes: \(payloadHash)")
|
||||
|
||||
// Build data block packet
|
||||
let packet = buildDataBlock(
|
||||
transferId: transferId,
|
||||
seed: seed,
|
||||
K: UInt8(K),
|
||||
totalLength: UInt16(data.count),
|
||||
payload: blockPayload
|
||||
)
|
||||
packets.append(packet)
|
||||
}
|
||||
|
||||
Logger.tak.info("Fountain encode: \(data.count) bytes → \(K) source blocks → \(blocksToSend) packets")
|
||||
return packets
|
||||
}
|
||||
|
||||
/// Split data into K blocks, padding the last block with zeros
|
||||
// swiftlint:disable:next identifier_name
|
||||
private func splitIntoBlocks(data: Data, K: Int) -> [Data] {
|
||||
var blocks: [Data] = []
|
||||
for i in 0..<K {
|
||||
let start = i * FountainConstants.blockSize
|
||||
let end = min(start + FountainConstants.blockSize, data.count)
|
||||
|
||||
var block: Data
|
||||
if start < data.count {
|
||||
// IMPORTANT: Use Data() to rebase indices to 0
|
||||
// Data slices keep original indices which causes crashes
|
||||
block = Data(data[start..<end])
|
||||
// Pad if necessary
|
||||
if block.count < FountainConstants.blockSize {
|
||||
block.append(Data(repeating: 0, count: FountainConstants.blockSize - block.count))
|
||||
}
|
||||
} else {
|
||||
block = Data(repeating: 0, count: FountainConstants.blockSize)
|
||||
}
|
||||
blocks.append(block)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
/// Build a fountain data block packet
|
||||
// swiftlint:disable:next identifier_name
|
||||
private func buildDataBlock(transferId: UInt32, seed: UInt16, K: UInt8, totalLength: UInt16, payload: 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))
|
||||
|
||||
// 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..<workingBlocks.count {
|
||||
var block = workingBlocks[i]
|
||||
|
||||
// Remove already-decoded indices by XORing out their data
|
||||
for idx in block.indices {
|
||||
if let decodedBlock = decoded[idx] {
|
||||
block.payload = xor(block.payload, decodedBlock)
|
||||
block.indices.remove(idx)
|
||||
}
|
||||
}
|
||||
workingBlocks[i] = block
|
||||
|
||||
// If only one unknown remains, we can decode it
|
||||
if block.indices.count == 1 {
|
||||
let idx = block.indices.first!
|
||||
decoded[idx] = block.payload
|
||||
progress = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if complete
|
||||
guard decoded.count >= 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..<state.K {
|
||||
if let block = decoded[i] {
|
||||
result.append(block)
|
||||
} else {
|
||||
Logger.tak.warning("Missing block \(i) in decoded data")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Trim to original length
|
||||
return Data(result.prefix(state.totalLength))
|
||||
}
|
||||
|
||||
// MARK: - Helper Functions
|
||||
|
||||
/// Get adaptive overhead based on K
|
||||
// swiftlint:disable:next identifier_name
|
||||
private func getAdaptiveOverhead(_ K: Int) -> 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<Int> {
|
||||
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<Int> {
|
||||
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<Int> {
|
||||
var indices = Set<Int>()
|
||||
|
||||
// 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..<result.count {
|
||||
let byteA = i < aData.count ? aData[i] : 0
|
||||
let byteB = i < bData.count ? bData[i] : 0
|
||||
result[i] = byteA ^ byteB
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Compute SHA-256 hash (first 8 bytes for ACK)
|
||||
static func computeHash(_ data: Data) -> 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
399
Meshtastic/Helpers/TAK/GenericCoTHandler.swift
Normal file
399
Meshtastic/Helpers/TAK/GenericCoTHandler.swift
Normal file
|
|
@ -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)..<UInt32.max)
|
||||
meshPacket.decoded = dataMessage
|
||||
|
||||
var toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
try await accessoryManager.send(toRadio, debugDescription: "Generic CoT (direct)")
|
||||
|
||||
Logger.tak.info("Sent generic CoT directly: \(payload.count) bytes on port 257")
|
||||
}
|
||||
|
||||
/// Send large payload using fountain coding
|
||||
private func sendFountainCoded(_ 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
|
||||
}
|
||||
|
||||
let transferId = FountainCodec.shared.generateTransferId()
|
||||
let packets = FountainCodec.shared.encode(data: payload, transferId: transferId)
|
||||
|
||||
Logger.tak.info("Sending fountain-coded CoT: \(payload.count) bytes → \(packets.count) blocks, xferId=\(transferId)")
|
||||
|
||||
// Track pending transfer
|
||||
pendingTransfers[transferId] = PendingTransfer(
|
||||
transferId: transferId,
|
||||
totalBlocks: packets.count,
|
||||
dataHash: FountainCodec.computeHash(payload)
|
||||
)
|
||||
|
||||
// Send all blocks with inter-packet delay
|
||||
for (index, packetData) in packets.enumerated() {
|
||||
var dataMessage = DataMessage()
|
||||
dataMessage.portnum = .atakForwarder
|
||||
dataMessage.payload = packetData
|
||||
|
||||
var meshPacket = MeshPacket()
|
||||
meshPacket.to = 0xFFFFFFFF
|
||||
meshPacket.from = UInt32(deviceNum)
|
||||
meshPacket.channel = channel
|
||||
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
|
||||
meshPacket.decoded = dataMessage
|
||||
|
||||
var toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
try await accessoryManager.send(toRadio, debugDescription: "Fountain block \(index + 1)/\(packets.count)")
|
||||
|
||||
// Inter-packet delay (100ms default, could be adjusted based on modem preset)
|
||||
if index < packets.count - 1 {
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.tak.info("Fountain transfer \(transferId) complete: sent \(packets.count) blocks")
|
||||
}
|
||||
|
||||
// MARK: - Receiving Generic CoT
|
||||
|
||||
/// Handle incoming ATAK_FORWARDER packet (port 257)
|
||||
/// - Parameters:
|
||||
/// - packet: The mesh packet
|
||||
/// - Returns: Decoded CoT message if successful
|
||||
func handleIncomingForwarderPacket(_ packet: MeshPacket) -> 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)..<UInt32.max)
|
||||
meshPacket.decoded = dataMessage
|
||||
|
||||
var toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
|
||||
do {
|
||||
try await accessoryManager.send(toRadio, debugDescription: "Fountain ACK")
|
||||
Logger.tak.debug("Sent fountain ACK for transfer \(transferId)")
|
||||
} catch {
|
||||
Logger.tak.warning("Failed to send fountain ACK: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming fountain ACK
|
||||
func handleIncomingAck(_ payload: Data, from nodeNum: UInt32) {
|
||||
guard let ack = FountainCodec.shared.parseAck(payload) else {
|
||||
Logger.tak.debug("Failed to parse fountain ACK from node \(nodeNum)")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.tak.debug("Received fountain ACK: xferId=\(ack.transferId), type=\(ack.type), from node \(nodeNum)")
|
||||
|
||||
if let pending = pendingTransfers[ack.transferId] {
|
||||
if ack.type == FountainConstants.ackTypeComplete {
|
||||
// Verify hash matches
|
||||
if ack.dataHash == pending.dataHash {
|
||||
Logger.tak.info("Fountain transfer \(ack.transferId) acknowledged by node \(nodeNum)")
|
||||
} else {
|
||||
Logger.tak.warning("Fountain ACK hash mismatch for transfer \(ack.transferId)")
|
||||
}
|
||||
pendingTransfers.removeValue(forKey: ack.transferId)
|
||||
} else if ack.type == FountainConstants.ackTypeNeedMore {
|
||||
Logger.tak.debug("Node \(nodeNum) needs \(ack.needed) more blocks for transfer \(ack.transferId)")
|
||||
// TODO: Send additional blocks
|
||||
}
|
||||
} else {
|
||||
// No pending transfer - might be echo of our own ACK or already completed
|
||||
Logger.tak.debug("Received ACK for unknown/completed transfer \(ack.transferId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
/// Tracks a pending outgoing fountain transfer
|
||||
private struct PendingTransfer {
|
||||
let transferId: UInt32
|
||||
let totalBlocks: Int
|
||||
let dataHash: Data
|
||||
let startTime: Date = Date()
|
||||
}
|
||||
|
||||
/// Errors for generic CoT handling
|
||||
enum GenericCoTError: LocalizedError {
|
||||
case notConnected
|
||||
case noDeviceNumber
|
||||
case compressionFailed
|
||||
case encodingFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected:
|
||||
return "Not connected to Meshtastic device"
|
||||
case .noDeviceNumber:
|
||||
return "No device number available"
|
||||
case .compressionFailed:
|
||||
return "Failed to compress CoT to EXI"
|
||||
case .encodingFailed:
|
||||
return "Failed to encode CoT for transmission"
|
||||
}
|
||||
}
|
||||
}
|
||||
271
Meshtastic/Helpers/TAK/MeshToCoTConverter.swift
Normal file
271
Meshtastic/Helpers/TAK/MeshToCoTConverter.swift
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
//
|
||||
// MeshToCoTConverter.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Converts Meshtastic packets to CoT format for TAK Server
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MeshtasticProtobufs
|
||||
import CoreLocation
|
||||
import OSLog
|
||||
import Combine
|
||||
|
||||
/// Converts Meshtastic packets to CoT format for bridging to TAK Server
|
||||
final class MeshToCoTConverter: ObservableObject {
|
||||
|
||||
static let shared = MeshToCoTConverter()
|
||||
|
||||
private let logger = Logger(subsystem: "Meshtastic", category: "MeshToCoT")
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Position // MARK: Packet to CoT
|
||||
|
||||
/// Convert a Meshtastic position packet to CoT message
|
||||
func convertPosition(_ position: Position, from node: NodeInfoEntity) -> CoTMessage? {
|
||||
guard let user = node.user else {
|
||||
logger.warning("Cannot convert position: node has no user info")
|
||||
return nil
|
||||
}
|
||||
|
||||
let callsign = user.longName ?? user.shortName ?? "Unknown"
|
||||
let uid = "MESHTASTIC-\(node.num.toHex())"
|
||||
|
||||
let latitude = Double(position.latitudeI) / 1e7
|
||||
let longitude = Double(position.longitudeI) / 1e7
|
||||
let altitude = Double(position.altitude)
|
||||
|
||||
var speed: Double = 0
|
||||
var course: Double = 0
|
||||
if position.speed != 0 {
|
||||
speed = Double(position.speed) * 0.194384 // Convert to knots
|
||||
}
|
||||
if position.heading != 0 {
|
||||
course = Double(position.heading)
|
||||
}
|
||||
|
||||
let battery = Int(position.batteryLevel)
|
||||
|
||||
return CoTMessage.pli(
|
||||
uid: uid,
|
||||
callsign: callsign,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
altitude: altitude,
|
||||
speed: speed,
|
||||
course: course,
|
||||
team: "Meshtastic",
|
||||
role: "Team Member",
|
||||
battery: battery > 0 ? battery : 100,
|
||||
staleMinutes: 10
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Node Info to CoT
|
||||
|
||||
/// Convert node info to CoT message (for node presence updates)
|
||||
func convertNodeInfo(_ node: NodeInfoEntity) -> CoTMessage? {
|
||||
guard let user = node.user else {
|
||||
logger.warning("Cannot convert node info: node has no user info")
|
||||
return nil
|
||||
}
|
||||
|
||||
let callsign = user.longName ?? user.shortName ?? "Unknown"
|
||||
let uid = "MESHTASTIC-\(node.num.toHex())"
|
||||
|
||||
var latitude = 0.0
|
||||
var longitude = 0.0
|
||||
var altitude = 9999999.0
|
||||
|
||||
if let position = node.position {
|
||||
latitude = Double(position.latitudeI) / 1e7
|
||||
longitude = Double(position.longitudeI) / 1e7
|
||||
if position.altitude != 0 {
|
||||
altitude = Double(position.altitude)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine CoT type based on device role
|
||||
let cotType = getCoTTypeForRole(user.role)
|
||||
|
||||
let now = Date()
|
||||
return CoTMessage(
|
||||
uid: uid,
|
||||
type: cotType,
|
||||
time: now,
|
||||
start: now,
|
||||
stale: now.addingTimeInterval(3600), // 1 hour stale for node info
|
||||
how: "m-g",
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
hae: altitude,
|
||||
ce: 9999999.0,
|
||||
le: 9999999.0,
|
||||
contact: CoTContact(callsign: callsign, endpoint: "0.0.0.0:4242:tcp"),
|
||||
group: CoTGroup(name: "Meshtastic", role: getRoleNameForDeviceRole(user.role)),
|
||||
remarks: "Meshtastic Node: \(callsign)"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Waypoint to CoT
|
||||
|
||||
/// Convert a Meshtastic waypoint to CoT message
|
||||
func convertWaypoint(_ waypoint: Waypoint, from node: NodeInfoEntity?) -> CoTMessage? {
|
||||
let uid = "WAYPOINT-\(waypoint.id)"
|
||||
|
||||
let latitude = Double(waypoint.latitudeI) / 1e7
|
||||
let longitude = Double(waypoint.longitudeI) / 1e7
|
||||
let altitude = waypoint.altitude > 0 ? Double(waypoint.altitude) : 9999999.0
|
||||
|
||||
let name = waypoint.name.isEmpty ? "Unnamed Waypoint" : waypoint.name
|
||||
let description = waypoint.description_p.isEmpty ? "Meshtastic Waypoint" : waypoint.description_p
|
||||
|
||||
// Get emoji based on waypoint icon/expire time
|
||||
let iconEmoji = getEmojiForWaypoint(waypoint)
|
||||
|
||||
// Handle expiry - if expire is 0, never expire. Otherwise use the expire time as Unix timestamp
|
||||
let stale: Date
|
||||
if waypoint.expire == 0 {
|
||||
// Never expire - set to 1 year from now
|
||||
stale = Date().addingTimeInterval(365 * 24 * 60 * 60)
|
||||
} else {
|
||||
// expire is Unix timestamp when waypoint expires
|
||||
let expireDate = Date(timeIntervalSince1970: TimeInterval(waypoint.expire))
|
||||
if expireDate > Date() {
|
||||
stale = expireDate
|
||||
} else {
|
||||
// Already expired, don't broadcast
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return CoTMessage(
|
||||
uid: uid,
|
||||
type: "b-ttf-ff", // Point feature friend - standard CoT type for waypoints/markers
|
||||
time: Date(),
|
||||
start: Date(),
|
||||
stale: stale,
|
||||
how: "m-g",
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
hae: altitude,
|
||||
ce: 100.0,
|
||||
le: 100.0,
|
||||
contact: CoTContact(callsign: "\(iconEmoji) \(name)", endpoint: "0.0.0.0:4242:tcp"),
|
||||
remarks: "\(description)\nCreated by: \(node?.user?.longName ?? "Unknown")"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Text Message to CoT
|
||||
|
||||
/// Convert a Meshtastic text message to CoT chat message
|
||||
func convertTextMessage(_ message: MessageEntity, from sender: NodeInfoEntity) -> CoTMessage? {
|
||||
guard let user = sender.user,
|
||||
let text = message.text else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let senderName = user.longName ?? user.shortName ?? "Unknown"
|
||||
let senderUid = "MESHTASTIC-\(sender.num.toHex())"
|
||||
let messageId = "MSG-\(message.id)"
|
||||
|
||||
return CoTMessage.chat(
|
||||
senderUid: senderUid,
|
||||
senderCallsign: senderName,
|
||||
message: text,
|
||||
chatroom: "Primary"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Get CoT type based on device role
|
||||
private func getCoTTypeForRole(_ role: UInt32) -> String {
|
||||
switch DeviceRoles(rawValue: Int(role)) {
|
||||
case .router, .routerLate:
|
||||
return "a-f-G-E" // Group entity (router)
|
||||
case .tracker:
|
||||
return "a-f-G-T-C" // Ground unit tracker
|
||||
case .tak:
|
||||
return "a-f-G-U-C" // TAK client
|
||||
case .takTracker:
|
||||
return "a-f-G-T-C" // TAK tracker
|
||||
case .sensor:
|
||||
return "a-f-G-s" // Sensor with friendly affiliation
|
||||
case .client, .clientMute, .clientHidden, .lostAndFound:
|
||||
return "a-f-G-U-C" // Friendly ground unit
|
||||
default:
|
||||
return "a-f-G-U-C" // Default to friendly unit
|
||||
}
|
||||
}
|
||||
|
||||
/// Get role name for device role
|
||||
private func getRoleNameForDeviceRole(_ role: UInt32) -> String {
|
||||
switch DeviceRoles(rawValue: Int(role)) {
|
||||
case .router, .routerLate:
|
||||
return "Router"
|
||||
case .tracker:
|
||||
return "Tracker"
|
||||
case .tak:
|
||||
return "TAK"
|
||||
case .takTracker:
|
||||
return "TAK Tracker"
|
||||
case .sensor:
|
||||
return "Sensor"
|
||||
case .client:
|
||||
return "Client"
|
||||
case .clientMute:
|
||||
return "Muted"
|
||||
case .clientHidden:
|
||||
return "Hidden"
|
||||
default:
|
||||
return "User"
|
||||
}
|
||||
}
|
||||
|
||||
/// Get emoji for waypoint based on icon
|
||||
private func getEmojiForWaypoint(_ waypoint: Waypoint) -> String {
|
||||
// Use icon field if available, otherwise use expire time to guess
|
||||
if waypoint.icon != 0 {
|
||||
switch waypoint.icon {
|
||||
case 1: return "📍" // Marker
|
||||
case 2: return "🚗" // Car
|
||||
case 3: return "🚶" // Person
|
||||
case 4: return "🏠" // Home
|
||||
case 5: return "⛺" // Camp
|
||||
case 6: return "⚠️" // Warning
|
||||
case 7: return "🏁" // Flag
|
||||
case 8: return "🔍" // Search
|
||||
case 9: return "🏥" // Medical
|
||||
case 10: return "🔥" // Fire
|
||||
case 11: return "🚁" // Helicopter
|
||||
case 12: return "⛵" // Boat
|
||||
case 13: return "🛸" // UFO
|
||||
default: return "📍"
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback based on name
|
||||
let name = waypoint.name.lowercased()
|
||||
if name.contains("help") || name.contains("emergency") {
|
||||
return "🆘"
|
||||
} else if name.contains("medical") || name.contains("hospital") {
|
||||
return "🏥"
|
||||
} else if name.contains("danger") || name.contains("warning") {
|
||||
return "⚠️"
|
||||
} else if name.contains("camp") {
|
||||
return "⛺"
|
||||
} else if name.contains("home") || name.contains("house") {
|
||||
return "🏠"
|
||||
} else if name.contains("car") || name.contains("vehicle") {
|
||||
return "🚗"
|
||||
} else if name.contains("flag") {
|
||||
return "🏁"
|
||||
} else if name.contains("person") || name.contains("me") {
|
||||
return "🚶"
|
||||
} else {
|
||||
return "📍"
|
||||
}
|
||||
}
|
||||
}
|
||||
788
Meshtastic/Helpers/TAK/TAKCertificateManager.swift
Normal file
788
Meshtastic/Helpers/TAK/TAKCertificateManager.swift
Normal file
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
497
Meshtastic/Helpers/TAK/TAKConnection.swift
Normal file
497
Meshtastic/Helpers/TAK/TAKConnection.swift
Normal file
|
|
@ -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 </event>)
|
||||
/// Implements TAK Protocol negotiation and keepalive
|
||||
actor TAKConnection {
|
||||
private let connection: NWConnection
|
||||
private var messageBuffer = Data()
|
||||
private var readerTask: Task<Void, Never>?
|
||||
private var keepaliveTask: Task<Void, Never>?
|
||||
private var continuation: AsyncStream<TAKConnectionEvent>.Continuation?
|
||||
|
||||
// CoT XML message delimiters (from StreamingCotProtocol.java)
|
||||
private let startTag = "<event".data(using: .utf8)!
|
||||
private let endTag = "</event>".data(using: .utf8)!
|
||||
private let maxMessageSize = 8_388_608 // 8MB max per TAK Server spec
|
||||
|
||||
// Protocol state
|
||||
private var protocolNegotiated = false
|
||||
private let serverUID = "Meshtastic-TAK-Server-\(UUID().uuidString.prefix(8))"
|
||||
|
||||
// Keepalive interval (30 seconds)
|
||||
private let keepaliveInterval: UInt64 = 30_000_000_000 // nanoseconds
|
||||
|
||||
// Client information
|
||||
private(set) var clientInfo: TAKClientInfo?
|
||||
private(set) var isConnected = false
|
||||
|
||||
var endpoint: NWEndpoint {
|
||||
connection.endpoint
|
||||
}
|
||||
|
||||
init(connection: NWConnection) {
|
||||
self.connection = connection
|
||||
}
|
||||
|
||||
/// Start handling the connection and return an event stream
|
||||
func start() -> AsyncStream<TAKConnectionEvent> {
|
||||
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 </event>)
|
||||
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(..<endRange.upperBound)
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure start is before end
|
||||
guard startRange.lowerBound < endRange.lowerBound else {
|
||||
// Malformed, discard up to end tag
|
||||
messageBuffer.removeSubrange(..<endRange.upperBound)
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the complete message
|
||||
let messageData = messageBuffer.subdata(in: startRange.lowerBound..<endRange.upperBound)
|
||||
|
||||
// Remove processed data from buffer
|
||||
messageBuffer.removeSubrange(..<endRange.upperBound)
|
||||
|
||||
// Parse if within size limits
|
||||
if messageData.count <= maxMessageSize {
|
||||
parseAndYieldMessage(messageData)
|
||||
} else {
|
||||
Logger.tak.warning("CoT message too large: \(messageData.count) bytes, discarding")
|
||||
}
|
||||
}
|
||||
|
||||
// Clear buffer if it exceeds max size (malformed data protection)
|
||||
if messageBuffer.count > 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 = """
|
||||
<event version="2.0" uid="\(serverUID)" type="t-x-takp-v" time="\(now)" start="\(now)" stale="\(stale)" how="m-g">
|
||||
<point lat="0" lon="0" hae="0" ce="9999999" le="9999999"/>
|
||||
<detail>
|
||||
<TakControl>
|
||||
<TakProtocolSupport version="0"/>
|
||||
</TakControl>
|
||||
</detail>
|
||||
</event>
|
||||
"""
|
||||
|
||||
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 = """
|
||||
<event version="2.0" uid="\(serverUID)" type="t-x-takp-r" time="\(now)" start="\(now)" stale="\(stale)" how="m-g">
|
||||
<point lat="0" lon="0" hae="0" ce="9999999" le="9999999"/>
|
||||
<detail>
|
||||
<TakControl>
|
||||
<TakResponse status="\(accepted ? "true" : "false")"/>
|
||||
</TakControl>
|
||||
</detail>
|
||||
</event>
|
||||
"""
|
||||
|
||||
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 = """
|
||||
<event version="2.0" uid="takPong" type="t-x-d-d" time="\(now)" start="\(now)" stale="\(stale)" how="m-g">
|
||||
<point lat="0" lon="0" hae="0" ce="9999999" le="9999999"/>
|
||||
<detail/>
|
||||
</event>
|
||||
"""
|
||||
|
||||
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<Void, Error>) 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<Void, Error>) 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
290
Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift
Normal file
290
Meshtastic/Helpers/TAK/TAKDataPackageGenerator.swift
Normal file
|
|
@ -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 """
|
||||
<?xml version='1.0' encoding='ASCII' standalone='yes'?>
|
||||
<preferences>
|
||||
<preference version="1" name="cot_streams">
|
||||
<entry key="count" class="class java.lang.Integer">1</entry>
|
||||
<entry key="description0" class="class java.lang.String">\(escapeXML(description))</entry>
|
||||
<entry key="enabled0" class="class java.lang.Boolean">true</entry>
|
||||
<entry key="connectString0" class="class java.lang.String">\(serverHost):\(port):\(protocolType)</entry>
|
||||
</preference>
|
||||
<preference version="1" name="com.atakmap.app_preferences">
|
||||
<entry key="displayServerConnectionWidget" class="class java.lang.Boolean">true</entry>
|
||||
<entry key="caLocation" class="class java.lang.String">cert/truststore.p12</entry>
|
||||
<entry key="caPassword" class="class java.lang.String">\(serverPassword)</entry>
|
||||
<entry key="certificateLocation" class="class java.lang.String">cert/\(userClientCertFileName)</entry>
|
||||
<entry key="clientPassword" class="class java.lang.String">\(clientPassword)</entry>
|
||||
</preference>
|
||||
</preferences>
|
||||
"""
|
||||
} else {
|
||||
// TCP mode - no certificates needed
|
||||
return """
|
||||
<?xml version='1.0' encoding='ASCII' standalone='yes'?>
|
||||
<preferences>
|
||||
<preference version="1" name="cot_streams">
|
||||
<entry key="count" class="class java.lang.Integer">1</entry>
|
||||
<entry key="description0" class="class java.lang.String">\(escapeXML(description))</entry>
|
||||
<entry key="enabled0" class="class java.lang.Boolean">true</entry>
|
||||
<entry key="connectString0" class="class java.lang.String">\(serverHost):\(port):\(protocolType)</entry>
|
||||
</preference>
|
||||
<preference version="1" name="com.atakmap.app_preferences">
|
||||
<entry key="displayServerConnectionWidget" class="class java.lang.Boolean">true</entry>
|
||||
</preference>
|
||||
</preferences>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
// 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 """
|
||||
<MissionPackageManifest version="2">
|
||||
<Configuration>
|
||||
<Parameter name="uid" value="\(uid)"/>
|
||||
<Parameter name="name" value="Meshtastic_TAK_Server"/>
|
||||
<Parameter name="onReceiveDelete" value="true"/>
|
||||
</Configuration>
|
||||
<Contents>
|
||||
<Content ignore="false" zipEntry="\(prefFileName)"/>
|
||||
<Content ignore="false" zipEntry="truststore.p12"/>
|
||||
<Content ignore="false" zipEntry="\(userClientCertFileName)"/>
|
||||
</Contents>
|
||||
</MissionPackageManifest>
|
||||
"""
|
||||
} else {
|
||||
// TCP mode - just the pref file
|
||||
return """
|
||||
<MissionPackageManifest version="2">
|
||||
<Configuration>
|
||||
<Parameter name="uid" value="\(uid)"/>
|
||||
<Parameter name="name" value="Meshtastic_TAK_Server"/>
|
||||
<Parameter name="onReceiveDelete" value="true"/>
|
||||
</Configuration>
|
||||
<Contents>
|
||||
<Content ignore="false" zipEntry="\(prefFileName)"/>
|
||||
</Contents>
|
||||
</MissionPackageManifest>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
1430
Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift
Normal file
1430
Meshtastic/Helpers/TAK/TAKMeshtasticBridge.swift
Normal file
File diff suppressed because it is too large
Load diff
692
Meshtastic/Helpers/TAK/TAKServerManager.swift
Normal file
692
Meshtastic/Helpers/TAK/TAKServerManager.swift
Normal file
|
|
@ -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<Void, Never>] = [:]
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,8 @@
|
|||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
23
Meshtastic/Resources/Certificates/ca.pem
Normal file
23
Meshtastic/Resources/Certificates/ca.pem
Normal file
|
|
@ -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-----
|
||||
BIN
Meshtastic/Resources/Certificates/client.p12
Normal file
BIN
Meshtastic/Resources/Certificates/client.p12
Normal file
Binary file not shown.
BIN
Meshtastic/Resources/Certificates/server.p12
Normal file
BIN
Meshtastic/Resources/Certificates/server.p12
Normal file
Binary file not shown.
|
|
@ -52,6 +52,7 @@ enum SettingsNavigationState: String {
|
|||
case debugLogs
|
||||
case appFiles
|
||||
case firmwareUpdates
|
||||
case tak
|
||||
}
|
||||
|
||||
struct NavigationState: Hashable {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
567
Meshtastic/Views/Settings/TAKServerConfig.swift
Normal file
567
Meshtastic/Views/Settings/TAKServerConfig.swift
Normal file
|
|
@ -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<ChannelEntity>
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<D: SwiftProtobuf.Decoder>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<D: SwiftProtobuf.Decoder>(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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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")
|
||||
|
|
|
|||
|
|
@ -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<D: SwiftProtobuf.Decoder>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<D: SwiftProtobuf.Decoder>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
493
MeshtasticTests/ConnectViewTests.swift
Normal file
493
MeshtasticTests/ConnectViewTests.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
README.md
76
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)
|
||||
|
|
|
|||
BIN
itak-example-data-package/iphone.p12
Normal file
BIN
itak-example-data-package/iphone.p12
Normal file
Binary file not shown.
12
itak-example-data-package/manifest.xml
Normal file
12
itak-example-data-package/manifest.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<MissionPackageManifest version="2">
|
||||
<Configuration>
|
||||
<Parameter name="uid" value="bcfaa4a5-2224-4095-bbe3-fdaa22a82741"/>
|
||||
<Parameter name="name" value="testbox_DP"/>
|
||||
<Parameter name="onReceiveDelete" value="true"/>
|
||||
</Configuration>
|
||||
<Contents>
|
||||
<Content ignore="false" zipEntry="taky-server.pref"/>
|
||||
<Content ignore="false" zipEntry="server.p12"/>
|
||||
<Content ignore="false" zipEntry="iphone.p12"/>
|
||||
</Contents>
|
||||
</MissionPackageManifest>
|
||||
BIN
itak-example-data-package/server.p12
Normal file
BIN
itak-example-data-package/server.p12
Normal file
Binary file not shown.
16
itak-example-data-package/taky-server.pref
Normal file
16
itak-example-data-package/taky-server.pref
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version='1.0' encoding='ASCII' standalone='yes'?>
|
||||
<preferences>
|
||||
<preference version="1" name="cot_streams">
|
||||
<entry key="count" class="class java.lang.Integer">1</entry>
|
||||
<entry key="description0" class="class java.lang.String">Win10 Taky Server</entry>
|
||||
<entry key="enabled0" class="class java.lang.Boolean">true</entry>
|
||||
<entry key="connectString0" class="class java.lang.String">172.30.254.210:8089:ssl</entry>
|
||||
</preference>
|
||||
<preference version="1" name="com.atakmap.app_preferences">
|
||||
<entry key="displayServerConnectionWidget" class="class java.lang.Boolean">true</entry>
|
||||
<entry key="caLocation" class="class java.lang.String">server.p12</entry>
|
||||
<entry key="caPassword" class="class java.lang.String">YOURPASSWORD</entry>
|
||||
<entry key="clientPassword" class="class java.lang.String">YOURPASSWORD</entry>
|
||||
<entry key="certificateLocation" class="class java.lang.String">iphone.p12</entry>
|
||||
</preference>
|
||||
</preferences>
|
||||
Loading…
Add table
Add a link
Reference in a new issue