Show role-specific feature commands in firmware picker

This commit is contained in:
mikecarper committed 2026-09-06 04:52:02 -07:00
1 parent 58643258db
commit b781b3df89
7 files changed
+10358 -15

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Generate release-bound picker controls from qualified manifests and resolved PIO config.
Resolve PlatformIO configuration separately, with no other PIO process running:
pio project config --json-output > /tmp/pio-config.json
"""
import argparse
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def generate(stage, config):
plan = json.loads((stage / 'release-plan.json').read_text())
envs = {name: dict(options) for name, options in config}
profiles = {}
for group in plan['groups']:
for manifest in json.loads((stage / group['key'] / 'TARGET-MANIFEST.json').read_text()):
if not manifest.get('verified'):
raise ValueError('Unqualified target: ' + manifest['target'])
env = envs['env:' + manifest['platformio_env']]
flags = env.get('build_flags', [])
defines = {}
for flag in flags:
undefine = re.match(r'-U\s*(\w+)$', flag.strip())
if undefine:
defines.pop(undefine[1], None)
match = re.match(r'-D\s*([\w]+)(?:=(.*))?$', flag.strip())
if match:
defines[match[1]] = match[2] or '1'
def enabled(name):
return name in defines and defines[name] != '0'
board_source = ''
for flag in flags:
match = re.fullmatch(r'-I\s+(variants/[\w.-]+)', flag.strip())
if match:
for file in sorted((ROOT / match[1]).glob('*')):
if file.suffix in {'.h', '.cpp'}:
board_source += file.read_text(errors='replace')
caps = set(manifest['capabilities'])
controls = {
'platform': manifest['platform'],
'gps': enabled('ENV_INCLUDE_GPS'),
'display': defines.get('DISPLAY_CLASS', 'NullDisplayDriver') != 'NullDisplayDriver',
'rxgain': enabled('SX126X_RX_BOOSTED_GAIN') or enabled('LR1110_RX_BOOSTED_GAIN'),
'rxps': 'SX126' in defines.get('RADIO_CLASS', ''),
'femRx': bool(re.search(r'bool\s+\w*(?:::)?setLoRaFemLnaEnabled\s*\(', board_source)),
'femTx': bool(re.search(r'bool\s+\w*(?:::)?setLoRaFemPaGainEnabled\s*\(', board_source)),
'webconfig': 'web.webconfig' in caps,
'mqtt': enabled('WITH_MQTT_BRIDGE'),
'rs232': enabled('WITH_RS232_BRIDGE'),
'espnowBridge': enabled('WITH_ESPNOW_BRIDGE'),
'primaryEspnow': enabled('MESH_PRIMARY_ESPNOW'),
'snmp': enabled('WITH_SNMP'),
'updateMethods': manifest.get('ota_update_methods', []),
}
for name in manifest['files']:
if not name.endswith(('.bin', '.uf2', '.zip', '.hex')):
continue
# Match parseFirmwareAsset's target extraction, including the
# separate -ota- marker and firmware's unchanged source tag.
target = re.split(r'-(?:ota-)?v\d+\.', name, maxsplit=1)[0]
if target in profiles and profiles[target] != controls:
raise ValueError('Conflicting target metadata: ' + target)
profiles[target] = controls
family = next(g['tag'] for g in plan['groups'] if g['key'] == 'companion')
return {'familyTag': family, 'source': plan['source'], 'profiles': profiles}
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--stage', type=Path, required=True)
parser.add_argument('--pio-config', type=Path, required=True)
parser.add_argument('--output', type=Path, default=ROOT / 'docs/_data/firmware_controls.json')
args = parser.parse_args()
result = generate(args.stage, json.loads(args.pio_config.read_text()))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, indent=2, sort_keys=True) + '\n')
print(f"Wrote controls for {len(result['profiles'])} qualified profiles")
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Package the web picker and release-bound controls as one offline HTML file."""
import argparse
import html
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def package(releases, controls):
if not any(r['tag_name'] == controls['familyTag'] for r in releases):
raise ValueError('Controls must match the supplied release family')
# Retain only data used by the picker; never embed authentication or API metadata.
compact = []
for release in releases:
row = {k: release.get(k) for k in ('name', 'tag_name', 'html_url', 'published_at', 'created_at', 'draft', 'prerelease')}
row['assets'] = [{k: asset[k] for k in ('name', 'browser_download_url', 'size')}
for asset in release['assets']]
compact.append(row)
markdown = (ROOT / 'docs/firmware_picker.md').read_text()
form = markdown[markdown.index('<div class="firmware-picker"'):markdown.index('\n## What the choices mean')]
form = form.replace('Loading release information...', 'Loading embedded release information...')
payload = json.dumps({'releases': compact, 'controls': controls}).replace('<', '\\u003c')
css = (ROOT / 'docs/_stylesheets/firmware_picker.css').read_text()
js = (ROOT / 'docs/_javascript/firmware_picker.js').read_text()
title = html.escape('MeshCore firmware picker — ' + controls['familyTag'])
return ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
f'<title>{title}</title><style>'
':root{--md-default-bg-color:white;--md-code-bg-color:#f3f5f7;--md-default-fg-color--light:#555}'
'body{font:16px system-ui;max-width:1200px;margin:2rem auto;padding:0 1rem;color:#222}'
'a{color:#07695d}button,summary{cursor:pointer}[hidden]{display:none!important}'
+ css + '</style></head><body><h1>' + title + '</h1>'
'<p>Choose your exact hardware and desired settings. Release metadata and '
'command directions are included in this file; downloads and the USB '
'web console require an internet connection.</p>' + form
+ '<script type="application/json" id="firmware-picker-data">' + payload + '</script>'
+ '<script>' + js.replace('</script', '<\\/script') + '</script></body></html>\n')
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--releases-json', type=Path, required=True, help='JSON array of public GitHub releases')
parser.add_argument('--controls-json', type=Path, default=ROOT / 'docs/_data/firmware_controls.json')
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(package(json.loads(args.releases_json.read_text()), json.loads(args.controls_json.read_text())))
print('Wrote ' + str(args.output))