diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5271708 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +GITHUB_TOKEN="personal_access_token" +REPO="DCC-EX/EX-Installer" diff --git a/.gitignore b/.gitignore index 35e1d33..1f03518 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ share/python-wheels/ *.egg MANIFEST .DS_Store +python/ +dist/ # PyInstaller # Usually these files are written by a python script from a template diff --git a/InnoSetup/INNOSETUP.md b/InnoSetup/INNOSETUP.md new file mode 100644 index 0000000..c2b8d5a --- /dev/null +++ b/InnoSetup/INNOSETUP.md @@ -0,0 +1,47 @@ +# Building and Distributing Windows Versions with Inno Setup + +As of version 0.0.21, EX-Installer will no long be distributed as a single .exe built with PyInstaller due to the myriad of issues encountered with anti-virus applications and Windows Defender. + +Instead, [Inno Setup](https://jrsoftware.org/isinfo.php) is used to build an installer, which users can use to install EX-Installer. + +Given this will no longer provide a standalone, independent version of Python, [WinPython](https://winpython.github.io/) is included in the installer built with Inno Setup. This mitigates users having to install Python when they don't know how, and also prevents conflicts with other existing versions. + +## Building the Windows Installer + +**NOTE** Until this part can be automated, you **must** manually edit `InnoSetup\ex-installer.iss` and set "MyAppVersion" to the current version of EX-Installer. + +### WinPython + +Download the WinPython zip file and copy the included "python" directory to the root of the EX-Installer directory. WinPython releases are [here](https://winpython.github.io/). + +Use the latest stable 64bit zip file eg. "Winpython64-3.13.0dot.zip". + +The copied "python" directory should be at the same level in the directory structure as "dist", "docs", "ex_installer", and "InnoSetup". + +To reduce the size of the compiled .exe file, deleting the "python\Doc" directory is recommended. + +### Install WinPython Requirements + +Ensure the required Python packages are installed: + +``` +python\python.exe -m pip install -r InnoSetup\winpython-requirements.txt +``` + +Be careful to run this using the WinPython's python.exe, not any other installed version. + +### Test EX-Installer + +At this point, EX-Installer should run as a module with WinPython using: + +``` +python\python.exe -m ex_installer +``` + +### Inno Setup + +Download and install [Inno Setup 6](https://jrsoftware.org/isdl.php) (6.4.3 at time of writing), and note there is a VSCode Extension "Inno Setup" that will help with syntax etc. + +Open Inno Setup and open the file "InnoSetup\ex-installer.iss". + +To compile the installer, simply click the "Compile" button. Provided there are no errors, "EX-Installer-Setup-Win64.exe" will be compiled and located in the "dist" folder, ready to be added to the release. diff --git a/InnoSetup/ex-installer.iss b/InnoSetup/ex-installer.iss new file mode 100644 index 0000000..4c3f19e --- /dev/null +++ b/InnoSetup/ex-installer.iss @@ -0,0 +1,60 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "EX-Installer" +#define MyAppVersion "0.0.21" +#define MyAppPublisher "DCC-EX" +#define MyAppURL "https://dcc-ex.com" +#define MyModuleName "ex_installer" +#define MyIconFile "ex_installer\images\dccex.ico" +#define MyPython "python\pythonw.exe" +#define MyLicenseFile "LICENSE" +#define MyDCCEXLogo "ex_installer\images\dccex-logo.bmp" +#define MyImg "..\ex_installer\images" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{D2F27D1F-526B-43FD-95A9-C210A82AC7CF} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppPublisher} +DisableDirPage=auto +DisableProgramGroupPage=auto +; Uncomment the following line to run in non administrative install mode (install for current user only). +;PrivilegesRequired=lowest +OutputBaseFilename="EX-Installer-Setup-Win64" +SolidCompression=yes +WizardStyle=modern +WizardImageFile="..\{#MyDCCEXLogo}" +WizardImageStretch=no +WizardSmallImageFile="{#MyImg}\dccex-58.bmp,{#MyImg}\dccex-71.bmp,{#MyImg}\dccex-85.bmp,{#MyImg}\dccex-103.bmp,{#MyImg}\dccex-112.bmp,{#MyImg}\dccex-129.bmp,{#MyImg}\dccex-147.bmp" +SetupIconFile="..\{#MyIconFile}" +OutputDir="..\dist" +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +LicenseFile="..\{#MyLicenseFile}" + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" + +[Files] +Source: "..\python\*"; DestDir: "{app}\python"; Flags: recursesubdirs +Source: "..\{#MyModuleName}\*"; DestDir: "{app}\{#MyModuleName}"; Flags: recursesubdirs; Excludes: "__pycache__" +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyPython}"; WorkingDir: "{app}"; Parameters: "-m {#MyModuleName}"; IconFilename: "{app}\{#MyIconFile}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyPython}"; WorkingDir: "{app}"; Parameters: "-m {#MyModuleName}"; IconFilename: "{app}\{#MyIconFile}" + +[Run] +Filename: "{app}\{#MyPython}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; WorkingDir: "{app}"; Parameters: "-m {#MyModuleName}"; Flags: shellexec postinstall skipifsilent diff --git a/InnoSetup/winpython-requirements.txt b/InnoSetup/winpython-requirements.txt new file mode 100644 index 0000000..f49022e --- /dev/null +++ b/InnoSetup/winpython-requirements.txt @@ -0,0 +1,14 @@ +certifi==2025.4.26 +cffi==1.17.1 +charset-normalizer==3.4.2 +CTkMessagebox==2.7 +customtkinter==5.2.2 +darkdetect==0.8.0 +idna==3.10 +packaging==25.0 +pillow==11.2.1 +pycparser==2.22 +pygit2==1.18.0 +pyserial==3.5 +requests==2.32.3 +urllib3==2.4.0 diff --git a/README.md b/README.md index e8f2f88..68c2039 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,21 @@ EX-Installer is a Python based, cross-platform installer for the various Arduino Binaries will be made available to allow EX-Installer to be run on: -- Windows 10/11 -- Linux graphical environments +- Windows 10/11 (64 bit only) +- Linux graphical environments (64 bit only, not Raspberry Pi) - macOS ## What's in this repository? This repository includes all source code of EX-Installer, along with related documentation and screen captures of the initial design ideas. -The binaries are kept in the /dist directory of the repository, and will also be hosted on the [DCC-EX website](https://dcc-ex.com). +The binaries are attached to each version's release of EX-Installer, and will also be linked from the [DCC-EX website](https://dcc-ex.com). -### EX-Installer-Configs repository + ## Operating principles and modules @@ -43,9 +43,7 @@ The main Python modules in use are: ## Supported products -Initially, EX-Installer will be focused on basic configuration and installation of EX-CommandStation only in order to be able to replace the previous version of EX-Installer. - -Once stable, it will be expanded to be able to configure and install all of our Arduino based products including: +Currently, EX-Installer configures and installs: - EX-CommandStation - EX-IOExpander @@ -53,9 +51,11 @@ Once stable, it will be expanded to be able to configure and install all of our ## Running EX-Installer -To run EX-Installer, simply download the appropriate executable or binary file for the Operating System in use. +To run EX-Installer on macOS or Linux, simply download the appropriate executable or binary file for the Operating System in use from the release. + +On Windows, download the installation setup file "EX-Installer-Setup-Win64.exe" to install EX-Installer. -If downloading directly from GitHub, use the "raw" file download. +### Run as a Python module Alternatively, if desired, it can be run using a local Python install as a Python module. @@ -82,7 +82,9 @@ Once all binaries for a specific version have been built and published, a GitHub ## How to build binaries -PyInstaller is used to build Windows executables and binaries for Linux/macOS. +**NOTE** that for Windows users, InnoSetup is now used to distribute a setup file instead, refer to `InnoSetup\INNOSETUP.md` for details on how this works. + +PyInstaller is used to build binaries for Linux/macOS. The use of CustomTkinter dictates that some extra options need to be defined to ensure non-Python files are included in the binary, otherwise they will not execute correctly. @@ -97,7 +99,7 @@ The script will refer to the "version.py" file mentioned above, so this needs to To run the script, you need to pass the EX-Installer repository directory and the platform being built for: ```shell -python -m build_app -D -P +python -m build_app -D -P ``` ### Building manually @@ -109,17 +111,60 @@ These directories are referenced in the commands below: - \ - This is the directory containing the locally cloned EX-Installer repository - \ - This is the directory containing the Python version's local packages - \ - This is the platform the binary is built for: - - Win64 - Windows 64 bit - - Win32 - Windows 32 bit - Linux64 - Linux 64 bit - macOS - macOS (64 bit only) The build commands should be executed in a command prompt or terminal window in the directory containing the cloned repository. -Windows command: +Linux/macOS command: -`pyinstaller --windowed --clean --onefile --icon=ex_installer\images\dccex-logo.png ex_installer\__main__.py --name "EX-Installer-" --add-data "\ex_installer\images\*;images" --add-data "\ex_installer\theme\dcc-ex-theme.json;theme/." --add-data "\venv\Lib\site-packages\customtkinter;customtkinter"` +`pyinstaller --windowed --clean --onefile --icon=ex_installer/images/dccex-logo.png ex_installer/__main__.py --name "EX-Installer-" --add-data "/ex_installer/images/*:images" --add-data "/ex_installer/theme/dcc-ex-theme.json:theme/." --add-data "/venv/lib/python3.8/site-packages/customtkinter:customtkinter" --hidden-import="PIL._tkinter_finder"` -Linux command: +## EX-Installer Release Manager -`pyinstaller --windowed --clean --onefile --icon=ex_installer/images/dccex-logo.png ex_installer/__main__.py --name "EX-Installer-" --add-data "/ex_installer/images/*:images" --add-data "/ex_installer/theme/dcc-ex-theme.json:theme/." --add-data "/venv/lib/python3.8/site-packages/customtkinter:customtkinter" --hidden-import="PIL._tkinter_finder"` +To simplify the release process and ensure releases are consistent, use the provided script "ex_installer_release.py". + +This script automates the release management process for EX-Installer and takes care of: + +- Creating the required GitHub tag +- Creating the required GitHub release +- Uploading the required distribution files to the release +- Publishing the release + +Each release will be versioned consistently based on the version defined in "ex_installer/version.py": + +- Releases 0.y.z will automatically be marked "Devel" +- Releases 1.y.z or later, where y is an even number will be marked "Prod" +- Releases 1.y.z or later, where y is an odd number will be marked "Devel" + +Examples: + +- 0.0.21 will become "v0.0.21-Devel" +- 1.0.0 will become "v1.0.0-Prod" +- 1.1.0 will become "v1.1.0-Devel" + +### Running the script + +To run this script, you must copy the provided '.env.example' file to '.env' and update it. + +It must contain a valid GitHub personal access token with these privileges on the DCC-EX/EX-Installer repository: + +- Contents - read/write +- Deployments - read/write +- Metadata - read + +When running this script, you must specify at least one of: + +- -F|--files: A comma separated list of files to upload, which must be located in your local EX-Installer/dist folder +- -D|--delete: A file to be deleted from the release +- -P|--publish: If specified, the release will be published, otherwise it will be created as a draft + +Note: You cannot specify both -F|--files and -D|--delete at the same time. + +To publish an EX-Installer release, three distribution files are expected to be attached as assets: + +- EX-Installer-Linux64 - 64bit Linux binary +- EX-Installer-macOS - macOS binary +- EX-Installer-Setup-Win64.exe - Windows 64bit installer executable built by Inno Setup + +When publishing, if any of these are not present, a warning will be generated, with a prompt to continue or cancel. diff --git a/build_app.py b/build_app.py index 625811f..9c05dae 100644 --- a/build_app.py +++ b/build_app.py @@ -37,8 +37,8 @@ # Create the argument parser and add the various required arguments parser = argparse.ArgumentParser() -parser.add_argument("-P", "--platform", help="Platform type: Win32|Win64|Linux64|macOS", - choices=["Win32", "Win64", "Linux64", "macOS"], required=True, +parser.add_argument("-P", "--platform", help="Platform type: Linux64|macOS", + choices=["Linux64", "macOS"], required=True, dest="platform") parser.add_argument("-D", "--directory", help="Directory containing the cloned repository and virtual environment", required=True, diff --git a/dist/EX-Installer-Linux64 b/dist/EX-Installer-Linux64 deleted file mode 100755 index dd5365b..0000000 Binary files a/dist/EX-Installer-Linux64 and /dev/null differ diff --git a/dist/EX-Installer-Win32.exe b/dist/EX-Installer-Win32.exe deleted file mode 100644 index 638d298..0000000 Binary files a/dist/EX-Installer-Win32.exe and /dev/null differ diff --git a/dist/EX-Installer-Win64.exe b/dist/EX-Installer-Win64.exe deleted file mode 100644 index 8097272..0000000 Binary files a/dist/EX-Installer-Win64.exe and /dev/null differ diff --git a/dist/EX-Installer-macOS b/dist/EX-Installer-macOS deleted file mode 100755 index 14f6db7..0000000 Binary files a/dist/EX-Installer-macOS and /dev/null differ diff --git a/ex_installer/arduino_cli.py b/ex_installer/arduino_cli.py index 52489eb..350fa05 100644 --- a/ex_installer/arduino_cli.py +++ b/ex_installer/arduino_cli.py @@ -247,17 +247,17 @@ class ArduinoCLI: } - ESP32 locked to 2.0.17 as 3.x causes compile errors for EX-CommandStation - - STM32 locked to 2.7.1 because 2.8.0 introduces new output that needs logic to deal with + - STM32 locked to 2.9.0 as 2.8.0 introduced new output that needs logic to deal with, and 2.9.0 supports F429ZI/F439ZI variants """ extra_platforms = { - "Espressif ESP32": { + "Espressif ESP32 (EX-CSB1)": { "platform_id": "esp32:esp32", "version": "2.0.17", "url": "https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json" }, - "STMicroelectronics Nucleo/STM32": { + "STMicroelectronics (Nucleo/STM32F4xx)": { "platform_id": "STMicroelectronics:stm32", - "version": "2.7.1", + "version": "2.9.0", "url": "https://github.com/stm32duino/BoardManagerFiles/raw/main/package_stmicroelectronics_index.json" } } @@ -274,6 +274,8 @@ class ArduinoCLI: Note that these were previously an attribute of a product in the product_details module but are now here. """ arduino_libraries = { + "STM32duino STM32Ethernet": "1.4.0", + "MDNS_Generic": "1.4.2", "Ethernet": "2.0.2" } @@ -281,13 +283,16 @@ class ArduinoCLI: Dictionary of devices supported with EX-Installer to enable selection when detecting unknown devices. """ supported_devices = { + "DCC-EX EX-CSB1": "esp32:esp32:esp32", "Arduino Mega or Mega 2560": "arduino:avr:mega", "Arduino Uno": "arduino:avr:uno", "Arduino Nano": "arduino:avr:nano", - "DCC-EX EX-CSB1": "esp32:esp32:esp32", "ESP32 Dev Kit": "esp32:esp32:esp32", "STMicroelectronics Nucleo F411RE": "STMicroelectronics:stm32:Nucleo_64:pnum=NUCLEO_F411RE", - "STMicroelectronics Nucleo F446RE": "STMicroelectronics:stm32:Nucleo_64:pnum=NUCLEO_F446RE" + "STMicroelectronics Nucleo F446RE": "STMicroelectronics:stm32:Nucleo_64:pnum=NUCLEO_F446RE", + "STMicroelectronics Nucleo F446ZE": "STMicroelectronics:stm32:Nucleo_144:pnum=NUCLEO_F446ZE", + "STMicroelectronics Nucleo F429ZI": "STMicroelectronics:stm32:Nucleo_144:pnum=NUCLEO_F429ZI", + "STMicroelectronics Nucleo F439ZI": "STMicroelectronics:stm32:Nucleo_144:pnum=NUCLEO_F439ZI" } """ @@ -539,9 +544,16 @@ def upload_sketch(self, file_path, fqbn, port, sketch_dir, queue): """ Compiles and uploads the sketch in the specified directory to the provided board/port. """ - params = ["upload", "-v", "-t", "-b", fqbn, "-p", port, sketch_dir, "--format", "jsonmini"] if fqbn.startswith('esp32:esp32'): - params = params + ["--board-options", "UploadSpeed=115200"] + params = ["upload", "-v", "-t", "-b", fqbn, "-p", port, sketch_dir, "--format", "jsonmini"] #, "--before", "default_reset", "--after", "hard_reset"] + params = params + ["--board-options", "UploadSpeed=115200"]#, "--before", "default_reset", "--after", "hard_reset"] # upload speeds of 230400 and 460800 are possible, but for now go slow + elif fqbn.startswith('STMicroelectronics:stm32:'): + fqbn_nucleo = fqbn + # fqbn_nucleo += ",upload_method=swdMethod" # this allows use of SWD upload, sadly this requires STM32CubeProgrammer to be installed + # defaults to using DFU upload as a "virtual USB disk" + params = ["upload", "-v", "-t", "-b", fqbn_nucleo, "-p", port, sketch_dir, "--format", "jsonmini"] + else: + params = ["upload", "-v", "-t", "-b", fqbn, "-p", port, sketch_dir, "--format", "jsonmini"] acli = ThreadedArduinoCLI(file_path, params, queue) acli.start() diff --git a/ex_installer/ex_commandstation.py b/ex_installer/ex_commandstation.py index 01f6abf..c288c0a 100644 --- a/ex_installer/ex_commandstation.py +++ b/ex_installer/ex_commandstation.py @@ -174,8 +174,18 @@ def setup_config_frame(self): "options to configure it as an access point (it will run as its own WiFi network you can connect " + "to) or to connect it to your existing WiFi network. Click this tip to be redirected to our " + "website for further information.") - ethernet_tip = ("If you have added Ethernet capability to your CommandStation, enable this option (this " + + # Enable Ethernet by default for Nucleo-F429ZI and F439ZI and alter the tip to suit + device = self.acli.selected_device + device_fqbn = self.acli.detected_devices[device]["matching_boards"][0]["fqbn"] + if (device_fqbn == "STMicroelectronics:stm32:Nucleo_144:pnum=NUCLEO_F429ZI" or + device_fqbn == "STMicroelectronics:stm32:Nucleo_144:pnum=NUCLEO_F439ZI"): + ethernet_tip = ("Your Nucleo has Ethernet capability, so this option is enabled by default. " + + "You may instead enable WiFi, which will disable Ethernet. Click this tip to be redirected to our website for further information.") + else: + ethernet_tip = ("If you have added Ethernet capability to your CommandStation, enable this option (this " + "will disable WiFi). Click this tip to be redirected to our website for further information.") + booster_input_tip = ("If you have an EX-CSB1 or added booster input capability to your ESP32 based CommandStation, enable this option. " + + "Click this tip to be redirected to our website for further information.") advanced_tip = ("If you need to specify additional options not available on this screen, enable this option " + "to edit the config files directly on the following screen. It is recommended not to touch " + "these unless you're comfortable you know what you're doing.") @@ -327,9 +337,24 @@ def setup_config_frame(self): self.ethernet_switch = ctk.CTkSwitch(self.switch_frame, text="I have ethernet", width=200, onvalue="on", offvalue="off", variable=self.ethernet_enabled, command=self.set_ethernet, font=self.instruction_font) + CreateToolTip(self.ethernet_switch, ethernet_tip, "https://dcc-ex.com/reference/hardware/ethernet-boards.html") + # Booster Input + self.booster_input_enabled = ctk.StringVar(self, value="off") + self.booster_input_switch = ctk.CTkSwitch(self.switch_frame, text="I have a booster input", width=200, + onvalue="on", offvalue="off", variable=self.booster_input_enabled, + command=self.set_booster_input, font=self.instruction_font) + CreateToolTip(self.booster_input_switch, booster_input_tip, + "https://dcc-ex.com/reference/hardware/FIXME") + self.booster_input_gpio = ctk.StringVar(self, value="") + self.booster_input_label = ctk.CTkLabel(self.options_frame, text="Specify GPIO to use for Booster Input:", + font=self.instruction_font) + self.booster_input_entry = ctk.CTkEntry(self.options_frame, textvariable=self.booster_input_gpio, + width=50, fg_color="white") + + # Track Manager Options self.track_modes_enabled = ctk.StringVar(self, value="off") self.track_modes_switch = ctk.CTkSwitch(self.switch_frame, text="Configure TrackManager", width=200, @@ -422,11 +447,12 @@ def setup_config_frame(self): self.display_switch.grid(column=0, row=0, **grid_options) self.wifi_switch.grid(column=0, row=1, **grid_options) self.ethernet_switch.grid(column=0, row=2, **grid_options) - self.track_modes_switch.grid(column=0, row=3, **grid_options) - self.power_on_switch.grid(column=0, row=4, **grid_options) - self.override_current_limit.grid(column=0, row=5, **grid_options) - self.blank_myautomation_switch.grid(column=0, row=6, **grid_options) - self.advanced_config_switch.grid(column=0, row=7, **grid_options) + self.booster_input_entry.grid(column=0, row=3, **grid_options) + self.track_modes_switch.grid(column=0, row=4, **grid_options) + self.power_on_switch.grid(column=0, row=5, **grid_options) + self.override_current_limit.grid(column=0, row=6, **grid_options) + self.blank_myautomation_switch.grid(column=0, row=7, **grid_options) + self.advanced_config_switch.grid(column=0, row=8, **grid_options) # Layout options frame self.options_frame.grid_columnconfigure((0, 1), weight=1) @@ -479,6 +505,7 @@ def check_selected_device(self): """ device = self.acli.selected_device device_fqbn = self.acli.detected_devices[device]["matching_boards"][0]["fqbn"] + device_dccex = self.acli.dccex_device # EEPROM disabled on ESP32 and Nucleo if device_fqbn.startswith("esp32") or device_fqbn.startswith("STMicroelectronics:stm32"): self.disable_eeprom_switch.select() @@ -508,10 +535,43 @@ def check_selected_device(self): if self.wifi_switch.get() == "off": self.wifi_switch.toggle() self.wifi_switch.configure(state="disabled") + # Allow WiFi to be enabled on other platforms elif not (device_fqbn.startswith("arduino:avr:nano") or device_fqbn == "arduino:avr:uno"): if self.wifi_switch.get() == "on": self.wifi_switch.toggle() self.wifi_switch.configure(state="enabled") + # Enable Ethernet by default for Nucleo-F429ZI and F439ZI and disable control + if (device_fqbn == "STMicroelectronics:stm32:Nucleo_144:pnum=NUCLEO_F429ZI" or + device_fqbn == "STMicroelectronics:stm32:Nucleo_144:pnum=NUCLEO_F439ZI"): + if self.ethernet_switch.get() == "off": + self.ethernet_switch.toggle() + self.ethernet_switch.configure(state="enabled") + # Allow Ethernet for everything else except Nano, UNO or ALL ESP32 devices + elif not (device_fqbn.startswith("arduino:avr:nano") or device_fqbn == "arduino:avr:uno" or device_fqbn.startswith("esp32")): + if self.ethernet_switch.get() == "on": + self.ethernet_switch.toggle() + self.ethernet_switch.configure(state="enabled") + else: + if self.ethernet_switch.get() == "on": + self.ethernet_switch.toggle() + self.ethernet_switch.configure(state="disabled") + + # Enable Booster Input by default for EX-CSB1, and optionally for ESP32, but not for others + if device_fqbn.startswith("esp32"): + # TODO PMA... need to disable the toggle only for CSB1! + if self.acli.dccex_device == "EXCSB1": + if self.booster_input_enabled.get() == "off": + self.booster_input_switch.select() + self.booster_input_entry.configure(state="disabled") + else: + # Here we have an ESP32 which may have a booster, but will let users decide which pin, suggesting GPIO22 + if self.booster_input_enabled.get() == "off": + self.booster_input_switch.select() + self.booster_input_entry.configure(state="normal") + else: + if self.booster_input_enabled.get() == "on": + self.booster_input_switch.deselect() + self.booster_input_entry.configure(state="disabled") def set_display(self): """ @@ -623,6 +683,19 @@ def set_ethernet(self): else: self.log.debug("Ethernet disabled") + def set_booster_input(self): + """ + Enable or disable booster input entry based on switch state. + """ + if self.booster_input_enabled.get() == "on": + self.booster_input_label.grid(column=0, row=4, sticky="e", padx=5, pady=5) + self.booster_input_entry.grid(column=1, row=4, sticky="w", padx=5, pady=5) + self.log.debug("Booster Input enabled") + else: + self.booster_input_label.grid_remove() + self.booster_input_entry.grid_remove() + self.log.debug("Booster Input disabled") + def decrement_channel(self): """ Function to decrement the WiFi channel @@ -650,6 +723,7 @@ def display_config_screen(self): self.set_wifi() self.set_track_modes() self.set_advanced_config() + self.set_booster_input() # Initialize Booster Input visibility self.check_motor_driver(self.motor_driver_combo.get()) self.next_back.set_next_text("Compile and load") self.next_back.set_next_command(self.create_config_files) @@ -724,7 +798,7 @@ def check_invalid_wifi_password(self): If in access point mode: - Must be between 8 and 64 characters - In either mode, must not contain \ or " # noqa: W605 + In either mode, must not contain '\' or '"' # noqa: W605 Returns tuple of (True|False, message) """ @@ -757,6 +831,17 @@ def current_override(self): self.current_limit_label.grid_remove() self.current_limit_entry.grid_remove() + def booster_input_gpio(self): + """ + Function to enable setting booster input GPIO + """ + if self.override_booster_input.get() == "on": + self.booster_input_label.grid() + self.booster_input_entry.grid() + else: + self.booster_input_label.grid_remove() + self.booster_input_entry.grid_remove() + def delete_config_files(self): """ Function to delete config files from product directory @@ -791,6 +876,8 @@ def generate_config(self): self.delete_config_files() param_errors = [] config_list = [] + device = self.acli.selected_device + device_fqbn = self.acli.detected_devices[device]["matching_boards"][0]["fqbn"] if self.motor_driver_combo.get() == "Select motor driver": param_errors.append("Motor driver not set") else: @@ -837,7 +924,25 @@ def generate_config(self): if self.wifi_switch.get() == "on": param_errors.append("Can not have both Ethernet and WiFi enabled") else: + # For now, we'll specify HOSTNAME, but ideally we want folks to be able to change it! + line = '#define WIFI_HOSTNAME "' + self.wifi_hostname.get() + '"\n' + config_list.append(line) config_list.append("#define ENABLE_ETHERNET true\n") + if self.booster_input_switch.get() == "on": + if device_fqbn.startswith("esp32:"): + if self.acli.dccex_device == "EXCSB1": + booster_input_gpio = "32" + wifi_led_gpio = "33" + else: + print("Not an excsb1!") + booster_input_gpio = "26" + wifi_led_gpio = "2" + line = '#define WIFI_LED ' + wifi_led_gpio + '\n' + config_list.append(line) + line = '#define BOOSTER_INPUT ' + booster_input_gpio + '\n' + config_list.append(line) + # else: + # booster_input_gpio = "UNKNOWN" if self.override_current_limit.get() == "on": try: int(self.current_limit.get()) diff --git a/ex_installer/images/dccex-103.bmp b/ex_installer/images/dccex-103.bmp new file mode 100644 index 0000000..c6e9f8e Binary files /dev/null and b/ex_installer/images/dccex-103.bmp differ diff --git a/ex_installer/images/dccex-112.bmp b/ex_installer/images/dccex-112.bmp new file mode 100644 index 0000000..b32ac1a Binary files /dev/null and b/ex_installer/images/dccex-112.bmp differ diff --git a/ex_installer/images/dccex-129.bmp b/ex_installer/images/dccex-129.bmp new file mode 100644 index 0000000..e3dc921 Binary files /dev/null and b/ex_installer/images/dccex-129.bmp differ diff --git a/ex_installer/images/dccex-147.bmp b/ex_installer/images/dccex-147.bmp new file mode 100644 index 0000000..6639ba3 Binary files /dev/null and b/ex_installer/images/dccex-147.bmp differ diff --git a/ex_installer/images/dccex-58.bmp b/ex_installer/images/dccex-58.bmp new file mode 100644 index 0000000..35c9dff Binary files /dev/null and b/ex_installer/images/dccex-58.bmp differ diff --git a/ex_installer/images/dccex-71.bmp b/ex_installer/images/dccex-71.bmp new file mode 100644 index 0000000..a9b4ed9 Binary files /dev/null and b/ex_installer/images/dccex-71.bmp differ diff --git a/ex_installer/images/dccex-85.bmp b/ex_installer/images/dccex-85.bmp new file mode 100644 index 0000000..6cafc93 Binary files /dev/null and b/ex_installer/images/dccex-85.bmp differ diff --git a/ex_installer/images/dccex-logo.bmp b/ex_installer/images/dccex-logo.bmp new file mode 100644 index 0000000..e3ccd9c Binary files /dev/null and b/ex_installer/images/dccex-logo.bmp differ diff --git a/ex_installer/manage_arduino_cli.py b/ex_installer/manage_arduino_cli.py index 45837fc..adc6774 100644 --- a/ex_installer/manage_arduino_cli.py +++ b/ex_installer/manage_arduino_cli.py @@ -35,6 +35,7 @@ class ManageArduinoCLI(WindowLayout): intro_text = ("We use the Arduino Command Line Interface (CLI) to upload the DCC-EX products to your Arduino. " + "The CLI eliminates the need to install the more daunting Arduino IDE. EX-Installer is able to " + "manage the installation and updating of the Arduino CLI for you at the click of a button.") + csb1_text = ("Important! If using the EX-CSB1, you must enable support for Espressif ESP32 below.") installed_text = "The Arduino CLI is installed" not_installed_text = "The Arduino CLI is not installed" install_instruction_text = ("To install the Arduino CLI, simply click the install button.\n\n" + @@ -149,8 +150,9 @@ def __init__(self, parent, *args, **kwargs): self.manage_cli_frame = ctk.CTkFrame(self.main_frame, height=360) self.manage_cli_frame.grid(column=0, row=0, sticky="nsew", ipadx=5, ipady=5) self.manage_cli_frame.grid_columnconfigure((0, 1), weight=1) - self.manage_cli_frame.grid_rowconfigure((0, 2), weight=4) - self.manage_cli_frame.grid_rowconfigure(1, weight=1) + self.manage_cli_frame.grid_rowconfigure((0, 1), weight=2) + self.manage_cli_frame.grid_rowconfigure(2, weight=1) + self.manage_cli_frame.grid_rowconfigure(3, weight=4) # Create state and instruction labels and manage CLI button label_options = {"wraplength": 700} @@ -158,6 +160,10 @@ def __init__(self, parent, *args, **kwargs): text=self.intro_text, font=self.instruction_font, **label_options) + self.csb1_label = ctk.CTkLabel(self.manage_cli_frame, + text=self.csb1_text, + font=self.bold_instruction_font, + **label_options) self.cli_state_label = ctk.CTkLabel(self.manage_cli_frame, font=self.instruction_font, **label_options) @@ -201,10 +207,11 @@ def __init__(self, parent, *args, **kwargs): # Layout frame self.intro_label.grid(column=0, row=0, columnspan=2) - self.cli_state_label.grid(column=0, row=1) - self.manage_cli_button.grid(column=1, row=1) - self.instruction_label.grid(column=0, row=2) - self.extra_platforms_frame.grid(column=1, row=2, ipadx=5, ipady=5) + self.csb1_label.grid(column=0, row=1, columnspan=2) + self.cli_state_label.grid(column=0, row=2) + self.manage_cli_button.grid(column=1, row=2) + self.instruction_label.grid(column=0, row=3) + self.extra_platforms_frame.grid(column=1, row=3, ipadx=5, ipady=5) self.set_state() @@ -216,6 +223,9 @@ def set_state(self): font=self.instruction_font) self.instruction_label.configure(text=self.refresh_instruction_text) self.manage_cli_button.configure(text="Refresh Arduino CLI", command=self._generate_refresh_cli) + for child in self.extra_platforms_frame.winfo_children(): + if isinstance(child, ctk.CTkSwitch): + child.configure(state="normal") self._generate_check_cli() else: self.cli_state_label.configure(text=self.not_installed_text, @@ -223,6 +233,9 @@ def set_state(self): font=self.bold_instruction_font) self.instruction_label.configure(text=self.install_instruction_text) self.manage_cli_button.configure(text="Install Arduino CLI", command=self._generate_install_cli) + for child in self.extra_platforms_frame.winfo_children(): + if isinstance(child, ctk.CTkSwitch): + child.configure(state="disabled") self.next_back.disable_next() def update_package_list(self, switch): @@ -713,7 +726,7 @@ def _install_single_library(self, library_name, version): Flag the library as installed here but that should be validated in a later version. """ - library = library_name + "@" + version + library = f'{library_name}@{version}' #library_name + "@" + version self.libraries_to_install[library_name]["state"] = "installed" self.log.debug(f"_install_single_library() {self.process_status}\nlibrary: {library}, version: {version}") self.process_start("install_libraries", "Install Arduino library " + library, "Manage_CLI") diff --git a/ex_installer/product_details.py b/ex_installer/product_details.py index 952b20a..9e12550 100644 --- a/ex_installer/product_details.py +++ b/ex_installer/product_details.py @@ -36,7 +36,10 @@ "arduino:avr:mega", "esp32:esp32:esp32", "STMicroelectronics:stm32:Nucleo_64:pnum=NUCLEO_F411RE", - "STMicroelectronics:stm32:Nucleo_64:pnum=NUCLEO_F446RE" + "STMicroelectronics:stm32:Nucleo_64:pnum=NUCLEO_F446RE", + "STMicroelectronics:stm32:Nucleo_144:pnum=NUCLEO_F446ZE", + "STMicroelectronics:stm32:Nucleo_144:pnum=NUCLEO_F429ZI", + "STMicroelectronics:stm32:Nucleo_144:pnum=NUCLEO_F439ZI" ], "minimum_config_files": [ "config.h" diff --git a/ex_installer/select_device.py b/ex_installer/select_device.py index 75fa1d1..b0bd1eb 100644 --- a/ex_installer/select_device.py +++ b/ex_installer/select_device.py @@ -205,16 +205,35 @@ def list_devices(self, event): matched_boards = [] for matched_board in self.acli.detected_devices[index]["matching_boards"]: matched_boards.append(matched_board["name"]) - multi_combo = ctk.CTkComboBox(self.device_list_frame, - values="Select the correct device", width=250, - command=lambda name, i=index: self.update_board(name, i)) - multi_combo.grid(column=1, row=row, sticky="e", **grid_options) - multi_combo.configure(values=matched_boards) - text = "Multiple matches detected" - text += " on " + self.acli.detected_devices[index]["port"] - tip = multi_device_tip - self.log.debug("Multiple matched devices on %s", self.acli.detected_devices[index]["port"]) - self.log.debug(self.acli.detected_devices[index]["matching_boards"]) + self.log.debug(f"Multiple detected devices: {self.acli.detected_devices[index]}") + # When running the STM32 device drivers under Windows, the Arduino CLI will detect multiple possible + # matches, due to the way STM32 boards are chosen. We detect the first of these being "Discovery" + # and create a special combo box with just the Nucleo options. Otherwise proceed per normal. + if matched_boards[0] == "Discovery": + nucleo_combo = ctk.CTkComboBox(self.device_list_frame, + values=["Select the correct device"], width=250, + command=lambda name, i=index: self.update_board(name, i)) + nucleo_combo.grid(column=1, row=row, sticky="e", **grid_options) + # Filter the dictionary keys to only include those that match "Nucleo" + filter_string = "Nucleo" + filtered_boards = [key for key in supported_boards if filter_string in key] + nucleo_combo.configure(values=filtered_boards) + port_description = self.get_port_description(self.acli.detected_devices[index]["port"]) + text = ("STM32 Nucleo or clone device detected on " + + self.acli.detected_devices[index]['port']) + tip = "The Arduino CLI has detected an STM32 Nucleo device but can't decide which one... please select which model you have attached" + self.log.debug("STM32 Nucleo or clone device on %s", self.acli.detected_devices[index]["port"]) + else: + multi_combo = ctk.CTkComboBox(self.device_list_frame, + values="Select the correct device", width=250, + command=lambda name, i=index: self.update_board(name, i)) + multi_combo.grid(column=1, row=row, sticky="e", **grid_options) + multi_combo.configure(values=matched_boards) + text = "Multiple matches detected" + text += " on " + self.acli.detected_devices[index]["port"] + tip = multi_device_tip + self.log.debug("Multiple matched devices on %s", self.acli.detected_devices[index]["port"]) + self.log.debug(self.acli.detected_devices[index]["matching_boards"]) elif self.acli.detected_devices[index]["matching_boards"][0]["name"] == "Unknown": unknown_combo = ctk.CTkComboBox(self.device_list_frame, values=["Select the correct device"], width=250, @@ -257,6 +276,8 @@ def update_board(self, name, index): self.acli.dccex_device = None else: self.acli.dccex_device = None + self.log.debug(f"Detected devices: {self.acli.detected_devices}") + self.log.debug(f"Supported devices: {self.acli.supported_devices}") self.acli.detected_devices[index]["matching_boards"][0]["name"] = name self.acli.detected_devices[index]["matching_boards"][0]["fqbn"] = self.acli.supported_devices[name] self.selected_device.set(index) diff --git a/ex_installer/version.py b/ex_installer/version.py index 4859dc5..e671009 100644 --- a/ex_installer/version.py +++ b/ex_installer/version.py @@ -7,11 +7,16 @@ read by the application build process to embed in the application details """ -ex_installer_version = "0.0.20" +ex_installer_version = "0.0.21" """ Version history: +0.0.21 - Adding STM32 Ethernet support, and support for more platform targets + - STM32 upload now via serial SWD (swdMethod) instead of DFU USB memory stick emulation + - Windows EX-Installer now distributed as a setup file using Inno Setup + - Don't allow extra plaftorms to be installed until the CLI is installed + - Add note for EX-CSB1 users to enable ESP32 support 0.0.20 - Fix bug with Windows file system path preventing cloning repositories 0.0.19 - NOTE: Support for Windows 32bit is deprecated in this release - Building STM32 platforms on Windows 32bit is no longer possible diff --git a/ex_installer_release.py b/ex_installer_release.py new file mode 100644 index 0000000..b5e67eb --- /dev/null +++ b/ex_installer_release.py @@ -0,0 +1,561 @@ +""" +Python script to manage EX-Installer GitHub releases. + +© 2025, Peter Cole. All rights reserved. + +This is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +It is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with CommandStation. If not, see . +""" +from github import Github +from github.Repository import Repository +from github.GitRelease import GitRelease +from github.InputGitAuthor import InputGitAuthor +import os +from dotenv import load_dotenv +from ex_installer.version import ex_installer_version +from typing import Optional, List +import re +import argparse +import traceback + +# Script brief +SCRIPT_BRIEF = """\ +============================ +EX-Installer Release Manager +============================ + +This script automates the release management process for EX-Installer and takes care of: + +- Creating the required GitHub tag +- Creating the required GitHub release +- Uploading the required distribution files to the release +- Publishing the release +""" + +# Script notes +SCRIPT_NOTES = """\ +To run this script, you must copy the provided '.env.example' file to '.env' and update it. + +It must contain a valid GitHub personal access token with these privileges on the DCC-EX/EX-Installer repository: + +- Contents - read/write +- Deployments - read/write +- Metadata - read + +When running this script, you must specify at least one of: + +- -F|--files: A comma separated list of files to upload, which must be located in your local EX-Installer/dist folder +- -D|--delete: A file to be deleted from the release +- -P|--publish: If specified, the release will be published, otherwise it will be created as a draft + +Note: You cannot specify both -F|--files and -D|--delete at the same time. + +To publish an EX-Installer release, three distribution files are expected to be attached as assets: + +- EX-Installer-Linux64 - 64bit Linux binary +- EX-Installer-macOS - macOS binary +- EX-Installer-Setup-Win64.exe - Windows 64bit installer executable built by Inno Setup + +When publishing, if any of these are not present, a warning will be generated, with a prompt to continue or cancel. +""" + +# Create argument parser and add arguments +parser = argparse.ArgumentParser( + description=SCRIPT_BRIEF, + epilog=SCRIPT_NOTES, + formatter_class=argparse.RawTextHelpFormatter +) + +# Add branch and publish arguments +parser.add_argument("-B", "--branch", help="Branch to use the latest commit from for tagging", + required=True, dest="branch") +parser.add_argument( + "-P", "--publish", help="If provided, this will trigger the release to be published rather than remaining a draft", + action="store_true") +# Add files and delete group arguments, either must be specified, but not both +file_arg_group = parser.add_mutually_exclusive_group() +file_arg_group.add_argument( + "-F", "--files", help="Comma separated list of files in the 'dist' folder to attach to the release", dest="files") +file_arg_group.add_argument( + "-D", "--delete", help="Single file to be deleted from the release, cannot use with -F|--files", dest="delete") + +# Parse the args ready for validation later +args = parser.parse_args() + +# Validate args before proceeding +if not args.files and not args.delete and not args.publish: + parser.error("You must specify at least one of -F|--files, -D|--delete, -P--publish.") + +# Load environment variables from .env file +load_dotenv() + +# Set GitHub token, name of the repository, and the current EX-Installer version +github_token = os.getenv("GITHUB_TOKEN") +repo_name = os.getenv("REPO") + + +""" +All functions for this script are below, script logic follows these. +""" + + +def get_version_release(repo: Repository, tag_name: str) -> Optional[GitRelease]: + """ + Get the release for the provided tag name. + + Args: + repo (Repository): A GitHub repository instance + tag_name (str): String containing the tag this release should be associated with + + Returns: + Optional[GitRelease]: Github release for this version, or None if it doesn't exist + """ + version_release = None + try: + for release in repo.get_releases(): + if release.tag_name == tag_name: + version_release = release + break + except Exception as error: + print(f"Could not check releases: {error}") + return version_release + + +def extract_release_notes(version: str, file_path: str) -> str: + """ + Extract release notes from version.py for the provided version. + + Args: + version (str): String containing the current version number + file_path (str): Path to version.py + + Returns: + str: Release notes in markdown format + """ + notes = [] + capture = False + version_start_pattern = re.compile(rf"^{version}\b") + version_stop_pattern = re.compile(r"^\d+\.\d+\.\d+") + + try: + with open(file_path, "r", encoding="utf-8") as file: + for line in file: + line = line.strip() + if version_start_pattern.match(line): + capture = True + text_only = re.sub(rf"^{version}\s*-", "-", line) + notes.append(text_only) + elif capture and version_stop_pattern.match(line): + break + elif capture and line: + notes.append(f"{line}") + except Exception as error: + print(f"Could not get contents from {file_path}: {error}") + return "\n".join(f"{note}" for note in notes) + + +def create_draft_release(repo: Repository, + tag_name: str, + author: InputGitAuthor, + release_notes: str) -> Optional[GitRelease]: + """ + Create a new draft release for the provided version. + + Args: + repo (Repository): Instance of a repository to create the release for + tag_name (str): Tag name to associate with this release + author (InputGitAuthor): Instance of an author for the tag/release + release_notes (str): Release notes to include in this release + + Returns: + Optional[GitRelease]: An instance of a release or None if creation fails + """ + # First make sure we have a tag associated with the latest commit + git_tag = None + release = None + release_name = "EX-Installer Release " + tag_name + for tag in repo.get_tags(): + if tag.name == tag_name: + git_tag = tag + break + # If no tag, create release and tag based on latest commit + if git_tag is None: + # Get the latest commit + print("Creating a new tag...") + commit_sha = repo.get_commits()[0].sha + try: + git_tag = repo.create_git_tag( + tag=tag_name, + message=tag_name, + object=commit_sha, + type='commit', + tagger=author + ) + except Exception as error: + print(f"ERROR: Could not create tag: {repr(error)}") + print("Traceback:") + traceback.print_exc() + if git_tag: + try: + release = repo.create_git_release( + tag=tag_name, + name=release_name, + message=release_notes, + draft=True, + prerelease=False, + generate_release_notes=False, + make_latest="false" + ) + except Exception as error: + print(f"ERROR: Could not create release: {repr(error)}") + print("Traceback:") + traceback.print_exc() + return release + + +def check_missing_assets(release: GitRelease) -> List: + """ + Check if this release is missing required assets: + - EX-Installer-Linux64 + - EX-Installer-macOS + - EX-Installer-Setup-Win64.exe + + Args: + release (GitRelease): Instance of the release to validate assets for + + Returns: + List: List of missing assets, empty if all are present + """ + asset_list = ['EX-Installer-Linux64', 'EX-Installer-macOS', 'EX-Installer-Setup-Win64.exe'] + for asset in release.get_assets(): + if asset.name in asset_list: + asset_list.remove(asset.name) + + return asset_list + + +def publish_release(repo: Repository, release: GitRelease): + """ + Publish the specified release. + + If this is a production release, it will be made the latest also. + + If this is a development release and there are no other production releases, it will be made the latest. + + Args: + repo (Repository): The repository this release is associated with + release (GitRelease): Instance of the release to publish + """ + name = release.title + message = release.body + make_latest = "false" + if "-Prod" in release.tag_name: + make_latest = "true" + else: + # If there are no production releases and this is devel, it should still be the latest + prod = any("-Prod" in rel.tag_name for rel in repo.get_releases()) + if not prod: + make_latest = "true" + + try: + release.update_release(name=name, message=message, draft=False, make_latest=make_latest) + except Exception as error: + print(f"ERROR: Could not publish release: {repr(error)}") + print("Traceback:") + traceback.print_exc() + + +def process_file_list(files: str) -> List: + """ + Processes a comma separated list of files into a List of full file paths. + + Args: + files (str): String containing comma separated list of files + + Returns: + List: List of file paths + """ + file_paths = [] + for file_name in files.split(","): + file_path = os.path.join(os.getcwd(), "dist", file_name.strip()) + if os.path.isfile(file_path): + file_paths.append(file_path) + else: + print(f"WARNING: Provided file '{file_path}' is not a valid file and will not be added to the release.") + return file_paths + + +def build_tag_name(version: str) -> Optional[str]: + """ + Use the provided version string to determine the correct Git tag for the release. + + Any version less than 1.y.z will be Devel. + Any version after 1.y.z with even y will be Prod. + Any version after 1.y.z with odd y will be Devel. + + Args: + version (str): Semantic version string in 'X.Y.Z' format + + Returns: + Optional (str): Tag name string in 'vX.Y.Z-[Devel|Prod]' format, or None if version is not valid + """ + production = False + version_numbers = [] + # If we don't have exactly 3, invalid + if len(version.split('.')) != 3: + return None + # Validate each item is a digit + for number in version.split('.'): + number = number.strip() + if not number.isdigit(): + return None + version_numbers.append(int(number)) + # 1.y.z or later and y is even = production + if version_numbers[0] > 0 and version_numbers[1] % 2 == 0: + production = True + # If we got here, build our tag + version_tag = "v" + ".".join(map(str, version_numbers)) + if production: + version_tag += "-Prod" + else: + version_tag += "-Devel" + return version_tag + + +def valid_email(email: str) -> bool: + """ + Validate that the provided email address is formatted correctly. + + Args: + email (str): Email address to check + + Returns: + bool: True if valid, False if not + """ + pattern = r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$" + return bool(re.match(pattern, email)) + + +def get_author(github_instance: Github) -> InputGitAuthor: + """ + Get the author for creating tags/releases. + + Args: + github_instance (Github): Instance of a GitHub connection to get the current user from + + Returns: + InputGitAuthor: Instance of an author to associate with a tag and release + """ + github_user = github_instance.get_user() + user_name = None + user_email = None + if github_user.name and github_user.email: + user_name = github_user.name + user_email = github_user.email + else: + commits = repo.get_commits(author=github_user.login) + if commits.totalCount > 0: + user_name = github_user.name + user_email = commits[0].commit.author.email + else: + while True: + user_email = input("Could not determine your email address for the release, enter it here: ") + if valid_email(user_email): + user_name = github_user.name + break + else: + print("Invalid email address provided, try again.") + + # Create the InputGitAuthor instance to author tags/releases + return InputGitAuthor(name=user_name, email=user_email) + + +def add_file_to_release(release: GitRelease, file_path: str): + """ + Add the specified file to the specified release as an asset. + + Args: + release (GitRelease): Instance of the release to add the file too + file_path (str): Full path to the file to add + """ + if not os.path.isfile(file_path): + print(f"Provided path is not a file: {file_path}") + return + for asset in release.get_assets(): + if asset.name in file_path: + print("WARNING: File exists already, deleting before uploading.") + delete_file_from_release(release, asset.name) + try: + release.upload_asset( + path=file_path, + label='' + ) + except Exception as error: + print(f"Could not add {file_path} to release: {repr(error)}") + print("Traceback:") + traceback.print_exc() + + +def delete_file_from_release(release: GitRelease, file_name: str): + """ + Delete the asset associated with the specified file name from the specified release. + + Args: + release (GitRelease): Instance of the release to delete the file from + file_name (str): Name of the file to delete + """ + file_exists = False + for asset in release.get_assets(): + if asset.name == file_name: + file_exists = True + try: + asset.delete_asset() + except Exception as error: + print(f"Could not delete file {file_name} from release: {repr(error)}") + print("Traceback:") + traceback.print_exc() + if not file_exists: + print(f"WARNING: File {file_name} is not an asset of this release, skipping.") + + +def validate_inno_setup_version(version: str, inno_setup_path: str) -> bool: + """ + Validates that the version in 'ex-installer.iss' matches the EX-Installer version. + + Args: + version (str): EX-Installer version to match + inno_setup_path(str): Path to the ex-installer.iss file + + Returns: + bool: True if version matches, otherwise False + """ + if os.path.isfile(inno_setup_path): + with open(inno_setup_path, "r", encoding="utf-8") as file: + for line in file: + if line.startswith('#define MyAppVersion'): + match = re.search(r'#define MyAppVersion\s*"(.+)".*$', line) + if match: + iss_version = match.group(1) + if iss_version == version: + return True + else: + print(f"ERROR: Expected Inno Setup version {version}, found {iss_version}") + return False + else: + print(f"ERROR: {inno_setup_path} is not a valid file") + return False + return False + + +""" +This script will use the version in version.py to determine the release type and tag name: +- Anything less than 1.x.x is development (vX.Y.Z-Devel) +- Once reaching 1.Y.Z, like EX-CommandStation, odd Y = Devel, even Y = Prod + +Script must validate that the version in version.py matches the version in ex-installer.iss. + +Mandatory user arguments to provide: +- Current working branch - need to use this for the correct commit SHA for the version tag +- Binary/.exe to add as an asset to the release +- Publish the release (optional) +""" +# Connect to GitHub using the token and get the repository +try: + print("Connecting to GitHub...") + github_instance = Github(github_token) +except Exception as error: + print(f"Could not connect to GitHub: {error}") + exit() + +# Validate the provided repository exists, and get it +try: + print(f"Getting repository {repo_name}...") + repo = github_instance.get_repo(repo_name) +except Exception as error: + print(f"Could not get repository '{repo_name}': {error}") + exit() + +# Get the author for the release +print("Getting release author...") +author = get_author(github_instance) + +# If files are to be added, validate and build the file path list +if args.files: + print("Validating file list to add...") + file_list = process_file_list(args.files) + if len(file_list) == 0: + print("ERROR: You haven't provided any valid files, at least one file must be provided.") + exit() + +# Validate Inno Setup version is set correctly +print("Validating Inno Setup version matches EX-Installer version...") +inno_setup_file = os.path.join(os.getcwd(), "InnoSetup", "ex-installer.iss") +inno_setup_valid = validate_inno_setup_version(ex_installer_version, inno_setup_file) +if inno_setup_valid is False: + print("ERROR: Inno Setup version mismatch, aborting.") + exit() + +# Get the tag name that should be associated with this release +tag_name = build_tag_name(ex_installer_version) +if tag_name is None: + print(f"Could not create tag name from '{ex_installer_version}', aborting.") + exit() +else: + print(f"Using tag name {tag_name} for release") + +# Now check if we have a release +print("Checking for an existing release...") +release = get_version_release(repo, tag_name) +if release is None: + print("No existing release found, creating a new one...") + version_file_path = os.path.join(os.getcwd(), "ex_installer", "version.py") + release_notes = extract_release_notes(ex_installer_version, version_file_path) + release = create_draft_release(repo, tag_name, author, release_notes) + +# If release was not found and couldn't be created, we can't continue +if release is None: + print("ERROR: No release exists and creation has failed, aborting.") + exit() + +# If adding files, do so now +if args.files: + print("Adding files to release...") + for file_path in file_list: + print(f"Adding file {file_path}...") + add_file_to_release(release, file_path) + +# If deleting a file, do so now +if args.delete: + print(f"Deleting file {args.delete} from release...") + delete_file_from_release(release, args.delete) + +# If publishing, do it +if args.publish: + # Make sure all required assets are present before publishing + missing_assets = check_missing_assets(release) + if len(missing_assets) > 0: + print("WARNING: The following files are missing from this release:") + for file in missing_assets: + print(file) + # If assets are missing, prompt to publish or not + while True: + confirm = input("Do you wish to publish anyway? (Y/N): ").strip().lower() + if confirm == 'y': + break + elif confirm == 'n': + print("Aborting.") + exit() + else: + print("Invalid response, enter Y|y or N|n.") + print("Publishing release...") + publish_release(repo, release) diff --git a/requirements-python313.txt b/requirements-python313.txt new file mode 100644 index 0000000..76a1b99 --- /dev/null +++ b/requirements-python313.txt @@ -0,0 +1,52 @@ +alabaster==1.0.0 +altgraph==0.17.4 +babel==2.17.0 +breathe==4.36.0 +certifi==2025.4.26 +cffi==1.17.1 +charset-normalizer==3.4.2 +colorama==0.4.6 +cryptography==44.0.3 +CTkMessagebox==2.7 +customtkinter==5.2.2 +darkdetect==0.8.0 +Deprecated==1.2.18 +docutils==0.21.2 +idna==3.10 +imagesize==1.4.1 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +packaging==25.0 +pefile==2023.2.7 +pillow==11.2.1 +pycparser==2.22 +pyenchant==3.2.2 +pygit2==1.18.0 +PyGithub==2.6.1 +Pygments==2.19.1 +pyinstaller==6.13.0 +pyinstaller-hooks-contrib==2025.4 +PyJWT==2.10.1 +PyNaCl==1.5.0 +pyserial==3.5 +python-dotenv==1.1.0 +pywin32-ctypes==0.2.3 +requests==2.32.3 +roman-numerals-py==3.1.0 +setuptools==80.4.0 +snowballstemmer==3.0.1 +Sphinx==8.2.3 +sphinx-rtd-dark-mode==1.3.0 +sphinx-rtd-theme==3.0.2 +sphinx-sitemap==2.6.0 +sphinxcontrib-applehelp==2.0.0 +sphinxcontrib-devhelp==2.0.0 +sphinxcontrib-htmlhelp==2.1.0 +sphinxcontrib-jquery==4.1 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 +sphinxcontrib-spelling==8.0.1 +typing_extensions==4.13.2 +urllib3==2.4.0 +wrapt==1.17.2