Merge origin/main into noise-floor, resolve conflicts

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-04-18 23:19:35 +00:00 committed by GitHub
commit 436ba7a03a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 32969 additions and 2960 deletions

View 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,
});
}

File diff suppressed because it is too large Load diff

View file

@ -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 = "";

View file

@ -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"
}
}
],

View file

@ -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")
}

View file

@ -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

View file

@ -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
)
}
}
}
}

View file

@ -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)")
}
}
}
}

View file

@ -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)
}

View file

@ -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

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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] {

View file

@ -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])
}
}
}

View file

@ -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

View 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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
}
// 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
}
}
}

View 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()
}
}

View 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
}
}

View 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)")
}
}
}

View 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"
}
}
}

View 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 "📍"
}
}
}

View 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)"
}
}

View 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)"
}
}
}

View 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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
// 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
}
}
}

File diff suppressed because it is too large Load diff

View 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"
}
}
}

View file

@ -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>

View file

@ -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")

View file

@ -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

View 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-----

Binary file not shown.

Binary file not shown.

View file

@ -52,6 +52,7 @@ enum SettingsNavigationState: String {
case debugLogs
case appFiles
case firmwareUpdates
case tak
}
struct NavigationState: Hashable {

View file

@ -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)
}
}
}

View file

@ -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")
}

View file

@ -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) {

View file

@ -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
}
}

View file

@ -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")
}

View file

@ -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")
}
}
}
}

View file

@ -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")
}
}
}
}

View file

@ -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")
}
}
}
}

View file

@ -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")
}
}
}
}

View file

@ -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")
}
}
}
}

View file

@ -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 {

View file

@ -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")

View file

@ -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)
}

View file

@ -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

View 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)
}
}

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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")

View file

@ -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")

View file

@ -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")
}

View file

@ -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
}
}

View 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)
}
}

View file

@ -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)
}
}

View file

@ -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)

Binary file not shown.

View 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>

Binary file not shown.

View 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>