Compare commits

...

2 Commits

Author SHA1 Message Date
Brian S. Stephan 3a55cad86f
implement Save As... in the TUI
this allows for loading an existing GP2040-CE dump or board in BOOTSEL
over USB and saving the parsed configuration to a new .bin/.uf2 file.
might be useful for making quick backups or variants of configs

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-03-26 13:47:30 -05:00
Brian S. Stephan f6d3ad02e0
support writing new configs as .uf2 files via the TUI
`edit-config --filename foo.uf2` can now create standalone config UF2
files. still working on reading UF2s, or maybe what edit-config needs is
a "Save As..." in order to read from a board/config and output it to a
new file, but this is a start

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-03-25 12:01:52 -05:00
5 changed files with 113 additions and 11 deletions

View File

@ -214,11 +214,16 @@ def write_new_config_to_filename(config: Message, filename: str, inject: bool =
with open(filename, 'rb') as file:
existing_binary = file.read()
binary = replace_config_in_binary(bytearray(existing_binary), config_binary)
with open(filename, 'wb') as file:
file.write(binary)
else:
binary = serialize_config_with_footer(config)
with open(filename, 'wb') as file:
file.write(binary)
with open(filename, 'wb') as file:
if filename[-4:] == '.uf2':
file.write(convert_binary_to_uf2(pad_config_to_storage_size(binary),
start=USER_CONFIG_BINARY_LOCATION))
else:
file.write(binary)
def write_new_config_to_usb(config: Message, endpoint_out: object, endpoint_in: object):

View File

@ -10,7 +10,7 @@ Button {
margin: 0 1;
}
EditScreen, MessageScreen {
EditScreen, MessageScreen, SaveAsScreen {
align: center middle;
}
@ -30,7 +30,7 @@ EditScreen Label {
visibility: hidden;
}
#edit-dialog {
#edit-dialog, #save-as-dialog {
padding: 0 1;
grid-rows: 1fr 1fr 1fr 1fr;
width: 50%;

View File

@ -16,7 +16,7 @@ from textual.app import App, ComposeResult
from textual.containers import Container, Grid, Horizontal
from textual.logging import TextualHandler
from textual.screen import ModalScreen
from textual.validation import Number
from textual.validation import Length, Number
from textual.widgets import Button, Footer, Header, Input, Label, Pretty, Select, TextArea, Tree
from textual.widgets.tree import TreeNode
@ -125,11 +125,61 @@ class MessageScreen(ModalScreen):
self.app.pop_screen()
class SaveAsScreen(ModalScreen):
"""Present the option of saving the configuration as a new file."""
def __init__(self, config, *args, **kwargs):
"""Initialize a filename argument to be populated."""
self.config = config
super().__init__(*args, **kwargs)
def compose(self) -> ComposeResult:
"""Build the pop-up window prompting for the new filename to save the configuration as."""
self.filename_field = Input(value=None, id='field-input', validators=[Length(minimum=1)])
yield Grid(
Container(Label("Filename (.uf2 or .bin) to write to:", id='field-name'), id='field-name-container'),
Container(self.filename_field, id='input-field-container'),
Container(Pretty('', id='input-errors', classes='hidden'), id='error-container'),
Horizontal(Container(Button("Cancel", id='cancel-button'), id='cancel-button-container'),
Container(Button("Confirm", id='confirm-button'), id='confirm-button-container'),
id='button-container'),
id='save-as-dialog',
)
@on(Input.Changed)
def show_invalid_reasons(self, event: Input.Changed) -> None:
"""Update the UI to show why validation failed."""
if event.validation_result:
error_field = self.query_one(Pretty)
save_button = self.query_one('#confirm-button', Button)
if not event.validation_result.is_valid:
error_field.update(event.validation_result.failure_descriptions)
error_field.classes = ''
save_button.disabled = True
else:
error_field.update('')
error_field.classes = 'hidden'
save_button.disabled = False
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Process the button actions."""
if event.button.id == 'confirm-button':
logger.debug("calling _save")
self._save()
self.app.pop_screen()
def _save(self):
"""Save the configuration to the specified file."""
write_new_config_to_filename(self.config, self.filename_field.value, inject=False)
self.notify(f"Saved to {self.filename_field.value}.", title="Configuration Saved")
class ConfigEditor(App):
"""Display the GP2040-CE configuration as a tree."""
BINDINGS = [
('a', 'add_node', "Add Node"),
('a', 'save_as', "Save As..."),
('n', 'add_node', "Add Node"),
('s', 'save', "Save Config"),
('q', 'quit', "Quit"),
('?', 'about', "About"),
@ -238,6 +288,10 @@ class ConfigEditor(App):
self.notify(f"Saved to {self.config_filename}.",
title="Configuration Saved")
def action_save_as(self) -> None:
"""Present a new dialog to save the configuration as a new standalone file."""
self.push_screen(SaveAsScreen(self.config))
def action_quit(self) -> None:
"""Quit the application."""
self.exit()
@ -409,8 +463,9 @@ def edit_config():
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--usb', action='store_true', help="retrieve the config from a RP2040 board connected over USB "
"and in BOOTSEL mode")
group.add_argument('--filename', help=".bin file of a GP2040-CE board's config + footer or entire storage section, "
"or of a GP2040-CE's whole board dump if --whole-board is specified")
group.add_argument('--filename', help=".bin of a GP2040-CE's whole board dump if --whole-board is specified, or a"
".bin file of a GP2040-CE board's config + footer or entire storage section; "
"if creating a new config, it can also be written in .uf2 format")
args, _ = parser.parse_known_args()
if args.usb:

View File

@ -15,7 +15,7 @@ from gp2040ce_bintools.builder import (FirmwareLengthError, combine_firmware_and
concatenate_firmware_and_storage_files, get_gp2040ce_from_usb,
pad_binary_up_to_board_config, pad_binary_up_to_user_config,
replace_config_in_binary, write_new_config_to_filename, write_new_config_to_usb)
from gp2040ce_bintools.storage import (get_board_storage_section, get_config, get_config_footer,
from gp2040ce_bintools.storage import (STORAGE_SIZE, get_board_storage_section, get_config, get_config_footer,
get_user_storage_section, serialize_config_with_footer)
HERE = os.path.dirname(os.path.abspath(__file__))
@ -272,6 +272,23 @@ def test_write_new_config_to_config_bin(firmware_binary, tmp_path):
assert len(config_dump) == config_size + 12
@with_pb2s
def test_write_new_config_to_config_uf2(firmware_binary, tmp_path):
"""Test that the config can be written to a file."""
tmp_file = os.path.join(tmp_path, 'config.uf2')
config_pb2 = get_config_pb2()
config = config_pb2.Config()
config.boardVersion = 'v0.7.5-COOL'
write_new_config_to_filename(config, tmp_file)
# read new file
with open(tmp_file, 'rb') as file:
config_dump = file.read()
# the current implementation of UF2 writing does it in 256 blocks, so each 256 byte block of
# binary is 512 bytes in the UF2
assert len(config_dump) == STORAGE_SIZE * 2
@with_pb2s
def test_write_new_config_to_usb(config_binary):
"""Test that the config can be written to USB at the proper alignment."""

View File

@ -183,7 +183,7 @@ async def test_add_node_to_repeated():
tree.root.expand_all()
await pilot.wait_for_scheduled_animations()
tree.select_node(altpinmappings_node)
await pilot.press('a')
await pilot.press('n')
newpinmappings_node = altpinmappings_node.children[0]
newpinmappings_node.expand()
await pilot.wait_for_scheduled_animations()
@ -216,3 +216,28 @@ async def test_save(config_binary, tmp_path):
config = get_config_from_file(new_filename)
assert config.boardVersion == 'v0.7.5-bss-wuz-here'
@pytest.mark.asyncio
@with_pb2s
async def test_save_as(config_binary, tmp_path):
"""Test that we can save to a new file."""
filename = os.path.join(tmp_path, 'config-original.bin')
with open(filename, 'wb') as file:
file.write(config_binary)
original_config = get_config(config_binary)
app = ConfigEditor(config_filename=filename)
async with app.run_test() as pilot:
await pilot.press('a')
await pilot.wait_for_scheduled_animations()
await pilot.click('Input#field-input')
await pilot.wait_for_scheduled_animations()
await pilot.press('/', 't', 'm', 'p', '/', 'g', 'p', 't', 'e', 's', 't')
await pilot.wait_for_scheduled_animations()
await pilot.click('Button#confirm-button')
with open('/tmp/gptest', 'rb') as new_file:
test_config_binary = new_file.read()
test_config = get_config(test_config_binary)
assert original_config == test_config