From 775ee5e904ee3d5f592f1641fee51a3ef9c1c148 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Sun, 9 Jun 2024 23:06:53 +0200 Subject: [PATCH 01/26] Bugfixes and GitHub API cache --- .gitignore | 3 +- README.md | 5 ++- app/main.py | 24 ++++++----- app/release_checker.py | 93 +++++++++++++++++++++++++++++------------ screenshot-mac.webp | Bin 0 -> 34916 bytes screenshot-win.webp | Bin 0 -> 11454 bytes 6 files changed, 86 insertions(+), 39 deletions(-) create mode 100644 screenshot-mac.webp create mode 100644 screenshot-win.webp diff --git a/.gitignore b/.gitignore index 000754a..ea36021 100644 --- a/.gitignore +++ b/.gitignore @@ -266,4 +266,5 @@ $RECYCLE.BIN/ *.lnk # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos,windows,linux -firmware/*.bin \ No newline at end of file +firmware/*.bin +firmware/*.json \ No newline at end of file diff --git a/README.md b/README.md index 7ac1800..45208d8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # BTClock OTA Flasher interface +![Screenshot Windows](screenshot-win.webp) +![Screenshot Mac](screenshot-mac.webp) + ## Instructions - Make sure you have Python (tested with Python 3.12) - Run `pip3 install -r requirements.txt` @@ -17,7 +20,7 @@ pyinstaller --hidden-import zeroconf._utils.ipaddress --hidden-import zeroconf._ ### Windows ```` -pyinstaller.exe --hidden-import zeroconf._utils.ipaddress --hidden-import zeroconf._handlers.answers --hidden-import pyserial -n BTClockOTA --windowed --onefile app.py +pyinstaller.exe BTClockOTA.spec ```` ### Linux diff --git a/app/main.py b/app/main.py index 93383ec..3c0c173 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,7 @@ from app.zeroconf_listener import ZeroconfListener from app.espota import FLASH, SPIFFS + class SerialPortsComboBox(wx.ComboBox): def __init__(self, parent, fw_update): self.fw_update = fw_update @@ -51,11 +52,11 @@ class BTClockOTAUpdater(wx.Frame): self.fw_label = wx.StaticText( panel, label=f"Fetching latest version from GitHub...") hbox.Add(self.fw_label, 1, wx.EXPAND | wx.ALL, 5) - + self.actionButtons = ActionButtonPanel( panel, self) hbox.AddStretchSpacer() - + hbox.Add(self.actionButtons, 2, wx.EXPAND | wx.ALL, 5) vbox.Add(hbox, 0, wx.EXPAND | wx.ALL, 20) @@ -95,12 +96,19 @@ class BTClockOTAUpdater(wx.Frame): info.parsed_addresses()[0]) version = info.properties.get(b"rev").decode() + fsHash = "Too old" + hwRev = "REV_A_EPD_2_13" if 'gitTag' in deviceSettings: version = deviceSettings["gitTag"] + if 'fsRev' in deviceSettings: + fsHash = deviceSettings['fsRev'][:7] + + if (info.properties.get(b"hw_rev") is not None): + hwRev = info.properties.get(b"hw_rev").decode() + fwHash = info.properties.get(b"rev").decode()[:7] - fsHash = deviceSettings['fsRev'][:7] address = info.parsed_addresses()[0] if index == wx.NOT_FOUND: @@ -109,23 +117,19 @@ class BTClockOTAUpdater(wx.Frame): self.device_list.SetItem(index, 0, name) self.device_list.SetItem(index, 1, version) self.device_list.SetItem(index, 2, fwHash) - if (info.properties.get(b"hw_rev") is not None): - self.device_list.SetItem( - index, 3, info.properties.get(b"hw_rev").decode()) + self.device_list.SetItem(index, 3, hwRev) self.device_list.SetItem(index, 4, address) else: self.device_list.SetItem(index, 0, name) self.device_list.SetItem(index, 1, version) self.device_list.SetItem(index, 2, fwHash) - if (info.properties.get(b"hw_rev").decode()): - self.device_list.SetItem( - index, 3, info.properties.get(b"hw_rev").decode()) + self.device_list.SetItem(index, 3, hwRev) self.device_list.SetItem(index, 4, address) self.device_list.SetItem(index, 5, fsHash) self.device_list.SetItemData(index, index) self.device_list.itemDataMap[index] = [ - name, version, fwHash, info.properties.get(b"hw_rev").decode(), address, fsHash] + name, version, fwHash, hwRev, address, fsHash] for col in range(0, len(self.device_list.column_headings)): self.device_list.SetColumnWidth( col, wx.LIST_AUTOSIZE_USEHEADER) diff --git a/app/release_checker.py b/app/release_checker.py index 0e4ec9e..dcc2d66 100644 --- a/app/release_checker.py +++ b/app/release_checker.py @@ -1,10 +1,15 @@ +import json import os import requests import wx from typing import Callable +from datetime import datetime, timedelta from app.utils import keep_latest_versions +CACHE_FILE = 'firmware/cache.json' +CACHE_DURATION = timedelta(minutes=30) + class ReleaseChecker: '''Release Checker for firmware updates''' @@ -14,53 +19,87 @@ class ReleaseChecker: def __init__(self): self.progress_callback: Callable[[int], None] = None + def load_cache(self): + '''Load cached data from file''' + if os.path.exists(CACHE_FILE): + with open(CACHE_FILE, 'r') as f: + return json.load(f) + return {} + + def save_cache(self, cache_data): + '''Save cache data to file''' + with open(CACHE_FILE, 'w') as f: + json.dump(cache_data, f) + def fetch_latest_release(self): '''Fetch latest firmware release from GitHub''' repo = "btclock/btclock_v3" + cache = self.load_cache() + now = datetime.now() if not os.path.exists("firmware"): os.makedirs("firmware") + + if 'latest_release' in cache and (now - datetime.fromisoformat(cache['latest_release']['timestamp'])) < CACHE_DURATION: + latest_release = cache['latest_release']['data'] + else: + url = f"https://api.github.com/repos/{repo}/releases/latest" + try: + response = requests.get(url) + response.raise_for_status() + latest_release = response.json() + cache['latest_release'] = { + 'data': latest_release, + 'timestamp': now.isoformat() + } + self.save_cache(cache) + except requests.RequestException as e: + raise ReleaseCheckerException( + f"Error fetching release: {e}") from e + + release_name = latest_release['tag_name'] + self.release_name = release_name + filenames_to_download = ["lolin_s3_mini_213epd_firmware.bin", "btclock_rev_b_213epd_firmware.bin", "littlefs.bin"] - url = f"https://api.github.com/repos/{repo}/releases/latest" - try: - response = requests.get(url) - response.raise_for_status() - latest_release = response.json() - release_name = latest_release['tag_name'] - self.release_name = release_name - asset_url = None - asset_urls = [] - for asset in latest_release['assets']: - if asset['name'] in filenames_to_download: - asset_urls.append(asset['browser_download_url']) - if asset_urls: - for asset_url in asset_urls: - self.download_file(asset_url, release_name) - ref_url = f"https://api.github.com/repos/{ - repo}/git/ref/tags/{release_name}" + asset_urls = [asset['browser_download_url'] + for asset in latest_release['assets'] if asset['name'] in filenames_to_download] + + if asset_urls: + for asset_url in asset_urls: + self.download_file(asset_url, release_name) + + ref_url = f"https://api.github.com/repos/{ + repo}/git/ref/tags/{release_name}" + if ref_url in cache and (now - datetime.fromisoformat(cache[ref_url]['timestamp'])) < CACHE_DURATION: + commit_hash = cache[ref_url]['data'] + + else: response = requests.get(ref_url) response.raise_for_status() ref_info = response.json() - if (ref_info["object"]["type"] == "commit"): - self.commit_hash = ref_info["object"]["sha"] + if ref_info["object"]["type"] == "commit": + commit_hash = ref_info["object"]["sha"] else: tag_url = f"https://api.github.com/repos/{ - repo}/git/tags/{ref_info["object"]["sha"]}" + repo}/git/tags/{ref_info['object']['sha']}" response = requests.get(tag_url) response.raise_for_status() tag_info = response.json() - self.commit_hash = tag_info["object"]["sha"] + commit_hash = tag_info["object"]["sha"] + cache[ref_url] = { + 'data': commit_hash, + 'timestamp': now.isoformat() + } + self.save_cache(cache) - return self.release_name + self.commit_hash = commit_hash - else: - raise ReleaseCheckerException( - f"File {filenames_to_download} not found in latest release") - except requests.RequestException as e: + return self.release_name + else: raise ReleaseCheckerException( - f"Error fetching release: {e}") from e + f"File {filenames_to_download} not found in latest release") def download_file(self, url, release_name): '''Downloads Fimware Files''' diff --git a/screenshot-mac.webp b/screenshot-mac.webp new file mode 100644 index 0000000000000000000000000000000000000000..c39631edcae4deca91ab932b4d1249c3d9e99106 GIT binary patch literal 34916 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�>eLwvsYT&&V(M=hg7jbQ|5G%kUo%oA( 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 literal 0 HcmV?d00001 diff --git a/screenshot-win.webp b/screenshot-win.webp new file mode 100644 index 0000000000000000000000000000000000000000..6ddf75e3b0f4e999330e71657cab8dd28d53119e GIT binary patch literal 11454 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 YGtb Date: Sun, 9 Jun 2024 23:23:06 +0200 Subject: [PATCH 02/26] Add macOS workflow and spec --- .github/workflows/build_macos.yaml | 51 +++++++++++++++++++ ...uild_artifacts.yaml => build_windows.yaml} | 2 +- BTClockOTA-universal.spec | 45 ++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build_macos.yaml rename .github/workflows/{build_artifacts.yaml => build_windows.yaml} (97%) create mode 100644 BTClockOTA-universal.spec diff --git a/.github/workflows/build_macos.yaml b/.github/workflows/build_macos.yaml new file mode 100644 index 0000000..46731af --- /dev/null +++ b/.github/workflows/build_macos.yaml @@ -0,0 +1,51 @@ +name: Build macOS artifacts and make release + +on: workflow_dispatch + +jobs: + build-windows: + runs-on: + - ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Download universal2 Python + run: | + curl -o python.pkg https://www.python.org/ftp/python/3.12.4/python-3.12.4-macos11.pkg + - name: Install Python + run: | + sudo installer -pkg python.pkg -target / + - name: Add Python to PATH + run: | + echo "/Library/Frameworks/Python.framework/Versions/3.12/bin" >> $GITHUB_PATH + - name: Verify Python installation + run: | + python3 --version + pip3 --version + - name: Install dependencies + run: | + pip3 install --upgrade pip + pip3 install pyinstaller + pip3 install -r requirements.txt + - name: Build with PyInstaller + run: | + pyinstaller BTClockOTA-universal.spec + - name: Get current block + id: getBlockHeight + run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-artifacts + path: dist/ + - name: Create release + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.getBlockHeight.outputs.blockHeight }} + commit: main + name: release-${{ steps.getBlockHeight.outputs.blockHeight }} + artifacts: "dist/**" + allowUpdates: true + makeLatest: true diff --git a/.github/workflows/build_artifacts.yaml b/.github/workflows/build_windows.yaml similarity index 97% rename from .github/workflows/build_artifacts.yaml rename to .github/workflows/build_windows.yaml index 22f5206..cfbdaeb 100644 --- a/.github/workflows/build_artifacts.yaml +++ b/.github/workflows/build_windows.yaml @@ -1,4 +1,4 @@ -name: Build artifacts and make release +name: Build Windows artifacts and make release on: workflow_dispatch diff --git a/BTClockOTA-universal.spec b/BTClockOTA-universal.spec new file mode 100644 index 0000000..4a0929c --- /dev/null +++ b/BTClockOTA-universal.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['app.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=['zeroconf._utils.ipaddress', + 'zeroconf._handlers.answers', 'pyserial', 'wx'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='BTClockOTA', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch="universal2", + codesign_identity=None, + entitlements_file=None, + icon=['update-icon.ico'], +) + +app = BUNDLE(exe, + name='BTClockOTA.app', + icon='update-icon.icns', + bundle_identifier=None) From 3263d51084dd1e56a498d706c28af96b83b78289 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Sun, 9 Jun 2024 23:26:04 +0200 Subject: [PATCH 03/26] Dont mix ubuntu and macos --- .github/workflows/build_macos.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_macos.yaml b/.github/workflows/build_macos.yaml index 46731af..df0358a 100644 --- a/.github/workflows/build_macos.yaml +++ b/.github/workflows/build_macos.yaml @@ -3,9 +3,8 @@ name: Build macOS artifacts and make release on: workflow_dispatch jobs: - build-windows: - runs-on: - - ubuntu-latest + build-macos: + runs-on: macos-latest permissions: contents: write steps: From a2bd34963404f08196bccdd26ad126d1a7b2464b Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Sun, 9 Jun 2024 23:31:49 +0200 Subject: [PATCH 04/26] Install some deps from source --- .github/workflows/build_macos.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build_macos.yaml b/.github/workflows/build_macos.yaml index df0358a..0476e9c 100644 --- a/.github/workflows/build_macos.yaml +++ b/.github/workflows/build_macos.yaml @@ -27,6 +27,8 @@ jobs: run: | pip3 install --upgrade pip pip3 install pyinstaller + pip3 install --no-cache cffi --no-binary :all: + pip3 install --no-cache charset_normalizer --no-binary :all: pip3 install -r requirements.txt - name: Build with PyInstaller run: | From 11b4ea3b4402ffeced64641bdee121a2e6b1d980 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Sun, 9 Jun 2024 23:43:13 +0200 Subject: [PATCH 05/26] Install wxPython prerelease and make it a DMG --- .github/workflows/build_macos.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_macos.yaml b/.github/workflows/build_macos.yaml index 0476e9c..3de7e1e 100644 --- a/.github/workflows/build_macos.yaml +++ b/.github/workflows/build_macos.yaml @@ -29,6 +29,7 @@ jobs: pip3 install pyinstaller pip3 install --no-cache cffi --no-binary :all: pip3 install --no-cache charset_normalizer --no-binary :all: + pip3 install -U --pre -f https://wxpython.org/Phoenix/snapshot-builds/ wxPython pip3 install -r requirements.txt - name: Build with PyInstaller run: | @@ -36,17 +37,23 @@ jobs: - name: Get current block id: getBlockHeight run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT + - name: Create DMG + run: | + mkdir dmg_temp + cp -R dist/BTClockOTA.app dmg_temp/ + # Create the DMG file + hdiutil create -volname "BTClockOTA" -srcfolder dmg_temp -ov -format UDZO "dist/BTClockOTA.dmg" - name: Archive artifacts uses: actions/upload-artifact@v4 with: name: macos-artifacts - path: dist/ + path: dist/BTClockOTA.dmg - name: Create release uses: ncipollo/release-action@v1 with: tag: ${{ steps.getBlockHeight.outputs.blockHeight }} commit: main name: release-${{ steps.getBlockHeight.outputs.blockHeight }} - artifacts: "dist/**" + artifacts: "dist/*.dmg" allowUpdates: true makeLatest: true From 9b04535f243bed09103062ca89a9ac03476be811 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 00:14:50 +0200 Subject: [PATCH 06/26] Add zip --- .github/workflows/build_macos.yaml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_macos.yaml b/.github/workflows/build_macos.yaml index 3de7e1e..36eae18 100644 --- a/.github/workflows/build_macos.yaml +++ b/.github/workflows/build_macos.yaml @@ -37,23 +37,29 @@ jobs: - name: Get current block id: getBlockHeight run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT + - name: Zip the app bundle + run: | + cd dist + zip -r BTClockOTA-macos-universal2.zip BTClockOTA.app - name: Create DMG run: | mkdir dmg_temp cp -R dist/BTClockOTA.app dmg_temp/ # Create the DMG file - hdiutil create -volname "BTClockOTA" -srcfolder dmg_temp -ov -format UDZO "dist/BTClockOTA.dmg" + hdiutil create -volname "BTClockOTA" -srcfolder dmg_temp -ov -format UDZO "dist/BTClockOTA-universal.dmg" - name: Archive artifacts uses: actions/upload-artifact@v4 with: name: macos-artifacts - path: dist/BTClockOTA.dmg + path: | + dist/* + !dist/BTClockOTA.app - name: Create release uses: ncipollo/release-action@v1 with: tag: ${{ steps.getBlockHeight.outputs.blockHeight }} commit: main name: release-${{ steps.getBlockHeight.outputs.blockHeight }} - artifacts: "dist/*.dmg" + artifacts: "dist/*.dmg,dist/*.zip" allowUpdates: true makeLatest: true From 1898bec5bbb416cd99965f94031f8e8cce81065c Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 00:56:42 +0200 Subject: [PATCH 07/26] Improve workflow --- .github/workflows/build_all.yaml | 38 ++++++++++++++++++++++++++++ .github/workflows/build_macos.yaml | 24 +++++++++--------- .github/workflows/build_windows.yaml | 26 +++++++++---------- 3 files changed, 63 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/build_all.yaml diff --git a/.github/workflows/build_all.yaml b/.github/workflows/build_all.yaml new file mode 100644 index 0000000..c3ed072 --- /dev/null +++ b/.github/workflows/build_all.yaml @@ -0,0 +1,38 @@ +name: Build all artifacts and make release + +on: workflow_dispatch + +jobs: + prepare: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Get current block + id: getBlockHeight + run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT + - name: Build Windows + uses: ./.github/workflows/build_windows + - name: Build macOS + uses: ./.github/workflows/build_macos + - name: Get Windows Artifacts + if: ${{ always() }} + uses: actions/download-artifact@v4 + with: + name: windows-artifacts + path: windows + - name: Get Job 1 Artifacts + if: ${{ always() }} + uses: actions/download-artifact@v4 + with: + name: macos-artifacts + path: macos + - name: Create release + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.getBlockHeight.outputs.blockHeight }} + commit: main + name: release-${{ steps.getBlockHeight.outputs.blockHeight }} + artifacts: "macos/**/*.dmg,macos/**/*.zip,windows/**/*.exe" + allowUpdates: true + makeLatest: true diff --git a/.github/workflows/build_macos.yaml b/.github/workflows/build_macos.yaml index 36eae18..1d07fa9 100644 --- a/.github/workflows/build_macos.yaml +++ b/.github/workflows/build_macos.yaml @@ -34,9 +34,9 @@ jobs: - name: Build with PyInstaller run: | pyinstaller BTClockOTA-universal.spec - - name: Get current block - id: getBlockHeight - run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT + # - name: Get current block + # id: getBlockHeight + # run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT - name: Zip the app bundle run: | cd dist @@ -54,12 +54,12 @@ jobs: path: | dist/* !dist/BTClockOTA.app - - name: Create release - uses: ncipollo/release-action@v1 - with: - tag: ${{ steps.getBlockHeight.outputs.blockHeight }} - commit: main - name: release-${{ steps.getBlockHeight.outputs.blockHeight }} - artifacts: "dist/*.dmg,dist/*.zip" - allowUpdates: true - makeLatest: true + # - name: Create release + # uses: ncipollo/release-action@v1 + # with: + # tag: ${{ steps.getBlockHeight.outputs.blockHeight }} + # commit: main + # name: release-${{ steps.getBlockHeight.outputs.blockHeight }} + # artifacts: "dist/*.dmg,dist/*.zip" + # allowUpdates: true + # makeLatest: true diff --git a/.github/workflows/build_windows.yaml b/.github/workflows/build_windows.yaml index cfbdaeb..94899de 100644 --- a/.github/workflows/build_windows.yaml +++ b/.github/workflows/build_windows.yaml @@ -34,21 +34,21 @@ jobs: --volume "${{ github.workspace }}:/src/" \ --env SPECFILE=./BTClockOTA.spec \ batonogov/pyinstaller-windows:latest - - name: Get current block - id: getBlockHeight - run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT + # - name: Get current block + # id: getBlockHeight + # run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT - name: Archive artifacts uses: actions/upload-artifact@v4 with: name: windows-artifacts path: dist/ - - name: Create release - uses: ncipollo/release-action@v1 - with: - tag: ${{ steps.getBlockHeight.outputs.blockHeight }} - commit: main - name: release-${{ steps.getBlockHeight.outputs.blockHeight }} - artifacts: 'dist/**' - allowUpdates: true - removeArtifacts: true - makeLatest: true \ No newline at end of file + # - name: Create release + # uses: ncipollo/release-action@v1 + # with: + # tag: ${{ steps.getBlockHeight.outputs.blockHeight }} + # commit: main + # name: release-${{ steps.getBlockHeight.outputs.blockHeight }} + # artifacts: 'dist/**' + # allowUpdates: true + # removeArtifacts: true + # makeLatest: true \ No newline at end of file From f84e27eeaf703e972dd7961fd9b0b941dda7f7cb Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 01:00:06 +0200 Subject: [PATCH 08/26] Add extensions --- .github/workflows/build_all.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_all.yaml b/.github/workflows/build_all.yaml index c3ed072..bc7d253 100644 --- a/.github/workflows/build_all.yaml +++ b/.github/workflows/build_all.yaml @@ -12,9 +12,9 @@ jobs: id: getBlockHeight run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT - name: Build Windows - uses: ./.github/workflows/build_windows + uses: ./.github/workflows/build_windows.yml - name: Build macOS - uses: ./.github/workflows/build_macos + uses: ./.github/workflows/build_macos.yml - name: Get Windows Artifacts if: ${{ always() }} uses: actions/download-artifact@v4 From 7872142ea09ee06707d6e05c8bf4689acc72fda3 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 01:01:25 +0200 Subject: [PATCH 09/26] Checkout repo --- .github/workflows/build_all.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build_all.yaml b/.github/workflows/build_all.yaml index bc7d253..07d1e7a 100644 --- a/.github/workflows/build_all.yaml +++ b/.github/workflows/build_all.yaml @@ -8,6 +8,8 @@ jobs: permissions: contents: write steps: + - name: Checkout repository + uses: actions/checkout@v4 - name: Get current block id: getBlockHeight run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT From 4649feda74dccce4f1e3258cf730cd843a7c07ce Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 01:04:56 +0200 Subject: [PATCH 10/26] Extension fix --- .github/workflows/build_all.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_all.yaml b/.github/workflows/build_all.yaml index 07d1e7a..291e692 100644 --- a/.github/workflows/build_all.yaml +++ b/.github/workflows/build_all.yaml @@ -14,9 +14,9 @@ jobs: id: getBlockHeight run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT - name: Build Windows - uses: ./.github/workflows/build_windows.yml + uses: ./.github/workflows/build_windows.yaml - name: Build macOS - uses: ./.github/workflows/build_macos.yml + uses: ./.github/workflows/build_macos.yaml - name: Get Windows Artifacts if: ${{ always() }} uses: actions/download-artifact@v4 From a83c50ab418dd14f264fad11ea76109191fb1ad3 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 01:12:41 +0200 Subject: [PATCH 11/26] Trying it different --- .github/workflows/build_all.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_all.yaml b/.github/workflows/build_all.yaml index 291e692..a5118cb 100644 --- a/.github/workflows/build_all.yaml +++ b/.github/workflows/build_all.yaml @@ -14,9 +14,9 @@ jobs: id: getBlockHeight run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT - name: Build Windows - uses: ./.github/workflows/build_windows.yaml + uses: btclock/ota-flasher/.github/workflows/build_windows.yaml@main - name: Build macOS - uses: ./.github/workflows/build_macos.yaml + uses: btclock/ota-flasher/.github/workflows/build_macos.yaml@main - name: Get Windows Artifacts if: ${{ always() }} uses: actions/download-artifact@v4 From fee2992e8650bccae1d1a4a185062d755de60996 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 01:20:47 +0200 Subject: [PATCH 12/26] All in one file --- .github/workflows/build_all.yaml | 85 +++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_all.yaml b/.github/workflows/build_all.yaml index a5118cb..af917ca 100644 --- a/.github/workflows/build_all.yaml +++ b/.github/workflows/build_all.yaml @@ -3,20 +3,93 @@ name: Build all artifacts and make release on: workflow_dispatch jobs: - prepare: + build-macos: + runs-on: macos-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Download universal2 Python + run: | + curl -o python.pkg https://www.python.org/ftp/python/3.12.4/python-3.12.4-macos11.pkg + - name: Install Python + run: | + sudo installer -pkg python.pkg -target / + - name: Add Python to PATH + run: | + echo "/Library/Frameworks/Python.framework/Versions/3.12/bin" >> $GITHUB_PATH + - name: Verify Python installation + run: | + python3 --version + pip3 --version + - name: Install dependencies + run: | + pip3 install --upgrade pip + pip3 install pyinstaller + pip3 install --no-cache cffi --no-binary :all: + pip3 install --no-cache charset_normalizer --no-binary :all: + pip3 install -U --pre -f https://wxpython.org/Phoenix/snapshot-builds/ wxPython + pip3 install -r requirements.txt + - name: Build with PyInstaller + run: | + pyinstaller BTClockOTA-universal.spec + - name: Zip the app bundle + run: | + cd dist + zip -r BTClockOTA-macos-universal2.zip BTClockOTA.app + - name: Create DMG + run: | + mkdir dmg_temp + cp -R dist/BTClockOTA.app dmg_temp/ + # Create the DMG file + hdiutil create -volname "BTClockOTA" -srcfolder dmg_temp -ov -format UDZO "dist/BTClockOTA-universal.dmg" + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-artifacts + path: | + dist/* + !dist/BTClockOTA.app + build-windows: + runs-on: + - ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Run Docker Container + run: | + docker run --rm \ + --volume "${{ github.workspace }}:/src/" \ + --env SPECFILE=./BTClockOTA.spec \ + batonogov/pyinstaller-windows:latest + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-artifacts + path: dist/ + release: + needs: [build-macos, build-windows] runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v4 - name: Get current block id: getBlockHeight run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT - - name: Build Windows - uses: btclock/ota-flasher/.github/workflows/build_windows.yaml@main - - name: Build macOS - uses: btclock/ota-flasher/.github/workflows/build_macos.yaml@main - name: Get Windows Artifacts if: ${{ always() }} uses: actions/download-artifact@v4 From e5bddaa8b149d2ea514acf9dff1fef48ab42e1f6 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 13:11:10 +0200 Subject: [PATCH 13/26] Add linux build --- .github/workflows/build_linux.yaml | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/build_linux.yaml diff --git a/.github/workflows/build_linux.yaml b/.github/workflows/build_linux.yaml new file mode 100644 index 0000000..3f907c6 --- /dev/null +++ b/.github/workflows/build_linux.yaml @@ -0,0 +1,39 @@ +name: Build Linux artifacts + +on: workflow_dispatch + +jobs: + build-linux: + runs-on: + - ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Run Docker Container + run: | + docker run --rm \ + --volume "${{ github.workspace }}:/src/" \ + --env SPECFILE=./BTClockOTA.spec \ + ghcr.io/btclock/pyinstaller-wxpython-linux:latest + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-artifacts + path: dist/ From 6f53026c380f18eaae045cba111bec338595002b Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 13:23:14 +0200 Subject: [PATCH 14/26] Add build linux to build all --- .github/workflows/build_all.yaml | 47 ++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_all.yaml b/.github/workflows/build_all.yaml index af917ca..2fe9f3b 100644 --- a/.github/workflows/build_all.yaml +++ b/.github/workflows/build_all.yaml @@ -79,8 +79,43 @@ jobs: with: name: windows-artifacts path: dist/ + build-linux: + runs-on: + - ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Run Docker Container + run: | + docker run --rm \ + --volume "${{ github.workspace }}:/src/" \ + --env SPECFILE=./BTClockOTA.spec \ + ghcr.io/btclock/pyinstaller-wxpython-linux:latest && + mv dist/BTClockOTA dist/BTClockOTA-linux-amd64 + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-artifacts + path: dist/ release: - needs: [build-macos, build-windows] + needs: [build-macos, build-windows, build-linux] runs-on: ubuntu-latest permissions: contents: write @@ -96,18 +131,24 @@ jobs: with: name: windows-artifacts path: windows - - name: Get Job 1 Artifacts + - name: Get macOS Artifacts if: ${{ always() }} uses: actions/download-artifact@v4 with: name: macos-artifacts path: macos + - name: Get Linux Artifacts + if: ${{ always() }} + uses: actions/download-artifact@v4 + with: + name: linux-artifacts + path: linux - name: Create release uses: ncipollo/release-action@v1 with: tag: ${{ steps.getBlockHeight.outputs.blockHeight }} commit: main name: release-${{ steps.getBlockHeight.outputs.blockHeight }} - artifacts: "macos/**/*.dmg,macos/**/*.zip,windows/**/*.exe" + artifacts: "macos/**/*.dmg,macos/**/*.zip,windows/**/*.exe,linux/*" allowUpdates: true makeLatest: true From 9fb7fb433f5221746ccedd9899b6e54aaed4b62d Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 15:31:13 +0200 Subject: [PATCH 15/26] Add console control, bugfixes and windows debug build --- .github/workflows/build_windows.yaml | 2 +- BTClockOTA-debug.spec | 39 ++++++++++++++++++++++++++++ app/espota.py | 17 ++++-------- app/fw_updater.py | 9 ++++++- app/main.py | 32 ++++++++++++++++++++--- app/release_checker.py | 6 +++-- 6 files changed, 86 insertions(+), 19 deletions(-) create mode 100644 BTClockOTA-debug.spec diff --git a/.github/workflows/build_windows.yaml b/.github/workflows/build_windows.yaml index 94899de..2525f74 100644 --- a/.github/workflows/build_windows.yaml +++ b/.github/workflows/build_windows.yaml @@ -32,7 +32,7 @@ jobs: run: | docker run --rm \ --volume "${{ github.workspace }}:/src/" \ - --env SPECFILE=./BTClockOTA.spec \ + --env SPECFILE=./BTClockOTA-debug.spec \ batonogov/pyinstaller-windows:latest # - name: Get current block # id: getBlockHeight diff --git a/BTClockOTA-debug.spec b/BTClockOTA-debug.spec new file mode 100644 index 0000000..82b9de4 --- /dev/null +++ b/BTClockOTA-debug.spec @@ -0,0 +1,39 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['app.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=['zeroconf._utils.ipaddress', 'zeroconf._handlers.answers', 'pyserial', 'wx'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='BTClockOTA-debug', + debug=True, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['update-icon.ico'], +) diff --git a/app/espota.py b/app/espota.py index 487f8ed..a55e5cf 100644 --- a/app/espota.py +++ b/app/espota.py @@ -102,8 +102,8 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename, inv_tries = 0 data = "" msg = "Sending invitation to %s " % remote_addr - sys.stderr.write(msg) - sys.stderr.flush() + logging.info(msg) + while inv_tries < 10: inv_tries += 1 sock2 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -111,8 +111,7 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename, try: sent = sock2.sendto(message.encode(), remote_address) # noqa: F841 except: # noqa: E722 - sys.stderr.write("failed\n") - sys.stderr.flush() + logging.info("failed\n") sock2.close() logging.error("Host %s Not Found", remote_addr) return 1 @@ -121,11 +120,8 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename, data = sock2.recv(37).decode() break except: # noqa: E722 - sys.stderr.write(".") - sys.stderr.flush() +# logging.info(".") sock2.close() - sys.stderr.write("\n") - sys.stderr.flush() if inv_tries == 10: logging.error("No response from the ESP") return 1 @@ -177,8 +173,7 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename, if PROGRESS: progress_handler(0) else: - sys.stderr.write("Uploading") - sys.stderr.flush() + logging.info("Uploading") offset = 0 while True: chunk = f.read(1024) @@ -192,7 +187,6 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename, res = connection.recv(10) last_response_contained_ok = "OK" in res.decode() except Exception as e: - sys.stderr.write("\n") logging.error("Error Uploading: %s", str(e)) connection.close() return 1 @@ -202,7 +196,6 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename, connection.close() return 0 - sys.stderr.write("\n") logging.info("Waiting for result...") count = 0 while count < 5: diff --git a/app/fw_updater.py b/app/fw_updater.py index 1ca37af..ec19d88 100644 --- a/app/fw_updater.py +++ b/app/fw_updater.py @@ -12,8 +12,9 @@ class FwUpdater: update_progress = None currentlyUpdating = False - def __init__(self, update_progress): + def __init__(self, update_progress, event_cb): self.update_progress = update_progress + self.event_cb = event_cb def get_serial_ports(self): ports = serial.tools.list_ports.comports() @@ -76,6 +77,9 @@ class FwUpdater: self.updatingName = address self.currentlyUpdating = True + + if self.event_cb is not None: + self.event_cb("Starting Firmware update") if os.path.exists(os.path.abspath(local_filename)): thread = Thread(target=self.run_fs_update, args=( @@ -88,6 +92,9 @@ class FwUpdater: self.updatingName = address self.currentlyUpdating = True + + if self.event_cb is not None: + self.event_cb("Starting WebUI update") if os.path.exists(os.path.abspath(local_filename)): thread = Thread(target=self.run_fs_update, args=( diff --git a/app/main.py b/app/main.py index 3c0c173..2b79b05 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,11 @@ import concurrent.futures +import logging import serial from app.gui.action_button_panel import ActionButtonPanel from app.release_checker import ReleaseChecker import wx +import wx.richtext as rt from zeroconf import ServiceBrowser, Zeroconf import os @@ -18,6 +20,19 @@ from app.zeroconf_listener import ZeroconfListener from app.espota import FLASH, SPIFFS +class RichTextCtrlHandler(logging.Handler): + def __init__(self, ctrl): + super().__init__() + self.ctrl = ctrl + + def emit(self, record): + msg = self.format(record) + wx.CallAfter(self.append_text, msg + '\n') + + def append_text(self, text): + self.ctrl.AppendText(text) + self.ctrl.ShowPosition(self.ctrl.GetLastPosition()) + class SerialPortsComboBox(wx.ComboBox): def __init__(self, parent, fw_update): self.fw_update = fw_update @@ -38,13 +53,23 @@ class BTClockOTAUpdater(wx.Frame): self.browser = ServiceBrowser( self.zeroconf, "_http._tcp.local.", self.listener) self.api_handler = ApiHandler() - self.fw_updater = FwUpdater(self.call_progress) + self.fw_updater = FwUpdater(self.call_progress, self.SetStatusText) + panel = wx.Panel(self) + self.log_ctrl = rt.RichTextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2) + monospace_font = wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) + self.log_ctrl.SetFont(monospace_font) + + handler = RichTextCtrlHandler(self.log_ctrl) + handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) + logging.getLogger().addHandler(handler) + logging.getLogger().setLevel(logging.DEBUG) + + self.device_list = DevicesPanel(panel) vbox = wx.BoxSizer(wx.VERTICAL) - vbox.Add(self.device_list, proportion=2, flag=wx.EXPAND | wx.ALL, border=20) hbox = wx.BoxSizer(wx.HORIZONTAL) @@ -62,10 +87,11 @@ class BTClockOTAUpdater(wx.Frame): self.progress_bar = wx.Gauge(panel, range=100) vbox.Add(self.progress_bar, 0, wx.EXPAND | wx.ALL, 20) + vbox.Add(self.log_ctrl, 1, flag=wx.EXPAND | wx.ALL, border=20) panel.SetSizer(vbox) - self.setup_ui() + wx.CallAfter(self.fetch_latest_release_async) def setup_ui(self): diff --git a/app/release_checker.py b/app/release_checker.py index dcc2d66..c10e04b 100644 --- a/app/release_checker.py +++ b/app/release_checker.py @@ -1,4 +1,5 @@ import json +import logging import os import requests import wx @@ -104,13 +105,14 @@ class ReleaseChecker: def download_file(self, url, release_name): '''Downloads Fimware Files''' local_filename = f"{release_name}_{url.split('/')[-1]}" - response = requests.get(url, stream=True) - total_length = response.headers.get('content-length') + if not os.path.exists("firmware"): os.makedirs("firmware") if os.path.exists(f"firmware/{local_filename}"): return + response = requests.get(url, stream=True) + total_length = response.headers.get('content-length') keep_latest_versions('firmware', 2) if total_length is None: From 504199a8c9261ad4ed3bce8c95c8139426510e45 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 15:43:08 +0200 Subject: [PATCH 16/26] Add hidden imports --- BTClockOTA-debug.spec | 2 +- BTClockOTA-universal.spec | 2 +- BTClockOTA.spec | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/BTClockOTA-debug.spec b/BTClockOTA-debug.spec index 82b9de4..4c9cbbf 100644 --- a/BTClockOTA-debug.spec +++ b/BTClockOTA-debug.spec @@ -6,7 +6,7 @@ a = Analysis( pathex=[], binaries=[], datas=[], - hiddenimports=['zeroconf._utils.ipaddress', 'zeroconf._handlers.answers', 'pyserial', 'wx'], + hiddenimports=['zeroconf._utils.ipaddress', 'zeroconf._handlers.answers', 'pyserial', 'wx', 'wx._xml'], hookspath=[], hooksconfig={}, runtime_hooks=[], diff --git a/BTClockOTA-universal.spec b/BTClockOTA-universal.spec index 4a0929c..e3d1c07 100644 --- a/BTClockOTA-universal.spec +++ b/BTClockOTA-universal.spec @@ -7,7 +7,7 @@ a = Analysis( binaries=[], datas=[], hiddenimports=['zeroconf._utils.ipaddress', - 'zeroconf._handlers.answers', 'pyserial', 'wx'], + 'zeroconf._handlers.answers', 'pyserial', 'wx', 'wx._xml'], hookspath=[], hooksconfig={}, runtime_hooks=[], diff --git a/BTClockOTA.spec b/BTClockOTA.spec index 1aec7f3..0f3984b 100644 --- a/BTClockOTA.spec +++ b/BTClockOTA.spec @@ -6,7 +6,7 @@ a = Analysis( pathex=[], binaries=[], datas=[], - hiddenimports=['zeroconf._utils.ipaddress', 'zeroconf._handlers.answers', 'pyserial', 'wx'], + hiddenimports=['zeroconf._utils.ipaddress', 'zeroconf._handlers.answers', 'pyserial', 'wx', 'wx._xml'], hookspath=[], hooksconfig={}, runtime_hooks=[], From 018b0431df2550afd00f2646de40f5cb93b413b3 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Mon, 10 Jun 2024 20:39:39 +0200 Subject: [PATCH 17/26] Improve buttons, use application data dir for cache and downloads --- app.py | 11 ++++++----- app/fw_updater.py | 6 ++++-- app/gui/action_button_panel.py | 2 +- app/main.py | 27 +++++++++++++++++++++------ app/release_checker.py | 14 +++++--------- app/utils.py | 15 ++++++++++++++- 6 files changed, 51 insertions(+), 24 deletions(-) diff --git a/app.py b/app.py index 7e92065..e82a0c9 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,8 @@ +from app.main import BTClockOTAUpdater import wx -from app.main import BTClockOTAUpdater - -app = wx.App(False) -frame = BTClockOTAUpdater(None, 'BTClock OTA updater') -app.MainLoop() \ No newline at end of file +if __name__ == "__main__": + app = wx.App(False) + frame = BTClockOTAUpdater(None, 'BTClock OTA updater') + + app.MainLoop() diff --git a/app/fw_updater.py b/app/fw_updater.py index ec19d88..e4e5d8e 100644 --- a/app/fw_updater.py +++ b/app/fw_updater.py @@ -7,6 +7,8 @@ import esptool import serial import wx +from app.utils import get_app_data_folder + class FwUpdater: update_progress = None @@ -72,7 +74,7 @@ class FwUpdater: if (hw_rev == "REV_B_EPD_2_13"): model_name = "btclock_rev_b_213epd" - local_filename = f"firmware/{ + local_filename = f"{get_app_data_folder()}/{ release_name}_{model_name}_firmware.bin" self.updatingName = address @@ -88,7 +90,7 @@ class FwUpdater: def start_fs_update(self, release_name, address): # Path to the firmware file - local_filename = f"firmware/{release_name}_littlefs.bin" + local_filename = f"{get_app_data_folder()}/{release_name}_littlefs.bin" self.updatingName = address self.currentlyUpdating = True diff --git a/app/gui/action_button_panel.py b/app/gui/action_button_panel.py index 5c28669..bfbf3a2 100644 --- a/app/gui/action_button_panel.py +++ b/app/gui/action_button_panel.py @@ -27,7 +27,7 @@ class ActionButtonPanel(wx.Panel): self.update_button = wx.Button(self, label="Update Firmware") self.update_button.Bind(wx.EVT_BUTTON, self.on_click_update_firmware) - self.update_fs_button = wx.Button(self, label="Update Filesystem") + self.update_fs_button = wx.Button(self, label="Update WebUI") self.update_fs_button.Bind(wx.EVT_BUTTON, self.on_click_update_fs) self.identify_button = wx.Button(self, label="Identify") diff --git a/app/main.py b/app/main.py index 2b79b05..719dcde 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ import concurrent.futures import logging +import traceback import serial from app.gui.action_button_panel import ActionButtonPanel @@ -15,11 +16,14 @@ from app import espota from app.api import ApiHandler from app.fw_updater import FwUpdater from app.gui.devices_panel import DevicesPanel +from app.utils import get_app_data_folder from app.zeroconf_listener import ZeroconfListener from app.espota import FLASH, SPIFFS - +class BTClockOTAApp(wx.App): + def OnInit(self): + return True class RichTextCtrlHandler(logging.Handler): def __init__(self, ctrl): super().__init__() @@ -27,7 +31,7 @@ class RichTextCtrlHandler(logging.Handler): def emit(self, record): msg = self.format(record) - wx.CallAfter(self.append_text, msg + '\n') + wx.CallAfter(self.append_text, "\n" + msg) def append_text(self, text): self.ctrl.AppendText(text) @@ -46,6 +50,7 @@ class BTClockOTAUpdater(wx.Frame): def __init__(self, parent, title): wx.Frame.__init__(self, parent, title=title, size=(800, 500)) + self.SetMinSize((800, 500)) self.releaseChecker = ReleaseChecker() self.zeroconf = Zeroconf() @@ -62,7 +67,7 @@ class BTClockOTAUpdater(wx.Frame): self.log_ctrl.SetFont(monospace_font) handler = RichTextCtrlHandler(self.log_ctrl) - handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) + handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', '%H:%M:%S')) logging.getLogger().addHandler(handler) logging.getLogger().setLevel(logging.DEBUG) @@ -93,7 +98,7 @@ class BTClockOTAUpdater(wx.Frame): self.setup_ui() wx.CallAfter(self.fetch_latest_release_async) - + wx.YieldIfNeeded() def setup_ui(self): self.setup_menubar() self.status_bar = self.CreateStatusBar(2) @@ -102,6 +107,8 @@ class BTClockOTAUpdater(wx.Frame): def setup_menubar(self): filemenu = wx.Menu() + menuOpenDownloadDir = filemenu.Append( + wx.ID_OPEN, "&Open Download Dir", " Open the directory with firmware files and cache") menuAbout = filemenu.Append( wx.ID_ABOUT, "&About", " Information about this program") menuExit = filemenu.Append( @@ -109,8 +116,9 @@ class BTClockOTAUpdater(wx.Frame): menuBar = wx.MenuBar() menuBar.Append(filemenu, "&File") - self.SetMenuBar(menuBar) + self.SetMenuBar(menuBar) + self.Bind(wx.EVT_MENU, self.OnOpenDownloadFolder, menuOpenDownloadDir) self.Bind(wx.EVT_MENU, self.OnAbout, menuAbout) self.Bind(wx.EVT_MENU, self.OnExit, menuExit) @@ -175,6 +183,9 @@ class BTClockOTAUpdater(wx.Frame): def fetch_latest_release_async(self): # Start a new thread to execute fetch_latest_release + app_folder = get_app_data_folder() + if not os.path.exists(app_folder): + os.makedirs(app_folder) executor = concurrent.futures.ThreadPoolExecutor() future = executor.submit(self.releaseChecker.fetch_latest_release) future.add_done_callback(self.handle_latest_release) @@ -186,7 +197,11 @@ class BTClockOTAUpdater(wx.Frame): latest_release}\nCommit: {self.releaseChecker.commit_hash}") except Exception as e: self.fw_label.SetLabel(f"Error occurred: {str(e)}") - + traceback.print_tb(e.__traceback__) + + def OnOpenDownloadFolder(self, e): + wx.LaunchDefaultBrowser(get_app_data_folder()) + def OnAbout(self, e): dlg = wx.MessageDialog( self, "An updater for BTClocks", "About BTClock OTA Updater", wx.OK) diff --git a/app/release_checker.py b/app/release_checker.py index c10e04b..15272dc 100644 --- a/app/release_checker.py +++ b/app/release_checker.py @@ -6,9 +6,9 @@ import wx from typing import Callable from datetime import datetime, timedelta -from app.utils import keep_latest_versions +from app.utils import get_app_data_folder, keep_latest_versions -CACHE_FILE = 'firmware/cache.json' +CACHE_FILE = get_app_data_folder() + '/cache.json' CACHE_DURATION = timedelta(minutes=30) @@ -38,8 +38,6 @@ class ReleaseChecker: cache = self.load_cache() now = datetime.now() - if not os.path.exists("firmware"): - os.makedirs("firmware") if 'latest_release' in cache and (now - datetime.fromisoformat(cache['latest_release']['timestamp'])) < CACHE_DURATION: latest_release = cache['latest_release']['data'] @@ -106,14 +104,12 @@ class ReleaseChecker: '''Downloads Fimware Files''' local_filename = f"{release_name}_{url.split('/')[-1]}" - if not os.path.exists("firmware"): - os.makedirs("firmware") - if os.path.exists(f"firmware/{local_filename}"): + if os.path.exists(f"{get_app_data_folder()}/{local_filename}"): return response = requests.get(url, stream=True) total_length = response.headers.get('content-length') - keep_latest_versions('firmware', 2) + keep_latest_versions(get_app_data_folder(), 2) if total_length is None: raise ReleaseCheckerException("No content length header") @@ -121,7 +117,7 @@ class ReleaseChecker: total_length = int(total_length) chunk_size = 1024 num_chunks = total_length // chunk_size - with open(f"firmware/{local_filename}", 'wb') as f: + with open(f"{get_app_data_folder()}/{local_filename}", 'wb') as f: for i, chunk in enumerate(response.iter_content(chunk_size=chunk_size)): if chunk: f.write(chunk) diff --git a/app/utils.py b/app/utils.py index 8b79405..82559c7 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,6 +1,6 @@ import os import re -import shutil +import wx def count_versions(folder_path): @@ -30,3 +30,16 @@ def keep_latest_versions(folder_path, num_versions_to_keep=2): for version in versions_to_remove: for file_name in version_files[version]: os.remove(os.path.join(folder_path, file_name)) + +def get_app_data_folder(): + app = wx.GetApp() + if app is None: + app = wx.App(False) + standard_paths = wx.StandardPaths.Get() + app_data_dir = standard_paths.GetAppDocumentsDir() + "/BTClockOTA" + app.Destroy() + return app_data_dir + else: + standard_paths = wx.StandardPaths.Get() + app_data_dir = standard_paths.GetAppDocumentsDir() + "/BTClockOTA" + return app_data_dir \ No newline at end of file From 3cd9fcef46739eb15b27793d8b033f2391dc876c Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Sat, 21 Sep 2024 18:51:41 +0200 Subject: [PATCH 18/26] Add more exotic versions to HW detection --- app/fw_updater.py | 11 ++++++++--- app/release_checker.py | 5 ++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/fw_updater.py b/app/fw_updater.py index e4e5d8e..8bd2206 100644 --- a/app/fw_updater.py +++ b/app/fw_updater.py @@ -70,9 +70,14 @@ class FwUpdater: def start_firmware_update(self, release_name, address, hw_rev): # self.SetStatusText(f"Starting firmware update") - model_name = "lolin_s3_mini_213epd" - if (hw_rev == "REV_B_EPD_2_13"): - model_name = "btclock_rev_b_213epd" + hw_rev_to_model = { + "REV_B_EPD_2_13": "btclock_rev_b_213epd", + "REV_V8_EPD_2_13": "btclock_v8_213epd", + "REV_A_EPD_2_9": "lolin_s3_mini_29epd" + } + + model_name = hw_rev_to_model.get(hw_rev, "lolin_s3_mini_213epd") + local_filename = f"{get_app_data_folder()}/{ release_name}_{model_name}_firmware.bin" diff --git a/app/release_checker.py b/app/release_checker.py index 15272dc..032a26c 100644 --- a/app/release_checker.py +++ b/app/release_checker.py @@ -60,7 +60,10 @@ class ReleaseChecker: self.release_name = release_name filenames_to_download = ["lolin_s3_mini_213epd_firmware.bin", - "btclock_rev_b_213epd_firmware.bin", "littlefs.bin"] + "lolin_s3_mini_29epd_firmware.bin", + "btclock_v8_213epd_firmware.bin", + "btclock_rev_b_213epd_firmware.bin", + "littlefs.bin"] asset_urls = [asset['browser_download_url'] for asset in latest_release['assets'] if asset['name'] in filenames_to_download] From 7dfed6af6c012589c4d79ae9db697ca2f095e339 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Sun, 17 Nov 2024 21:27:50 -0600 Subject: [PATCH 19/26] Change API to rof.tools mirrors --- app/release_checker.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/app/release_checker.py b/app/release_checker.py index 032a26c..d5c897f 100644 --- a/app/release_checker.py +++ b/app/release_checker.py @@ -11,6 +11,7 @@ from app.utils import get_app_data_folder, keep_latest_versions CACHE_FILE = get_app_data_folder() + '/cache.json' CACHE_DURATION = timedelta(minutes=30) +LATEST_RELEASE_ENDPOINT = "https://git.rof.tools/api/v1/repos/mirrors/btclock_v3/tags" class ReleaseChecker: '''Release Checker for firmware updates''' @@ -34,7 +35,7 @@ class ReleaseChecker: def fetch_latest_release(self): '''Fetch latest firmware release from GitHub''' - repo = "btclock/btclock_v3" + repo = "mirrors/btclock_v3" cache = self.load_cache() now = datetime.now() @@ -42,7 +43,8 @@ class ReleaseChecker: if 'latest_release' in cache and (now - datetime.fromisoformat(cache['latest_release']['timestamp'])) < CACHE_DURATION: latest_release = cache['latest_release']['data'] else: - url = f"https://api.github.com/repos/{repo}/releases/latest" +# url = f"https://api.github.com/repos/{repo}/releases/latest" + url = f"https://git.rof.tools/api/v1/repos/{repo}/releases/latest" try: response = requests.get(url) response.raise_for_status() @@ -72,8 +74,9 @@ class ReleaseChecker: for asset_url in asset_urls: self.download_file(asset_url, release_name) - ref_url = f"https://api.github.com/repos/{ - repo}/git/ref/tags/{release_name}" + ref_url = f"https://git.rof.tools/api/v1/repos/{repo}/tags/{release_name}" + #ref_url = f"https://api.github.com/repos/{ +# repo}/git/ref/tags/{release_name}" if ref_url in cache and (now - datetime.fromisoformat(cache[ref_url]['timestamp'])) < CACHE_DURATION: commit_hash = cache[ref_url]['data'] @@ -81,15 +84,8 @@ class ReleaseChecker: response = requests.get(ref_url) response.raise_for_status() ref_info = response.json() - if ref_info["object"]["type"] == "commit": - commit_hash = ref_info["object"]["sha"] - else: - tag_url = f"https://api.github.com/repos/{ - repo}/git/tags/{ref_info['object']['sha']}" - response = requests.get(tag_url) - response.raise_for_status() - tag_info = response.json() - commit_hash = tag_info["object"]["sha"] + commit_hash = ref_info["commit"]["sha"] + cache[ref_url] = { 'data': commit_hash, 'timestamp': now.isoformat() From c820fb942117ac9c71c50dcd31d3c308579aeabd Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Tue, 26 Nov 2024 01:06:00 +0100 Subject: [PATCH 20/26] Change endpoint to btclock git --- app/release_checker.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/release_checker.py b/app/release_checker.py index d5c897f..f811c94 100644 --- a/app/release_checker.py +++ b/app/release_checker.py @@ -11,7 +11,7 @@ from app.utils import get_app_data_folder, keep_latest_versions CACHE_FILE = get_app_data_folder() + '/cache.json' CACHE_DURATION = timedelta(minutes=30) -LATEST_RELEASE_ENDPOINT = "https://git.rof.tools/api/v1/repos/mirrors/btclock_v3/tags" +LATEST_RELEASE_ENDPOINT = "https://git.btclock.dev/api/v1/repos/btclock/btclock_v3/tags" class ReleaseChecker: '''Release Checker for firmware updates''' @@ -35,7 +35,7 @@ class ReleaseChecker: def fetch_latest_release(self): '''Fetch latest firmware release from GitHub''' - repo = "mirrors/btclock_v3" + repo = "btclock/btclock_v3" cache = self.load_cache() now = datetime.now() @@ -44,7 +44,7 @@ class ReleaseChecker: latest_release = cache['latest_release']['data'] else: # url = f"https://api.github.com/repos/{repo}/releases/latest" - url = f"https://git.rof.tools/api/v1/repos/{repo}/releases/latest" + url = f"https://git.btclock.dev/api/v1/repos/{repo}/releases/latest" try: response = requests.get(url) response.raise_for_status() @@ -74,7 +74,7 @@ class ReleaseChecker: for asset_url in asset_urls: self.download_file(asset_url, release_name) - ref_url = f"https://git.rof.tools/api/v1/repos/{repo}/tags/{release_name}" + ref_url = f"https://git.btclock.dev/api/v1/repos/{repo}/tags/{release_name}" #ref_url = f"https://api.github.com/repos/{ # repo}/git/ref/tags/{release_name}" if ref_url in cache and (now - datetime.fromisoformat(cache[ref_url]['timestamp'])) < CACHE_DURATION: From 46da0c049be779d6eb98b639422d68aaa8bb1893 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Tue, 31 Dec 2024 11:52:58 +0100 Subject: [PATCH 21/26] Update for new filenames --- app/fw_updater.py | 16 ++++++++++++---- app/gui/action_button_panel.py | 3 ++- app/release_checker.py | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/app/fw_updater.py b/app/fw_updater.py index 8bd2206..6ac6b97 100644 --- a/app/fw_updater.py +++ b/app/fw_updater.py @@ -93,18 +93,26 @@ class FwUpdater: address, os.path.abspath(local_filename), FLASH)) thread.start() - def start_fs_update(self, release_name, address): + def start_fs_update(self, release_name, address, hw_rev): + hw_rev_to_model = { + "REV_B_EPD_2_13": "littlefs_8MB", + "REV_V8_EPD_2_13": "littlefs_16MB", + "REV_A_EPD_2_9": "littlefs_4MB" + } + # Path to the firmware file - local_filename = f"{get_app_data_folder()}/{release_name}_littlefs.bin" + local_filename = f"{get_app_data_folder()}/{release_name}_{hw_rev_to_model.get(hw_rev, "littlefs_4MB")}.bin" self.updatingName = address self.currentlyUpdating = True if self.event_cb is not None: - self.event_cb("Starting WebUI update") + self.event_cb(f"Starting WebUI update {local_filename}") if os.path.exists(os.path.abspath(local_filename)): thread = Thread(target=self.run_fs_update, args=( address, os.path.abspath(local_filename), SPIFFS)) thread.start() - + else: + if self.event_cb is not None: + self.event_cb(f"Firmware file not found: {local_filename}") diff --git a/app/gui/action_button_panel.py b/app/gui/action_button_panel.py index bfbf3a2..34e66e2 100644 --- a/app/gui/action_button_panel.py +++ b/app/gui/action_button_panel.py @@ -80,6 +80,7 @@ class ActionButtonPanel(wx.Panel): selected_index = self.device_list.GetFirstSelected() if selected_index != -1: service_name = self.device_list.GetItemText(selected_index, 0) + hw_rev = self.device_list.GetItemText(selected_index, 3) info = self.listener.services.get(service_name) if self.currentlyUpdating: wx.MessageBox("Please wait, already updating", @@ -89,7 +90,7 @@ class ActionButtonPanel(wx.Panel): if info: address = info.parsed_addresses( )[0] if info.parsed_addresses() else "N/A" - self.parent_frame.fw_updater.start_fs_update(self.parent_frame.releaseChecker.release_name, address) + self.parent_frame.fw_updater.start_fs_update(self.parent_frame.releaseChecker.release_name, address, hw_rev) else: wx.MessageBox( "No service information available for selected device", "Error", wx.ICON_ERROR) diff --git a/app/release_checker.py b/app/release_checker.py index f811c94..64b0f79 100644 --- a/app/release_checker.py +++ b/app/release_checker.py @@ -65,7 +65,7 @@ class ReleaseChecker: "lolin_s3_mini_29epd_firmware.bin", "btclock_v8_213epd_firmware.bin", "btclock_rev_b_213epd_firmware.bin", - "littlefs.bin"] + "littlefs_4MB.bin", "littlefs_8MB.bin", "littlefs_16MB.bin"] asset_urls = [asset['browser_download_url'] for asset in latest_release['assets'] if asset['name'] in filenames_to_download] From 86b4b50b9908a681cc5578d02b411eadd86f4594 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Tue, 31 Dec 2024 12:01:50 +0100 Subject: [PATCH 22/26] add forgejo workflow --- .forgejo/workflows/build_all.yaml | 170 ++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 .forgejo/workflows/build_all.yaml diff --git a/.forgejo/workflows/build_all.yaml b/.forgejo/workflows/build_all.yaml new file mode 100644 index 0000000..3e8698d --- /dev/null +++ b/.forgejo/workflows/build_all.yaml @@ -0,0 +1,170 @@ +name: Build all artifacts and make release + +on: + workflow_dispatch: + inputs: + build: + description: 'Select build type' + required: true + default: 'all' + options: + - all + - mac + - windows + - linux + +jobs: + build-macos: + if: ${{ github.event.inputs.build == 'all' || github.event.inputs.build == 'mac' }} + runs-on: macos-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Download universal2 Python + run: | + curl -o python.pkg https://www.python.org/ftp/python/3.12.4/python-3.12.4-macos11.pkg + - name: Install Python + run: | + sudo installer -pkg python.pkg -target / + - name: Add Python to PATH + run: | + echo "/Library/Frameworks/Python.framework/Versions/3.12/bin" >> $GITHUB_PATH + - name: Verify Python installation + run: | + python3 --version + pip3 --version + - name: Install dependencies + run: | + pip3 install --upgrade pip + pip3 install pyinstaller + pip3 install --no-cache cffi --no-binary :all: + pip3 install --no-cache charset_normalizer --no-binary :all: + pip3 install -U --pre -f https://wxpython.org/Phoenix/snapshot-builds/ wxPython + pip3 install -r requirements.txt + - name: Build with PyInstaller + run: | + pyinstaller BTClockOTA-universal.spec + - name: Zip the app bundle + run: | + cd dist + zip -r BTClockOTA-macos-universal2.zip BTClockOTA.app + - name: Create DMG + run: | + mkdir dmg_temp + cp -R dist/BTClockOTA.app dmg_temp/ + # Create the DMG file + hdiutil create -volname "BTClockOTA" -srcfolder dmg_temp -ov -format UDZO "dist/BTClockOTA-universal.dmg" + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-artifacts + path: | + dist/* + !dist/BTClockOTA.app + build-windows: + if: ${{ github.event.inputs.build == 'all' || github.event.inputs.build == 'windows' }} + runs-on: docker + container: + image: ghcr.io/catthehacker/ubuntu:act-22.04 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Run Docker Container + run: | + docker run --rm \ + --volume "${{ github.workspace }}:/src/" \ + --env SPECFILE=./BTClockOTA.spec \ + batonogov/pyinstaller-windows:latest + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-artifacts + path: dist/ + build-linux: + if: ${{ github.event.inputs.build == 'all' || github.event.inputs.build == 'linux' }} + runs-on: docker + container: + image: ghcr.io/catthehacker/ubuntu:act-22.04 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Run Docker Container + run: | + docker run --rm \ + --volume "${{ github.workspace }}:/src/" \ + --env SPECFILE=./BTClockOTA.spec \ + ghcr.io/btclock/pyinstaller-wxpython-linux:latest && + mv dist/BTClockOTA dist/BTClockOTA-linux-amd64 + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-artifacts + path: dist/ + release: + needs: [build-macos, build-windows, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Get current block + id: getBlockHeight + run: echo "blockHeight=$(curl -s https://mempool.space/api/blocks/tip/height)" >> $GITHUB_OUTPUT + - name: Get Windows Artifacts + if: ${{ always() }} + uses: actions/download-artifact@v4 + with: + name: windows-artifacts + path: windows + - name: Get macOS Artifacts + if: ${{ always() }} + uses: actions/download-artifact@v4 + with: + name: macos-artifacts + path: macos + - name: Get Linux Artifacts + if: ${{ always() }} + uses: actions/download-artifact@v4 + with: + name: linux-artifacts + path: linux + - name: Create release + uses: ncipollo/release-action@v1 + with: + tag: ${{ steps.getBlockHeight.outputs.blockHeight }} + commit: main + name: release-${{ steps.getBlockHeight.outputs.blockHeight }} + artifacts: "macos/**/*.dmg,macos/**/*.zip,windows/**/*.exe,linux/*" + allowUpdates: true + makeLatest: true From 3452a924f964e53d429e48df25bd9443a0280ba4 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Tue, 31 Dec 2024 12:02:53 +0100 Subject: [PATCH 23/26] Fix workflow --- .forgejo/workflows/build_all.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/build_all.yaml b/.forgejo/workflows/build_all.yaml index 3e8698d..3fda450 100644 --- a/.forgejo/workflows/build_all.yaml +++ b/.forgejo/workflows/build_all.yaml @@ -7,6 +7,7 @@ on: description: 'Select build type' required: true default: 'all' + type: choice options: - all - mac From cd5f999cdab718f10c74d6b8f70fb4edfb5a32d6 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Tue, 31 Dec 2024 12:15:13 +0100 Subject: [PATCH 24/26] Use docker images directly instead of dind --- .forgejo/workflows/build_all.yaml | 42 +++++-------------------------- 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/.forgejo/workflows/build_all.yaml b/.forgejo/workflows/build_all.yaml index 3fda450..00d0fa2 100644 --- a/.forgejo/workflows/build_all.yaml +++ b/.forgejo/workflows/build_all.yaml @@ -68,27 +68,15 @@ jobs: if: ${{ github.event.inputs.build == 'all' || github.event.inputs.build == 'windows' }} runs-on: docker container: - image: ghcr.io/catthehacker/ubuntu:act-22.04 + image: batonogov/pyinstaller-windows:latest permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Cache Docker layers - uses: actions/cache@v4 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- - - name: Run Docker Container + - name: Build with PyInstaller run: | - docker run --rm \ - --volume "${{ github.workspace }}:/src/" \ - --env SPECFILE=./BTClockOTA.spec \ - batonogov/pyinstaller-windows:latest + python -m PyInstaller $SPECFILE - name: Archive artifacts uses: actions/upload-artifact@v4 with: @@ -98,33 +86,15 @@ jobs: if: ${{ github.event.inputs.build == 'all' || github.event.inputs.build == 'linux' }} runs-on: docker container: - image: ghcr.io/catthehacker/ubuntu:act-22.04 + image: ghcr.io/btclock/pyinstaller-wxpython-linux:latest permissions: contents: write steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Cache Docker layers - uses: actions/cache@v4 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- - - name: Run Docker Container + - name: Build with PyInstaller run: | - docker run --rm \ - --volume "${{ github.workspace }}:/src/" \ - --env SPECFILE=./BTClockOTA.spec \ - ghcr.io/btclock/pyinstaller-wxpython-linux:latest && + python -m PyInstaller $SPECFILE && mv dist/BTClockOTA dist/BTClockOTA-linux-amd64 - name: Archive artifacts uses: actions/upload-artifact@v4 From 807d4d05851665860ce7c98e22576553a1b7b825 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Tue, 31 Dec 2024 12:20:20 +0100 Subject: [PATCH 25/26] Add ghcr authentication --- .forgejo/workflows/build_all.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.forgejo/workflows/build_all.yaml b/.forgejo/workflows/build_all.yaml index 00d0fa2..677c8d1 100644 --- a/.forgejo/workflows/build_all.yaml +++ b/.forgejo/workflows/build_all.yaml @@ -87,6 +87,8 @@ jobs: runs-on: docker container: image: ghcr.io/btclock/pyinstaller-wxpython-linux:latest + username: dsbaars + password: ${{ secrets.GH_TOKEN }} permissions: contents: write steps: From c8c69a39b42972a6e57a7897a94879dadc242b07 Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Tue, 31 Dec 2024 12:41:11 +0100 Subject: [PATCH 26/26] More workflow fixes --- .forgejo/workflows/build_all.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.forgejo/workflows/build_all.yaml b/.forgejo/workflows/build_all.yaml index 677c8d1..dfdf59e 100644 --- a/.forgejo/workflows/build_all.yaml +++ b/.forgejo/workflows/build_all.yaml @@ -66,7 +66,7 @@ jobs: !dist/BTClockOTA.app build-windows: if: ${{ github.event.inputs.build == 'all' || github.event.inputs.build == 'windows' }} - runs-on: docker + runs-on: docker-amd64 container: image: batonogov/pyinstaller-windows:latest permissions: @@ -84,11 +84,12 @@ jobs: path: dist/ build-linux: if: ${{ github.event.inputs.build == 'all' || github.event.inputs.build == 'linux' }} - runs-on: docker + runs-on: docker-amd64 container: image: ghcr.io/btclock/pyinstaller-wxpython-linux:latest - username: dsbaars - password: ${{ secrets.GH_TOKEN }} + credentials: + username: dsbaars + password: ${{ secrets.GH_TOKEN }} permissions: contents: write steps: