Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,15 @@ meshtastic:

The default is `LongFast` (Meshtastic's standard public channel). Change it only if your mesh uses a custom primary channel name. You can also edit this from the dashboard: open the **Radio** tab, edit **Channel 0**, and save. The Radio and Messages tabs reflect the same value.

### Quick Deploy (QR export)

**Configuration → Channels → Quick Deploy** exports public channel parameters for field radios:

- QR code and `https://meshtastic.org/e/#…` URL (Meshtastic app compatible)
- Downloadable JSON via `GET /api/config/export`

**Private channel keys are never exported.** The QR uses the standard Meshtastic default PSK only (`AQ==`), matching a public primary channel deployment. Scan with the Meshtastic mobile app (Android in-app scanner; iOS camera).

---

## Private Channel Monitoring
Expand Down
66 changes: 66 additions & 0 deletions frontend/css/configuration.css
Original file line number Diff line number Diff line change
Expand Up @@ -477,3 +477,69 @@ select.cfg-field__input option:checked {
.cfg-mc-channels {
margin-bottom: 14px;
}

.cfg-quick-deploy {
display: grid;
grid-template-columns: auto 1fr;
gap: 1.25rem;
align-items: start;
}

.cfg-quick-deploy__qr {
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 0.75rem;
min-width: 236px;
min-height: 236px;
display: flex;
align-items: center;
justify-content: center;
}

.cfg-quick-deploy__placeholder {
margin: 0;
font-size: 12px;
color: var(--text-muted);
text-align: center;
}

.cfg-quick-deploy__url {
margin: 0 0 0.5rem;
font-family: var(--font-mono);
font-size: 10px;
word-break: break-all;
color: var(--text-secondary);
}

.cfg-quick-deploy__meta {
display: flex;
flex-direction: column;
gap: 0.35rem;
}

.cfg-quick-deploy__row {
display: grid;
grid-template-columns: 6rem 1fr;
gap: 0.5rem;
font-size: 13px;
}

.cfg-quick-deploy__key {
color: var(--text-muted);
}

.cfg-quick-deploy__val {
font-family: var(--font-mono);
color: var(--text-primary);
}

.cfg-card--quick-deploy .cfg-card__actions {
grid-column: 1 / -1;
}

@media (max-width: 720px) {
.cfg-quick-deploy {
grid-template-columns: 1fr;
}
}
2 changes: 2 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,8 @@ <h3 class="dangerous-panel__title">Service actions</h3>
<script src="js/configuration/identity_card.js"></script>
<script src="js/configuration/radio_card.js"></script>
<script src="js/configuration/nodeinfo_config_card.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<script src="js/configuration/quick_deploy_card.js"></script>
<script src="js/configuration/channels_card.js"></script>
<script src="js/configuration/meshcore_card.js"></script>
<script src="js/configuration/transmit_card.js"></script>
Expand Down
18 changes: 13 additions & 5 deletions frontend/js/configuration/configuration_panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,21 @@ class ConfigurationPanel {
this._cards.set('nodeinfo-status', status);
}
}
} else if (section === 'channels' && window.ChannelsConfigCard) {
} else if (section === 'channels') {
const host = document.getElementById('cfg-channels-panel');
if (host) {
host.innerHTML = '';
const card = new window.ChannelsConfigCard(api);
card.mount(host);
this._cards.set('channels', card);
host.innerHTML = '<div class="cfg-section" data-channels-mount></div>';
const mount = host.querySelector('[data-channels-mount]');
if (window.QuickDeployCard) {
const quick = new window.QuickDeployCard(api);
quick.mount(mount);
this._cards.set('quick-deploy', quick);
}
if (window.ChannelsConfigCard) {
const card = new window.ChannelsConfigCard(api);
card.mount(mount);
this._cards.set('channels', card);
}
}
} else if (section === 'meshcore' && window.MeshcoreConfigCard) {
const host = document.getElementById('cfg-meshcore-panel');
Expand Down
146 changes: 146 additions & 0 deletions frontend/js/configuration/quick_deploy_card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Configuration → Channels — Quick Deploy QR export.
*
* Fetches GET /api/config/export and renders a Meshtastic-compatible
* channel URL as QR + downloadable JSON. Private PSKs are never shown.
*/

class QuickDeployCard {
constructor(api) {
this._api = api;
this._root = null;
this._exportData = null;
}

mount(root) {
this._root = root;
this._root.innerHTML = `
<article class="cfg-card cfg-card--quick-deploy">
<header class="cfg-card__head">
<h3 class="cfg-card__title">Quick Deploy</h3>
<p class="cfg-card__hint">
Share public channel settings with field radios.
Uses the standard Meshtastic default key only:
private channel PSKs are never exported.
</p>
</header>
<div class="cfg-quick-deploy">
<div class="cfg-quick-deploy__qr" data-qr-host>
<p class="cfg-quick-deploy__placeholder">Loading…</p>
</div>
<div class="cfg-quick-deploy__meta" data-export-meta></div>
<div class="cfg-card__actions">
<button type="button" class="terminal-button"
data-copy-url disabled>Copy URL</button>
<button type="button" class="terminal-button terminal-button--primary"
data-download-json disabled>Download JSON</button>
</div>
<p class="cfg-status" data-quick-deploy-status aria-live="polite"></p>
</div>
</article>
`;

this._qrHost = this._root.querySelector('[data-qr-host]');
this._metaEl = this._root.querySelector('[data-export-meta]');
this._statusEl = this._root.querySelector('[data-quick-deploy-status]');
this._copyBtn = this._root.querySelector('[data-copy-url]');
this._downloadBtn = this._root.querySelector('[data-download-json]');

this._copyBtn.addEventListener('click', () => this._copyUrl());
this._downloadBtn.addEventListener('click', () => this._downloadJson());
}

async render(_config) {
this._setStatus('pending', 'Loading export…');
const data = await this._api.get('/api/config/export');
if (!data) {
this._setStatus('error', 'Could not load export.');
return;
}
this._exportData = data;
this._paintMeta(data);
await this._paintQr(data.meshtastic_url);
this._copyBtn.disabled = !data.meshtastic_url;
this._downloadBtn.disabled = false;
this._setStatus('success', 'Ready: scan with Meshtastic app.');
}

_paintMeta(data) {
const rows = [
['Channel', data.channel_name],
['Preset', data.modem_preset_display || data.modem_preset],
['Region', data.region],
['Frequency', data.frequency_mhz != null ? `${data.frequency_mhz} MHz` : 'n/a'],
['Hop limit', data.hop_limit],
];
this._metaEl.innerHTML = rows.map(([k, v]) => `
<div class="cfg-quick-deploy__row">
<span class="cfg-quick-deploy__key">${this._api.escape(k)}</span>
<span class="cfg-quick-deploy__val">${this._api.escape(String(v ?? 'n/a'))}</span>
</div>
`).join('');
}

async _paintQr(url) {
if (!url) {
this._qrHost.innerHTML = '<p class="cfg-quick-deploy__placeholder">No URL</p>';
return;
}
if (typeof window.QRCode === 'undefined') {
this._qrHost.innerHTML = `
<p class="cfg-quick-deploy__url">${this._api.escape(url)}</p>
<p class="cfg-quick-deploy__placeholder">QR library unavailable: use Copy URL.</p>
`;
return;
}
this._qrHost.innerHTML = '<canvas data-qr-canvas></canvas>';
const canvas = this._qrHost.querySelector('[data-qr-canvas]');
try {
const root = getComputedStyle(document.documentElement);
await window.QRCode.toCanvas(canvas, url, {
width: 220,
margin: 1,
color: {
dark: root.getPropertyValue('--text-primary').trim() || '#e2e8f0',
light: root.getPropertyValue('--bg-primary').trim() || '#0a0e17',
},
});
} catch (e) {
console.error('QR render failed:', e);
this._qrHost.innerHTML = `<p class="cfg-quick-deploy__placeholder">QR render failed.</p>`;
}
}

async _copyUrl() {
const url = this._exportData && this._exportData.meshtastic_url;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
this._api.toast('Channel URL copied.');
} catch (e) {
this._setStatus('error', 'Copy failed: select URL manually.');
}
}

_downloadJson() {
if (!this._exportData) return;
const blob = new Blob(
[JSON.stringify(this._exportData, null, 2)],
{ type: 'application/json' },
);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'meshpoint-quick-deploy.json';
a.click();
URL.revokeObjectURL(a.href);
this._api.toast('JSON downloaded.');
}

_setStatus(kind, message) {
if (!this._statusEl) return;
this._statusEl.dataset.kind = kind;
this._statusEl.textContent = message;
}
}

window.QuickDeployCard = QuickDeployCard;
12 changes: 12 additions & 0 deletions src/api/routes/config_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from src.api.routes import config_enrichment, mqtt_config_routes, nodeinfo_routes
from src.config import AppConfig, save_section_to_yaml
from src.config_export import build_quick_deploy_export
from src.models.device_identity import DeviceIdentity
from src.radio.presets import (
REGION_DEFAULTS,
Expand Down Expand Up @@ -168,6 +169,17 @@ async def get_config():
return config_enrichment.enrich_config_payload(_config, payload)


@router.get("/export")
async def export_quick_deploy():
"""Public channel parameters + Meshtastic QR URL (no private PSKs)."""
if _config is None:
raise HTTPException(503, "Config not loaded")
try:
return build_quick_deploy_export(_config)
except ValueError as exc:
raise HTTPException(500, str(exc)) from exc


class RelaySettingsUpdate(BaseModel):
enabled: Optional[bool] = None
max_relay_per_minute: Optional[int] = None
Expand Down
Loading
Loading