Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add batocera-bootstrap tooling for more configuration management options #12914

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
177 changes: 177 additions & 0 deletions package/batocera/core/batocera-scripts/scripts/batocera-bootstrap
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
#
# Tooling to allow configuration of Batocera settings from files located on the boot partition.
#
# This can be invoked manually, or ran during the postshare.sh process to start a new system with a preferred configuration.
# If the postshare.sh script is used, the configurations will be applied on each boot.
# This can make it such that your preferred wifi network will be reapplied on every boot,
# So if you change networks as you travel, it would always reset to your /boot/bootstrap.batocera.conf.<named> setting upon reboot.
#
# Recommended usage is to place files directly on the boot partition after flashing a new image and place bootstrap files in that directory.
#
# An example can be found here: https://gist.github.com/Skeeg/9b5a68f0d18c2df0b589274ca534363c#file-batocera-bootstrapper-sh
# With a postshare.sh example here: https://gist.github.com/Skeeg/9b5a68f0d18c2df0b589274ca534363c#file-postshare-sh
#
# @skeeg on Batocera Discord. https://github.com/Skeeg
#
# 20241103 - Initial revision
# 20241109 - Update to not sort and clobber the batocera.conf file.
# Values in the base file will be updated in place, and new values will be placed in the User-generated Configurations section.
#

import re
import os
import sys
import xml.etree.ElementTree as ET
import xml.dom.minidom as minidom

def backup_file(file_to_backup):
# Determine the next available backup file number
counter = 1
while os.path.exists(f"{file_to_backup}.bak{counter}"):
counter += 1
backup_destination_file = f"{file_to_backup}.bak{counter}"

if os.path.exists(file_to_backup):
with open(file_to_backup, 'rb') as src_file:
with open(backup_destination_file, 'wb') as dst_file:
dst_file.write(src_file.read())

print(f'Backed up {file_to_backup} to {backup_destination_file}.')
return backup_destination_file

def prettify_xml(elem):
"""Return a pretty-printed XML string for the Element."""
rough_string = ET.tostring(elem, 'utf-8')
reparsed = minidom.parseString(rough_string)
pretty_string = reparsed.toprettyxml(indent=" ")

# Remove unnecessary blank lines
lines = pretty_string.split('\n')
non_empty_lines = [line for line in lines if line.strip()]
return '\n'.join(non_empty_lines)

def update_xml(source_file, target_file):
# Parse the XML files
tree1 = ET.parse(source_file)
tree2 = ET.parse(target_file)

root1 = tree1.getroot()
root2 = tree2.getroot()

# Create a dictionary to store key-value pairs from the source file
config1 = {
(child.attrib['name'], child.tag): child.attrib['value'] for child in root1
}

# Drop any values from the target file that are defined in the source file.
for child in root2:
name = child.attrib['name']
child_type = child.tag # e.g., 'bool', 'int', 'string'
if (name, child_type) in config1:
child.attrib['value'] = config1[(name, child_type)]
config1.pop((name, child_type))# Remove the updated key from the dictionary

# Merge remaining entries from the source file into the target file
for (name, child_type), value in config1.items():
new_element = ET.Element(child_type)
new_element.attrib = {'name': name, 'value': value}
root2.append(new_element)

# Write the merged results to the target file as pretty-printed XML
pretty_xml_as_string = prettify_xml(root2)
with open(target_file, 'w', encoding='utf-8') as f:
f.write(pretty_xml_as_string)

print(f"Updated '{target_file}' with values from '{source_file}.")

def merge_keyvalues(source_file, target_file):
# Read the source file and target file
with open(source_file, 'r') as src_file:
source_lines = src_file.readlines()
filtered_lines = [line for line in source_lines if line.strip() and not line.strip().startswith('#')]

# Create empty map in case no target file exists
target_dict = {}

# Load existing target file data
if os.path.exists(target_file):
with open(target_file, 'r') as tgt_file:
for line in tgt_file:
if line.strip() and not line.strip().startswith('#'):
key, _, value = line.partition('=')
target_dict[key.strip()] = value.strip()

# Process the source file lines
for line in filtered_lines:
key, _, value = line.partition('=')
key = key.strip()
value = value.strip()
target_dict[key] = value

return target_dict

def update_keyvalue(source_file, target_file):

merged_keyvalues = merge_keyvalues(source_file, target_file)

# Read the target file lines
with open(target_file, 'r') as tgt:
target_lines = tgt.readlines()

# Dictionaries to track updated target lines
updated_lines = []
target_keys = {}

# First pass: Detect and prepare target data for updates, preserving comments
for line in target_lines:
#matching on pretty much everything but # and
match = re.match(r'^(#+)?([\w\s\.\_\-\+\[\]\(\)\{\}\\\/\,\"\'\!\@\#\$\%\^\&\*\<\>\?\:\;\|]+)=(.*)', line.strip())
if match:
comment, key, value = match.groups()
if key in merged_keyvalues:
# Update value and uncomment if necessary
new_value = merged_keyvalues[key]
updated_lines.append(f"{key}={new_value}\n")
target_keys[key] = True
else:
updated_lines.append(line)
target_keys[key] = False
else:
updated_lines.append(line)

# Append new entries from merged_keyvalues that were not in target_keys
for key, value in merged_keyvalues.items():
if key not in target_keys:
updated_lines.append(f"{key}={value}\n")

# Write updated target file
with open(target_file, 'w') as tgt:
tgt.writelines(updated_lines)

print(f"Updated '{target_file}' with values from '{source_file}.")

if __name__ == "__main__":
if len(sys.argv) != 5:
print("Usage: batocera-bootstrap <data_structure> <directory path with bootstrap.\{filename\}* files> <conf file to update> <[True|False] for backup>")
print("Example for keyvalue: batocera-bootstrap keyvalue /boot /userdata/system/batocera.conf True")
print("Example for xml: batocera-bootstrap xml /boot /userdata/system/configs/emulationstation/es_settings.cfg True")
print("The destination base filename is used as the filter, so all /boot/bootstrap.batocera.conf* files in this example will be read")
sys.exit(1)

data_structure = str.lower(sys.argv[1])
bootstrap_directory = sys.argv[2]
config_to_update = sys.argv[3]
config_backup = sys.argv[4]

if config_backup:
backup_file(config_to_update)

for file in sorted(os.listdir(bootstrap_directory)):
if file.startswith('bootstrap.' + os.path.basename(config_to_update)):
if data_structure == 'xml':
update_xml(os.path.join(bootstrap_directory, file), config_to_update)
elif data_structure == 'keyvalue':
update_keyvalue(os.path.join(bootstrap_directory, file), config_to_update)

print(f'Configuration bootstrapping complete.')