mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-14 06:36:28 +00:00
Refresh 1.17.1.6 picker metadata and preserve capacity directions
This commit is contained in:
@@ -103,6 +103,7 @@ jobs:
|
||||
- name: Verify logging sleep guards and instructions
|
||||
run: |
|
||||
python3 -B test/test_logging_sleep_contract.py
|
||||
python3 -B test/test_picker_controls_generator.py
|
||||
node test/test_firmware_picker.js
|
||||
|
||||
- name: Verify nRF52 UF2-reset CLI coverage
|
||||
|
||||
+474
-1001
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ The picker reads public release metadata from GitHub. It does not upload device
|
||||
information. Hardware names, target names, and download links come directly
|
||||
from the published firmware assets.
|
||||
|
||||
<div class="firmware-picker" data-firmware-picker data-release-repo="mikecarper/MeshCore" data-controls-url="../_data/firmware_controls.json" data-share-url="https://mikecarper.github.io/MeshCore/firmware_picker/">
|
||||
<div class="firmware-picker" data-firmware-picker data-release-repo="mikecarper/MeshCore" data-controls-url="../_data/firmware_controls.json?v=1.17.1.6" data-share-url="https://mikecarper.github.io/MeshCore/firmware_picker/">
|
||||
<div class="firmware-picker-intro" role="note">
|
||||
<strong>Current release set</strong>
|
||||
<p data-role="release-set">Loading release information...</p>
|
||||
@@ -368,6 +368,9 @@ Hardware-specific controls are enabled only when `_data/firmware_controls.json`
|
||||
matches the selected release family and exact target. If that metadata is
|
||||
missing or belongs to another release, the picker retains basic role/logging
|
||||
directions and links the complete guide without inventing hardware support.
|
||||
Capacity directions come from each qualified profile's recorded reductions and
|
||||
are tied to the source hash of the selected download. The older 1.17.1.5 USB
|
||||
sleep workaround stays limited to that release.
|
||||
|
||||
After qualifying a new release, resolve its PlatformIO configuration with no
|
||||
other PlatformIO process running, then generate the controls from that source
|
||||
@@ -380,6 +383,9 @@ python3 scripts/generate_picker_controls.py \
|
||||
--pio-config /tmp/meshcore-picker-pio-config.json
|
||||
```
|
||||
|
||||
Refresh the `data-controls-url` version query when publishing the generated
|
||||
metadata so browsers fetch the new release's controls.
|
||||
|
||||
For the downloadable version, save the release family's public GitHub release
|
||||
objects as a JSON array, then package the same picker UI and controls:
|
||||
|
||||
|
||||
@@ -12,6 +12,45 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def capacity_note(reductions, defines):
|
||||
notes = []
|
||||
for reduction in reductions:
|
||||
contacts = re.fullmatch(r'companion.capacity limited to (\d+) contacts for runtime RAM; (\d+) queued frames and all Full transports retained', reduction)
|
||||
compact = re.fullmatch(r'companion.capacity limited to (\d+) contacts, (\d+) channels, and (\d+) queued frames by measured internal DRAM', reduction)
|
||||
queue = re.fullmatch(r'nRF52 Full: (\d+) offline frames normally; (\d+) while mOTA borrows queue storage', reduction)
|
||||
paper = re.fullmatch(r'Wireless Paper Full: (\d+) contacts; (\d+) offline frames normally, (\d+) while mOTA borrows queue storage', reduction)
|
||||
neighbors = re.match(r'mesh.neighbors limited to (\d+)\b', reduction)
|
||||
rules = re.match(r'mesh.flood_rules limited to (\d+)\b', reduction)
|
||||
if contacts:
|
||||
count, frames = contacts.groups()
|
||||
notes.append(f'Full Companion capacity: {count} contacts and {frames} queued messages; all Full transports are retained. '
|
||||
f'Export contacts before updating if you have more than {count}; entries beyond this limit may be unavailable or omitted by a later save.')
|
||||
elif compact:
|
||||
count, channels, frames = compact.groups()
|
||||
notes.append(f'Full Companion capacity: {count} contacts, {channels} channels and {frames} queued messages. '
|
||||
'Export contacts and channels before updating if they exceed these limits; extra entries may be unavailable or omitted by a later save.')
|
||||
elif queue or paper:
|
||||
if paper:
|
||||
count, normal, borrowed = paper.groups()
|
||||
channels = defines.get('MAX_GROUP_CHANNELS', '')
|
||||
capacity = f'{count} contacts'
|
||||
if re.fullmatch(r'\d+', channels):
|
||||
capacity += f' and {channels} channels'
|
||||
notes.append(f'Wireless Paper Full capacity: {capacity}.')
|
||||
else:
|
||||
normal, borrowed = queue.groups()
|
||||
notes.append(f'Full Companion queue: {normal} offline messages normally; {borrowed} while mOTA borrows queue storage. '
|
||||
f'Sync unread messages with a Companion app before starting mOTA if more than {borrowed} are pending. '
|
||||
f'Stopping or disconnecting the source restores all {normal} slots.')
|
||||
elif neighbors:
|
||||
notes.append(f'Neighbor table: {neighbors[1]} entries.')
|
||||
elif rules:
|
||||
notes.append(f'Flood rules: {rules[1]} entries; the complete rule engine is retained.')
|
||||
elif reduction.startswith(('companion.capacity', 'nRF52 Full:', 'Wireless Paper Full:', 'mesh.neighbors', 'mesh.flood_rules')):
|
||||
raise ValueError('Unrecognized capacity reduction: ' + reduction)
|
||||
return ' '.join(notes)
|
||||
|
||||
|
||||
def generate(stage, config):
|
||||
plan = json.loads((stage / 'release-plan.json').read_text())
|
||||
envs = {name: dict(options) for name, options in config}
|
||||
@@ -58,6 +97,13 @@ def generate(stage, config):
|
||||
}
|
||||
if manifest.get('ota_update_requirements'):
|
||||
controls['updateRequirements'] = manifest['ota_update_requirements']
|
||||
note = capacity_note(manifest.get('reductions', []), defines)
|
||||
if note:
|
||||
source = manifest.get('source_commit', plan['source'])
|
||||
if not isinstance(source, str) or not re.fullmatch(r'[0-9a-f]{40}', source):
|
||||
raise ValueError('Capacity notes require an exact source commit: ' + manifest['target'])
|
||||
controls['memoryNote'] = note
|
||||
controls['memorySource'] = source
|
||||
for name in manifest['files']:
|
||||
if not name.endswith(('.bin', '.uf2', '.zip', '.hex')):
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"familyTag": "v1.17.1.5-halo-keymind-cascade-dev-26303793",
|
||||
"memoryFollowupSources": {
|
||||
"Heltec_Wireless_Paper_companion_radio_full": "1e4d1e167a984abee34e92288b44d55f3d0e15a6"
|
||||
},
|
||||
"memoryReplacementSource": "aa20e9278ebdd574c4769fdb58d46fd9d4face68",
|
||||
"profiles": {
|
||||
"Heltec_Wireless_Paper_companion_radio_full": {
|
||||
"display": true,
|
||||
"espnowBridge": false,
|
||||
"femRx": false,
|
||||
"femTx": false,
|
||||
"gps": false,
|
||||
"memoryNote": "Wireless Paper Full: 350 contacts and 40 channels; 256 offline message slots normally, 128 while mOTA borrows shared storage. Idle WiFi mOTA listening keeps all 256 slots. Sync unread messages with a Companion app before starting mOTA if more than 128 are pending. Stopping or disconnecting the source restores all 256 slots and frees scratch buffers. USB, Bluetooth, WiFi and the display are retained.",
|
||||
"memorySource": "1e4d1e167a984abee34e92288b44d55f3d0e15a6",
|
||||
"mqtt": false,
|
||||
"platform": "ESP32_PLATFORM",
|
||||
"primaryEspnow": false,
|
||||
"rs232": false,
|
||||
"rxgain": true,
|
||||
"rxps": true,
|
||||
"snmp": false,
|
||||
"updateMethods": [
|
||||
"wifi"
|
||||
],
|
||||
"webconfig": true
|
||||
},
|
||||
"Heltec_v3_companion_radio_full": {
|
||||
"display": true,
|
||||
"espnowBridge": false,
|
||||
"femRx": false,
|
||||
"femTx": false,
|
||||
"gps": true,
|
||||
"memoryNote": "Memory-corrected Full Companion: 150 contacts; the 256-message queue and all Full transports are retained. Export contacts before updating if you have more than 150; entries beyond this limit may be unavailable and a later save may omit them.",
|
||||
"memorySource": "aa20e9278ebdd574c4769fdb58d46fd9d4face68",
|
||||
"mqtt": true,
|
||||
"platform": "ESP32_PLATFORM",
|
||||
"primaryEspnow": false,
|
||||
"rs232": false,
|
||||
"rxgain": true,
|
||||
"rxps": true,
|
||||
"snmp": false,
|
||||
"updateMethods": [
|
||||
"wifi"
|
||||
],
|
||||
"webconfig": true
|
||||
},
|
||||
"RAK_4631_companion_radio_full": {
|
||||
"display": true,
|
||||
"espnowBridge": false,
|
||||
"femRx": false,
|
||||
"femTx": false,
|
||||
"gps": true,
|
||||
"memoryNote": "Corrected nRF52 Full: 256 offline message slots normally; 128 while mOTA borrows shared storage. Sync unread messages with a Companion app before starting mOTA if more than 128 are pending. Stopping or disconnecting the source returns all 256 slots. Contacts, channels and Full transports are retained.",
|
||||
"memorySource": "aa20e9278ebdd574c4769fdb58d46fd9d4face68",
|
||||
"mqtt": false,
|
||||
"platform": "NRF52_PLATFORM",
|
||||
"primaryEspnow": false,
|
||||
"rs232": false,
|
||||
"rxgain": true,
|
||||
"rxps": true,
|
||||
"snmp": false,
|
||||
"updateMethods": [
|
||||
"bluetooth"
|
||||
],
|
||||
"webconfig": false
|
||||
},
|
||||
"RAK_4631_repeater": {
|
||||
"display": true,
|
||||
"espnowBridge": false,
|
||||
"femRx": false,
|
||||
"femTx": false,
|
||||
"gps": true,
|
||||
"mqtt": false,
|
||||
"platform": "NRF52_PLATFORM",
|
||||
"primaryEspnow": false,
|
||||
"rs232": true,
|
||||
"rxgain": true,
|
||||
"rxps": true,
|
||||
"snmp": false,
|
||||
"updateMethods": [
|
||||
"bluetooth"
|
||||
],
|
||||
"webconfig": false
|
||||
},
|
||||
"SenseCapIndicator-LoRa_companion_radio_full": {
|
||||
"display": true,
|
||||
"espnowBridge": false,
|
||||
"femRx": false,
|
||||
"femTx": false,
|
||||
"gps": false,
|
||||
"mqtt": false,
|
||||
"platform": "ESP32_PLATFORM",
|
||||
"primaryEspnow": false,
|
||||
"rs232": false,
|
||||
"rxgain": true,
|
||||
"rxps": true,
|
||||
"snmp": false,
|
||||
"updateMethods": [
|
||||
"wifi"
|
||||
],
|
||||
"webconfig": true
|
||||
},
|
||||
"Station_G3_ESP32_repeater": {
|
||||
"display": true,
|
||||
"espnowBridge": false,
|
||||
"femRx": true,
|
||||
"femTx": true,
|
||||
"gps": true,
|
||||
"mqtt": false,
|
||||
"platform": "ESP32_PLATFORM",
|
||||
"primaryEspnow": false,
|
||||
"rs232": false,
|
||||
"rxgain": false,
|
||||
"rxps": true,
|
||||
"snmp": false,
|
||||
"updateMethods": [
|
||||
"wifi"
|
||||
],
|
||||
"webconfig": false
|
||||
},
|
||||
"heltec_v4_2_v4_3_companion_radio_full_femon": {
|
||||
"display": true,
|
||||
"espnowBridge": false,
|
||||
"femRx": true,
|
||||
"femTx": false,
|
||||
"gps": true,
|
||||
"mqtt": true,
|
||||
"platform": "ESP32_PLATFORM",
|
||||
"primaryEspnow": false,
|
||||
"rs232": false,
|
||||
"rxgain": true,
|
||||
"rxps": true,
|
||||
"snmp": false,
|
||||
"updateMethods": [
|
||||
"wifi"
|
||||
],
|
||||
"webconfig": true
|
||||
},
|
||||
"heltec_v4_repeater_observer_mqtt-full-usb-wifi": {
|
||||
"display": true,
|
||||
"espnowBridge": false,
|
||||
"femRx": true,
|
||||
"femTx": false,
|
||||
"gps": true,
|
||||
"mqtt": true,
|
||||
"platform": "ESP32_PLATFORM",
|
||||
"primaryEspnow": false,
|
||||
"rs232": false,
|
||||
"rxgain": true,
|
||||
"rxps": true,
|
||||
"snmp": true,
|
||||
"updateMethods": [
|
||||
"wifi",
|
||||
"lora"
|
||||
],
|
||||
"webconfig": true
|
||||
}
|
||||
},
|
||||
"source": "2630379365cf47cb7eec146b7d1b57b354efe251"
|
||||
}
|
||||
@@ -866,7 +866,9 @@ assert.strictEqual(
|
||||
console.log("generalized firmware picker tests passed");
|
||||
|
||||
// Shared features must produce the same commands for every role.
|
||||
const controls = require('../docs/_data/firmware_controls.json');
|
||||
// Keep the old release's workarounds and replacement-image checks independent
|
||||
// of whichever release the live site currently serves.
|
||||
const controls = require('./fixtures/firmware_picker_1_17_1_5_controls.json');
|
||||
const liveFamily = controls.familyTag;
|
||||
const controlledReleases = [release(liveFamily, '2026-09-01T00:00:00Z', [
|
||||
asset('heltec_v4_2_v4_3_companion_radio_full_femon-' + liveFamily + '.bin'),
|
||||
@@ -980,6 +982,29 @@ assert(stale.profiles.every(p => !p.controls));
|
||||
assert(!picker.runtimeDirections({...mqttCompanion, controls: undefined}, {}).some(s => s.title === 'GPS'));
|
||||
console.log('role-specific runtime directions tests passed');
|
||||
|
||||
// Exercise every profile from the current generated catalog while the tests
|
||||
// above keep the older release's USB and capacity workarounds intact.
|
||||
const currentControls = require('../docs/_data/firmware_controls.json');
|
||||
const currentAssets = Object.entries(currentControls.profiles).map(([target, info]) => {
|
||||
const source = info.memorySource || currentControls.source;
|
||||
const tag = currentControls.familyTag.replace(/-[0-9a-f]{8}$/, '-' + source.slice(0, 8));
|
||||
return asset(target + '-' + tag + (info.platform === 'NRF52_PLATFORM' ? '.uf2' : '.bin'));
|
||||
});
|
||||
const currentCatalog = picker.buildCatalog([
|
||||
release(currentControls.familyTag, '2026-09-13T00:00:00Z', currentAssets),
|
||||
], currentControls);
|
||||
assert.strictEqual(currentCatalog.rows.length, currentAssets.length);
|
||||
assert(currentCatalog.profiles.every(profile => profile.controls && profile.chipFamily !== 'unknown'));
|
||||
const capacityProfiles = currentCatalog.profiles.filter(profile => profile.controls.memoryNote);
|
||||
assert(capacityProfiles.length > 0, 'Regeneration must preserve capacity directions');
|
||||
for (const profile of capacityProfiles) {
|
||||
assert(picker.installSteps(profile, profile.installKinds[0]).includes(profile.controls.memoryNote), profile.target);
|
||||
}
|
||||
const currentObserver = currentCatalog.profiles.find(profile => profile.target === observer.target);
|
||||
assert.deepStrictEqual(picker.runtimeDirections(currentObserver, {logging: 'usb'})[0].actions[0].commands,
|
||||
['set logging.output usb', 'get logging.output']);
|
||||
console.log('current release metadata and capacity directions tests passed');
|
||||
|
||||
assert.strictEqual(nrf.chipFamily, 'nrf52');
|
||||
assert.strictEqual(mqttCompanion.chipFamily, 'esp32');
|
||||
assert.deepStrictEqual(picker.facetValues(controlled.profiles, {chipFamily: 'nrf52'}, 'hardwareFamily'), ['RAK_4631']);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location('picker_controls', ROOT / 'scripts/generate_picker_controls.py')
|
||||
GENERATOR = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(GENERATOR)
|
||||
|
||||
|
||||
class PickerControlsTests(unittest.TestCase):
|
||||
def generate(self, reductions, *, source=None, flags=(), verified=True):
|
||||
initial = 'a' * 40
|
||||
manifest = dict(target='sample_companion_radio_full', platformio_env='sample',
|
||||
platform='ESP32_PLATFORM', verified=verified, capabilities=['profile.full'],
|
||||
reductions=reductions, ota_update_methods=['wifi'],
|
||||
files=['sample_companion_radio_full-v1.17.1.6-dev-' + (source or initial)[:8] + '.bin'])
|
||||
if source is not None:
|
||||
manifest['source_commit'] = source
|
||||
with tempfile.TemporaryDirectory(prefix='meshcore-picker-controls-') as temporary:
|
||||
stage = Path(temporary)
|
||||
(stage / 'companion').mkdir()
|
||||
(stage / 'release-plan.json').write_text(json.dumps(dict(source=initial, groups=[
|
||||
dict(key='companion', tag='v1.17.1.6-dev-aaaaaaaa')
|
||||
])))
|
||||
(stage / 'companion/TARGET-MANIFEST.json').write_text(json.dumps([manifest]))
|
||||
config = [('env:sample', [('build_flags', list(flags))])]
|
||||
result = GENERATOR.generate(stage, config)
|
||||
return result['profiles']['sample_companion_radio_full']
|
||||
|
||||
def test_compiled_capacity_overrides_unreduced_config_and_binds_repair_source(self):
|
||||
control = self.generate([
|
||||
'companion.capacity limited to 150 contacts for runtime RAM; 256 queued frames and all Full transports retained'
|
||||
], source='b' * 40, flags=['-DMAX_CONTACTS=350'])
|
||||
self.assertIn('150 contacts and 256 queued messages', control['memoryNote'])
|
||||
self.assertNotIn('350 contacts', control['memoryNote'])
|
||||
self.assertEqual(control['memorySource'], 'b' * 40)
|
||||
|
||||
def test_compact_contacts_channels_and_queue_limits(self):
|
||||
for channels in (8, 30):
|
||||
with self.subTest(channels=channels):
|
||||
control = self.generate([
|
||||
f'companion.capacity limited to 100 contacts, {channels} channels, and 16 queued frames by measured internal DRAM'
|
||||
])
|
||||
self.assertIn(f'100 contacts, {channels} channels and 16 queued messages', control['memoryNote'])
|
||||
|
||||
def test_borrowed_queue_notes_retain_normal_and_active_capacities(self):
|
||||
control = self.generate(['nRF52 Full: 256 offline frames normally; 128 while mOTA borrows queue storage'])
|
||||
self.assertIn('256 offline messages normally; 128 while mOTA', control['memoryNote'])
|
||||
self.assertIn('restores all 256 slots', control['memoryNote'])
|
||||
self.assertEqual(control['memorySource'], 'a' * 40)
|
||||
|
||||
def test_paper_includes_declared_channels_after_flag_overrides(self):
|
||||
control = self.generate([
|
||||
'Wireless Paper Full: 350 contacts; 256 offline frames normally, 128 while mOTA borrows queue storage'
|
||||
], flags=['-DMAX_GROUP_CHANNELS=80', '-UMAX_GROUP_CHANNELS', '-D MAX_GROUP_CHANNELS=40'])
|
||||
self.assertIn('350 contacts and 40 channels', control['memoryNote'])
|
||||
self.assertIn('256 offline messages normally; 128 while mOTA', control['memoryNote'])
|
||||
|
||||
def test_repeater_table_limits_are_kept_together(self):
|
||||
control = self.generate([
|
||||
'mesh.neighbors limited to 50 by measured RAM/flash capacity',
|
||||
'mesh.flood_rules limited to 16 by measured internal RAM; complete rule engine, color display, GPS, and OTA retained',
|
||||
])
|
||||
self.assertIn('Neighbor table: 50 entries', control['memoryNote'])
|
||||
self.assertIn('Flood rules: 16 entries', control['memoryNote'])
|
||||
|
||||
def test_other_reductions_do_not_invent_capacity_notes(self):
|
||||
control = self.generate(['web.webconfig omitted to preserve the legacy portable ESP32 app slot'])
|
||||
self.assertNotIn('memoryNote', control)
|
||||
self.assertNotIn('memorySource', control)
|
||||
|
||||
def test_unknown_capacity_formats_are_not_silently_discarded(self):
|
||||
with self.assertRaisesRegex(ValueError, 'Unrecognized capacity reduction'):
|
||||
self.generate(['companion.capacity changed without numeric limits'])
|
||||
|
||||
def test_unqualified_or_unbound_notes_are_rejected(self):
|
||||
reduction = ['nRF52 Full: 256 offline frames normally; 128 while mOTA borrows queue storage']
|
||||
with self.assertRaisesRegex(ValueError, 'Unqualified target'):
|
||||
self.generate(reduction, verified=False)
|
||||
with self.assertRaisesRegex(ValueError, 'exact source commit'):
|
||||
self.generate(reduction, source='abcdef12')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user