From 9583aad99fde4bb03217d3a43bda937c6090e6b0 Mon Sep 17 00:00:00 2001 From: iceice400 Date: Sun, 7 Jun 2026 21:10:52 -0400 Subject: [PATCH 1/2] feat(config): Quick Deploy QR export of public channel params --- docs/CONFIGURATION.md | 9 ++ frontend/css/configuration.css | 66 ++++++++ frontend/index.html | 2 + .../js/configuration/configuration_panel.js | 18 ++- .../js/configuration/quick_deploy_card.js | 142 ++++++++++++++++++ src/api/routes/config_routes.py | 12 ++ src/config_export.py | 110 ++++++++++++++ tests/test_config_export.py | 64 ++++++++ 8 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 frontend/js/configuration/quick_deploy_card.js create mode 100644 src/config_export.py create mode 100644 tests/test_config_export.py diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f12b077d..0f929811 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 diff --git a/frontend/css/configuration.css b/frontend/css/configuration.css index 70b484c8..a6c9a4ef 100644 --- a/frontend/css/configuration.css +++ b/frontend/css/configuration.css @@ -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: rgba(243, 244, 246, 0.55); + text-align: center; +} + +.cfg-quick-deploy__url { + margin: 0 0 0.5rem; + font-family: 'JetBrains Mono', Menlo, monospace; + font-size: 10px; + word-break: break-all; + color: #94a3b8; +} + +.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: rgba(243, 244, 246, 0.55); +} + +.cfg-quick-deploy__val { + font-family: 'JetBrains Mono', Menlo, monospace; + color: #e2e8f0; +} + +.cfg-card--quick-deploy .cfg-card__actions { + grid-column: 1 / -1; +} + +@media (max-width: 720px) { + .cfg-quick-deploy { + grid-template-columns: 1fr; + } +} diff --git a/frontend/index.html b/frontend/index.html index 239398cc..af491b29 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -710,6 +710,8 @@

Service actions

+ + diff --git a/frontend/js/configuration/configuration_panel.js b/frontend/js/configuration/configuration_panel.js index 6972cc18..942b0035 100644 --- a/frontend/js/configuration/configuration_panel.js +++ b/frontend/js/configuration/configuration_panel.js @@ -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 = '
'; + 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'); diff --git a/frontend/js/configuration/quick_deploy_card.js b/frontend/js/configuration/quick_deploy_card.js new file mode 100644 index 00000000..297afdf6 --- /dev/null +++ b/frontend/js/configuration/quick_deploy_card.js @@ -0,0 +1,142 @@ +/** + * 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 = ` +
+
+

Quick Deploy

+

+ Share public channel settings with field radios. + Uses the standard Meshtastic default key only — + private channel PSKs are never exported. +

+
+
+
+

Loading…

+
+
+
+ + +
+

+
+
+ `; + + 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` : '—'], + ['Hop limit', data.hop_limit], + ]; + this._metaEl.innerHTML = rows.map(([k, v]) => ` +
+ ${this._api.escape(k)} + ${this._api.escape(String(v ?? '—'))} +
+ `).join(''); + } + + async _paintQr(url) { + if (!url) { + this._qrHost.innerHTML = '

No URL

'; + return; + } + if (typeof window.QRCode === 'undefined') { + this._qrHost.innerHTML = ` +

${this._api.escape(url)}

+

QR library unavailable — use Copy URL.

+ `; + return; + } + this._qrHost.innerHTML = ''; + const canvas = this._qrHost.querySelector('[data-qr-canvas]'); + try { + await window.QRCode.toCanvas(canvas, url, { + width: 220, + margin: 1, + color: { dark: '#e2e8f0', light: '#0f1218' }, + }); + } catch (e) { + console.error('QR render failed:', e); + this._qrHost.innerHTML = `

QR render failed.

`; + } + } + + 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; diff --git a/src/api/routes/config_routes.py b/src/api/routes/config_routes.py index c5094526..ad49904e 100644 --- a/src/api/routes/config_routes.py +++ b/src/api/routes/config_routes.py @@ -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, @@ -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 diff --git a/src/config_export.py b/src/config_export.py new file mode 100644 index 00000000..f2f95b4f --- /dev/null +++ b/src/config_export.py @@ -0,0 +1,110 @@ +"""Public Meshtastic Quick Deploy export (QR URL + JSON). + +Builds a standard ``https://meshtastic.org/e/#…`` channel URL using the +Meshpoint's radio region and modem preset with the **public default PSK** +only. Private ``channel_keys`` and non-default secrets are never included. +""" + +from __future__ import annotations + +import base64 + +from meshtastic.protobuf import apponly_pb2, config_pb2 + +from src.config import AppConfig +from src.radio.presets import MODEM_PRESETS, preset_from_params + +# Meshtastic well-known default key (base64 ``AQ==``). +_PUBLIC_PSK = b"\x01" + +_REGION_TO_PROTO: dict[str, str] = { + "US": "US", + "EU_868": "EU_868", + "ANZ": "ANZ", + "IN": "IN", + "KR": "KR", + "SG_923": "SG_923", +} + + +def build_quick_deploy_export(cfg: AppConfig) -> dict: + """Return public channel parameters and a Meshtastic-compatible share URL.""" + radio = cfg.radio + mt = cfg.meshtastic + tx = cfg.transmit + + preset_key = preset_from_params( + radio.spreading_factor, + radio.bandwidth_khz, + radio.coding_rate, + ) or "LONG_FAST" + preset = MODEM_PRESETS.get(preset_key) + display = preset.display_name if preset else preset_key + + channel_name = (mt.primary_channel_name or display or "LongFast").strip() + if len(channel_name) > 11: + channel_name = channel_name[:11] + + channel_set = apponly_pb2.ChannelSet() + lora = channel_set.lora_config + lora.use_preset = True + try: + lora.modem_preset = config_pb2.Config.LoRaConfig.ModemPreset.Value( + preset_key + ) + except ValueError: + lora.modem_preset = config_pb2.Config.LoRaConfig.ModemPreset.LONG_FAST + + region_proto = _REGION_TO_PROTO.get(radio.region or "US", "US") + try: + lora.region = config_pb2.Config.LoRaConfig.RegionCode.Value(region_proto) + except ValueError: + lora.region = config_pb2.Config.LoRaConfig.RegionCode.US + + lora.hop_limit = max(0, min(int(tx.hop_limit or 3), 7)) + lora.tx_enabled = True + + settings = channel_set.settings.add() + settings.name = channel_name + settings.psk = _PUBLIC_PSK + + raw = channel_set.SerializeToString() + fragment = ( + base64.urlsafe_b64encode(raw) + .decode("ascii") + .replace("=", "") + .replace("+", "-") + .replace("/", "_") + ) + meshtastic_url = f"https://meshtastic.org/e/#{fragment}" + + payload = { + "channel_name": channel_name, + "frequency_mhz": radio.frequency_mhz, + "region": radio.region, + "modem_preset": preset_key, + "modem_preset_display": display, + "spreading_factor": radio.spreading_factor, + "bandwidth_khz": radio.bandwidth_khz, + "coding_rate": radio.coding_rate, + "hop_limit": lora.hop_limit, + "meshtastic_url": meshtastic_url, + "psk_included": False, + "device_name": cfg.device.device_name, + "note": ( + "Public default channel only (standard Meshtastic PSK). " + "Private channel keys from this Meshpoint are not exported." + ), + } + _assert_no_secrets(payload) + return payload + + +def _assert_no_secrets(payload: dict) -> None: + """Defensive check: response must not leak configured private keys.""" + if payload.get("psk_included"): + raise ValueError("export must not include private PSK material") + forbidden = {"psk_b64", "default_key_b64", "channel_keys", "private_key"} + for key in payload: + if key in forbidden or key.endswith("_key_b64"): + raise ValueError(f"export must not include secret field: {key}") diff --git a/tests/test_config_export.py b/tests/test_config_export.py new file mode 100644 index 00000000..ff3bd7b5 --- /dev/null +++ b/tests/test_config_export.py @@ -0,0 +1,64 @@ +"""Quick Deploy public channel export.""" + +from __future__ import annotations + +import base64 +import unittest + +from meshtastic.protobuf import apponly_pb2 + +from src.config import AppConfig, MeshtasticConfig, RadioConfig, TransmitConfig +from src.config_export import build_quick_deploy_export + + +class TestConfigExport(unittest.TestCase): + def test_export_uses_public_default_psk_only(self) -> None: + cfg = AppConfig() + cfg.radio = RadioConfig( + region="US", + frequency_mhz=906.875, + spreading_factor=11, + bandwidth_khz=250.0, + coding_rate="4/5", + ) + cfg.meshtastic = MeshtasticConfig( + primary_channel_name="LongFast", + default_key_b64="wLvS00jm+SlCkdkZ6DRZXvLoqoSgPT+3vh8zX+MJoyQ=", + channel_keys={"SecretChat": "anotherBase64PSK=="}, + ) + cfg.transmit = TransmitConfig(hop_limit=3) + + payload = build_quick_deploy_export(cfg) + + self.assertFalse(payload["psk_included"]) + self.assertNotIn("channel_keys", payload) + self.assertNotIn("psk_b64", payload) + self.assertEqual(payload["channel_name"], "LongFast") + self.assertEqual(payload["modem_preset"], "LONG_FAST") + self.assertTrue(payload["meshtastic_url"].startswith("https://meshtastic.org/e/#")) + + fragment = payload["meshtastic_url"].split("/#", 1)[1] + padding = "=" * ((4 - len(fragment) % 4) % 4) + raw = base64.urlsafe_b64decode(fragment + padding) + channel_set = apponly_pb2.ChannelSet() + channel_set.ParseFromString(raw) + self.assertEqual(len(channel_set.settings), 1) + self.assertEqual(channel_set.settings[0].name, "LongFast") + self.assertEqual(bytes(channel_set.settings[0].psk), b"\x01") + + def test_eu_region_maps_in_url(self) -> None: + cfg = AppConfig() + cfg.radio = RadioConfig( + region="EU_868", + frequency_mhz=869.525, + spreading_factor=11, + bandwidth_khz=250.0, + coding_rate="4/5", + ) + payload = build_quick_deploy_export(cfg) + self.assertEqual(payload["region"], "EU_868") + self.assertIn("meshtastic.org/e/#", payload["meshtastic_url"]) + + +if __name__ == "__main__": + unittest.main() From 42a656dc903e631a307ef5474c7ac574a0fa4ecc Mon Sep 17 00:00:00 2001 From: iceice400 Date: Thu, 11 Jun 2026 02:22:02 -0400 Subject: [PATCH 2/2] style(frontend): align Quick Deploy card with design-system tokens Use canonical CSS variables for colors and fonts; replace em dashes in UI copy with colons; read QR canvas colors from :root tokens. Co-authored-by: Cursor --- frontend/css/configuration.css | 12 ++++++------ frontend/js/configuration/quick_deploy_card.js | 18 +++++++++++------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/frontend/css/configuration.css b/frontend/css/configuration.css index a6c9a4ef..a58090a9 100644 --- a/frontend/css/configuration.css +++ b/frontend/css/configuration.css @@ -500,16 +500,16 @@ select.cfg-field__input option:checked { .cfg-quick-deploy__placeholder { margin: 0; font-size: 12px; - color: rgba(243, 244, 246, 0.55); + color: var(--text-muted); text-align: center; } .cfg-quick-deploy__url { margin: 0 0 0.5rem; - font-family: 'JetBrains Mono', Menlo, monospace; + font-family: var(--font-mono); font-size: 10px; word-break: break-all; - color: #94a3b8; + color: var(--text-secondary); } .cfg-quick-deploy__meta { @@ -526,12 +526,12 @@ select.cfg-field__input option:checked { } .cfg-quick-deploy__key { - color: rgba(243, 244, 246, 0.55); + color: var(--text-muted); } .cfg-quick-deploy__val { - font-family: 'JetBrains Mono', Menlo, monospace; - color: #e2e8f0; + font-family: var(--font-mono); + color: var(--text-primary); } .cfg-card--quick-deploy .cfg-card__actions { diff --git a/frontend/js/configuration/quick_deploy_card.js b/frontend/js/configuration/quick_deploy_card.js index 297afdf6..0dcf8748 100644 --- a/frontend/js/configuration/quick_deploy_card.js +++ b/frontend/js/configuration/quick_deploy_card.js @@ -20,7 +20,7 @@ class QuickDeployCard {

Quick Deploy

Share public channel settings with field radios. - Uses the standard Meshtastic default key only — + Uses the standard Meshtastic default key only: private channel PSKs are never exported.

@@ -62,7 +62,7 @@ class QuickDeployCard { await this._paintQr(data.meshtastic_url); this._copyBtn.disabled = !data.meshtastic_url; this._downloadBtn.disabled = false; - this._setStatus('success', 'Ready — scan with Meshtastic app.'); + this._setStatus('success', 'Ready: scan with Meshtastic app.'); } _paintMeta(data) { @@ -70,13 +70,13 @@ class QuickDeployCard { ['Channel', data.channel_name], ['Preset', data.modem_preset_display || data.modem_preset], ['Region', data.region], - ['Frequency', data.frequency_mhz != null ? `${data.frequency_mhz} MHz` : '—'], + ['Frequency', data.frequency_mhz != null ? `${data.frequency_mhz} MHz` : 'n/a'], ['Hop limit', data.hop_limit], ]; this._metaEl.innerHTML = rows.map(([k, v]) => `
${this._api.escape(k)} - ${this._api.escape(String(v ?? '—'))} + ${this._api.escape(String(v ?? 'n/a'))}
`).join(''); } @@ -89,17 +89,21 @@ class QuickDeployCard { if (typeof window.QRCode === 'undefined') { this._qrHost.innerHTML = `

${this._api.escape(url)}

-

QR library unavailable — use Copy URL.

+

QR library unavailable: use Copy URL.

`; return; } this._qrHost.innerHTML = ''; 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: '#e2e8f0', light: '#0f1218' }, + color: { + dark: root.getPropertyValue('--text-primary').trim() || '#e2e8f0', + light: root.getPropertyValue('--bg-primary').trim() || '#0a0e17', + }, }); } catch (e) { console.error('QR render failed:', e); @@ -114,7 +118,7 @@ class QuickDeployCard { await navigator.clipboard.writeText(url); this._api.toast('Channel URL copied.'); } catch (e) { - this._setStatus('error', 'Copy failed — select URL manually.'); + this._setStatus('error', 'Copy failed: select URL manually.'); } }