commit 10e2c89b6395bf2392254a4b56197b0a77cc11e9 Author: Djuri Baars Date: Sun May 19 02:42:36 2024 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05c5808 --- /dev/null +++ b/.gitignore @@ -0,0 +1,270 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos,windows,linux +# Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,macos,windows,linux + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos,windows,linux +firmware/*.bin \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..7fa4641 --- /dev/null +++ b/app.py @@ -0,0 +1,7 @@ +import wx + +from app.gui import BTClockOTAUpdater + +app = wx.App(False) +frame = BTClockOTAUpdater(None, 'BTClock OTA updater') +app.MainLoop() \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..7ebba98 --- /dev/null +++ b/app/api.py @@ -0,0 +1,24 @@ +import time +import requests +from threading import Thread + +class ApiHandler: + def identify_btclock(self, address): + self.make_api_call(address, "api/identify") + return + + def check_fs_hash(self, address): + ret = self.run_api_call(address, "fs_hash.txt") + return ret + + def make_api_call(self, address, path): + thread = Thread(target=self.run_api_call, args=(address,path)) + thread.start() + + def run_api_call(self, address, path): + try: + url = f"http://{address}/{path}" + response = requests.get(url) + return response.text + except requests.RequestException as e: + print("error") \ No newline at end of file diff --git a/app/espota.py b/app/espota.py new file mode 100644 index 0000000..487f8ed --- /dev/null +++ b/app/espota.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python +# +# Original espota.py by Ivan Grokhotkov: +# https://gist.github.com/igrr/d35ab8446922179dc58c +# +# Modified since 2015-09-18 from Pascal Gollor (https://github.com/pgollor) +# Modified since 2015-11-09 from Hristo Gochkov (https://github.com/me-no-dev) +# Modified since 2016-01-03 from Matthew O'Gorman (https://githumb.com/mogorman) +# +# This script will push an OTA update to the ESP +# use it like: +# python espota.py -i -I -p -P [-a password] -f +# Or to upload SPIFFS image: +# python espota.py -i -I -p -P [-a password] -s -f +# +# Changes +# 2015-09-18: +# - Add option parser. +# - Add logging. +# - Send command to controller to differ between flashing and transmitting SPIFFS image. +# +# Changes +# 2015-11-09: +# - Added digest authentication +# - Enhanced error tracking and reporting +# +# Changes +# 2016-01-03: +# - Added more options to parser. +# +# Changes +# 2023-05-22: +# - Replaced the deprecated optparse module with argparse. +# - Adjusted the code style to conform to PEP 8 guidelines. +# - Used with statement for file handling to ensure proper resource cleanup. +# - Incorporated exception handling to catch and handle potential errors. +# - Made variable names more descriptive for better readability. +# - Introduced constants for better code maintainability. + +from __future__ import print_function +import socket +import sys +import os +import argparse +import logging +import hashlib +import random + +# Commands +FLASH = 0 +SPIFFS = 100 +AUTH = 200 + +# Constants +PROGRESS_BAR_LENGTH = 60 + + +# update_progress(): Displays or updates a console progress bar +def update_progress(progress): + if PROGRESS: + status = "" + if isinstance(progress, int): + progress = float(progress) + if not isinstance(progress, float): + progress = 0 + status = "Error: progress var must be float\r\n" + if progress < 0: + progress = 0 + status = "Halt...\r\n" + if progress >= 1: + progress = 1 + status = "Done...\r\n" + block = int(round(PROGRESS_BAR_LENGTH * progress)) + text = "\rUploading: [{0}] {1}% {2}".format( + "=" * block + " " * (PROGRESS_BAR_LENGTH - block), int(progress * 100), status + ) + sys.stderr.write(text) + sys.stderr.flush() + else: + sys.stderr.write(".") + sys.stderr.flush() + + +def serve(remote_addr, local_addr, remote_port, local_port, password, filename, command=FLASH, progress_handler=False): # noqa: C901 + # Create a TCP/IP socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_address = (local_addr, local_port) + logging.info("Starting on %s:%s", str(server_address[0]), str(server_address[1])) + try: + sock.bind(server_address) + sock.listen(1) + except Exception as e: + logging.error("Listen Failed: %s", str(e)) + return 1 + + content_size = os.path.getsize(filename) + file_md5 = hashlib.md5(open(filename, "rb").read()).hexdigest() + logging.info("Upload size: %d", content_size) + message = "%d %d %d %s\n" % (command, local_port, content_size, file_md5) + + # Wait for a connection + inv_tries = 0 + data = "" + msg = "Sending invitation to %s " % remote_addr + sys.stderr.write(msg) + sys.stderr.flush() + while inv_tries < 10: + inv_tries += 1 + sock2 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + remote_address = (remote_addr, int(remote_port)) + try: + sent = sock2.sendto(message.encode(), remote_address) # noqa: F841 + except: # noqa: E722 + sys.stderr.write("failed\n") + sys.stderr.flush() + sock2.close() + logging.error("Host %s Not Found", remote_addr) + return 1 + sock2.settimeout(TIMEOUT) + try: + data = sock2.recv(37).decode() + break + except: # noqa: E722 + sys.stderr.write(".") + sys.stderr.flush() + sock2.close() + sys.stderr.write("\n") + sys.stderr.flush() + if inv_tries == 10: + logging.error("No response from the ESP") + return 1 + if data != "OK": + if data.startswith("AUTH"): + nonce = data.split()[1] + cnonce_text = "%s%u%s%s" % (filename, content_size, file_md5, remote_addr) + cnonce = hashlib.md5(cnonce_text.encode()).hexdigest() + passmd5 = hashlib.md5(password.encode()).hexdigest() + result_text = "%s:%s:%s" % (passmd5, nonce, cnonce) + result = hashlib.md5(result_text.encode()).hexdigest() + sys.stderr.write("Authenticating...") + sys.stderr.flush() + message = "%d %s %s\n" % (AUTH, cnonce, result) + sock2.sendto(message.encode(), remote_address) + sock2.settimeout(10) + try: + data = sock2.recv(32).decode() + except: # noqa: E722 + sys.stderr.write("FAIL\n") + logging.error("No Answer to our Authentication") + sock2.close() + return 1 + if data != "OK": + sys.stderr.write("FAIL\n") + logging.error("%s", data) + sock2.close() + sys.exit(1) + return 1 + sys.stderr.write("OK\n") + else: + logging.error("Bad Answer: %s", data) + sock2.close() + return 1 + sock2.close() + + logging.info("Waiting for device...") + try: + sock.settimeout(10) + connection, client_address = sock.accept() + sock.settimeout(None) + connection.settimeout(None) + except: # noqa: E722 + logging.error("No response from device") + sock.close() + return 1 + try: + with open(filename, "rb") as f: + if PROGRESS: + progress_handler(0) + else: + sys.stderr.write("Uploading") + sys.stderr.flush() + offset = 0 + while True: + chunk = f.read(1024) + if not chunk: + break + offset += len(chunk) + progress_handler(offset / float(content_size)) + connection.settimeout(10) + try: + connection.sendall(chunk) + 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 + + if last_response_contained_ok: + logging.info("Success") + connection.close() + return 0 + + sys.stderr.write("\n") + logging.info("Waiting for result...") + count = 0 + while count < 5: + count += 1 + connection.settimeout(60) + try: + data = connection.recv(32).decode() + logging.info("Result: %s", data) + + if "OK" in data: + logging.info("Success") + connection.close() + return 0 + + except Exception as e: + logging.error("Error receiving result: %s", str(e)) + connection.close() + return 1 + + logging.error("Error response from device") + connection.close() + return 1 + + finally: + connection.close() + + sock.close() + return 1 + + +def parse_args(unparsed_args): + parser = argparse.ArgumentParser(description="Transmit image over the air to the ESP32 module with OTA support.") + + # destination ip and port + parser.add_argument("-i", "--ip", dest="esp_ip", action="store", help="ESP32 IP Address.", default=False) + parser.add_argument("-I", "--host_ip", dest="host_ip", action="store", help="Host IP Address.", default="0.0.0.0") + parser.add_argument("-p", "--port", dest="esp_port", type=int, help="ESP32 OTA Port. Default: 3232", default=3232) + parser.add_argument( + "-P", + "--host_port", + dest="host_port", + type=int, + help="Host server OTA Port. Default: random 10000-60000", + default=random.randint(10000, 60000), + ) + + # authentication + parser.add_argument("-a", "--auth", dest="auth", help="Set authentication password.", action="store", default="") + + # image + parser.add_argument("-f", "--file", dest="image", help="Image file.", metavar="FILE", default=None) + parser.add_argument( + "-s", + "--spiffs", + dest="spiffs", + action="store_true", + help="Transmit a SPIFFS image and do not flash the module.", + default=False, + ) + + # output + parser.add_argument( + "-d", + "--debug", + dest="debug", + action="store_true", + help="Show debug output. Overrides loglevel with debug.", + default=False, + ) + parser.add_argument( + "-r", + "--progress", + dest="progress", + action="store_true", + help="Show progress output. Does not work for Arduino IDE.", + default=False, + ) + parser.add_argument( + "-t", + "--timeout", + dest="timeout", + type=int, + help="Timeout to wait for the ESP32 to accept invitation.", + default=10, + ) + + return parser.parse_args(unparsed_args) + + +def main(args): + options = parse_args(args) + log_level = logging.WARNING + if options.debug: + log_level = logging.DEBUG + + logging.basicConfig(level=log_level, format="%(asctime)-8s [%(levelname)s]: %(message)s", datefmt="%H:%M:%S") + logging.debug("Options: %s", str(options)) + + # check options + global PROGRESS + PROGRESS = options.progress + + global TIMEOUT + TIMEOUT = options.timeout + + if not options.esp_ip or not options.image: + logging.critical("Not enough arguments.") + return 1 + + command = FLASH + if options.spiffs: + command = SPIFFS + + return serve( + options.esp_ip, options.host_ip, options.esp_port, options.host_port, options.auth, options.image, command + ) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/app/fw_update.py b/app/fw_update.py new file mode 100644 index 0000000..e69de29 diff --git a/app/gui.py b/app/gui.py new file mode 100644 index 0000000..8d3ef62 --- /dev/null +++ b/app/gui.py @@ -0,0 +1,313 @@ +import random +from threading import Thread +import threading +import wx +from zeroconf import ServiceBrowser, Zeroconf +import requests +import os +import webbrowser + +from app import espota +from app.api import ApiHandler +from app.zeroconf_listener import ZeroconfListener + +from espota import FLASH,SPIFFS + +class DevicesPanel(wx.ListCtrl): + def __init__(self, parent): + wx.ListCtrl.__init__(self, parent, style=wx.LC_REPORT) + self.InsertColumn(0, 'Name', width=150) + self.InsertColumn(1, 'Version', width=50) + self.InsertColumn(2, 'SW Revision', width=310) + self.InsertColumn(3, 'HW Revision', width=130) + self.InsertColumn(4, 'IP', width=110) + self.InsertColumn(5, 'FS version', width=110) + +class BTClockOTAUpdater(wx.Frame): + release_name = "" + commit_hash = "" + + def __init__(self, parent, title): + wx.Frame.__init__(self, parent, title=title, size=(800,500)) + self.SetMinSize((800,500)) + ubuntu_it = wx.Font(32, wx.FONTFAMILY_MODERN, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD, False, faceName="Ubuntu") + # "Ubuntu-RI.ttf") + + + + panel = wx.Panel(self) + + self.title = wx.StaticText(panel, label="BTClock OTA firmware updater") + self.title.SetFont(ubuntu_it) + vbox = wx.BoxSizer(wx.VERTICAL) + vbox.Add(self.title, 0, wx.EXPAND | wx.ALL, 20, 0) + + self.device_list = DevicesPanel(panel) + self.device_list.Bind(wx.EVT_LIST_ITEM_SELECTED, self.on_item_selected) + self.device_list.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.on_item_deselected) + + vbox.Add(self.device_list, proportion = 2, flag=wx.EXPAND | wx.ALL, border = 20) + hbox = wx.BoxSizer(wx.HORIZONTAL) + bbox = wx.BoxSizer(wx.HORIZONTAL) + + gs = wx.GridSizer(1, 4, 1, 1) + + self.fw_label = wx.StaticText(panel, label=f"Checking latest version...") + self.update_button = wx.Button(panel, label="Update Firmware") + self.update_button.Bind(wx.EVT_BUTTON, self.on_click_update_firmware) + self.update_fs_button = wx.Button(panel, label="Update Filesystem") + self.update_fs_button.Bind(wx.EVT_BUTTON, self.on_click_update_fs) + + self.identify_button = wx.Button(panel, label="Identify") + self.identify_button.Bind(wx.EVT_BUTTON, self.on_click_identify) + self.open_webif_button = wx.Button(panel, label="Open WebUI") + self.open_webif_button.Bind(wx.EVT_BUTTON, self.on_click_webui) + self.update_button.Disable() + self.update_fs_button.Disable() + self.identify_button.Disable() + self.open_webif_button.Disable() + + hbox.Add(self.fw_label, 1, wx.EXPAND | wx.ALL, 5) + bbox.Add(self.update_button) + bbox.Add(self.update_fs_button) + bbox.Add(self.identify_button) + bbox.Add(self.open_webif_button) + + hbox.AddStretchSpacer() + hbox.Add(bbox, 2, wx.EXPAND | wx.ALL, 5) + vbox.Add(hbox, 0, wx.EXPAND | wx.ALL, 20) + + self.progress_bar = wx.Gauge(panel, range=100) + vbox.Add(self.progress_bar, 0, wx.EXPAND | wx.ALL, 20) + + panel.SetSizer(vbox) + + filemenu= wx.Menu() + menuAbout = filemenu.Append(wx.ID_ABOUT, "&About"," Information about this program") + menuExit = filemenu.Append(wx.ID_EXIT,"E&xit"," Terminate the program") + + menuBar = wx.MenuBar() + menuBar.Append(filemenu,"&File") # Adding the "filemenu" to the MenuBar + self.SetMenuBar(menuBar) # Adding the MenuBar to the Frame content. + + + + self.Bind(wx.EVT_MENU, self.OnAbout, menuAbout) + self.Bind(wx.EVT_MENU, self.OnExit, menuExit) + self.status_bar = self.CreateStatusBar(2) + # self.StatusBar.SetFieldsCount(2) +# self.StatusBar.SetStatusWidths(-3, -1) + self.Show(True) + + self.zeroconf = Zeroconf() + self.listener = ZeroconfListener(self.on_zeroconf_state_change) + self.browser = ServiceBrowser(self.zeroconf, "_http._tcp.local.", self.listener) + self.api_handler = ApiHandler() + + wx.CallAfter(self.fetch_latest_release) + + def on_item_selected(self, event): + self.update_button.Enable() + self.update_fs_button.Enable() + self.identify_button.Enable() + self.open_webif_button.Enable() + + def on_item_deselected(self, event): + if self.device_list.GetFirstSelected() == -1: + self.update_button.Disable() + self.update_fs_button.Disable() + self.identify_button.Disable() + self.open_webif_button.Disable() + + def on_zeroconf_state_change(self, type, name, state, info): + index = self.device_list.FindItem(0, name) + + if state == "Added": + if index == wx.NOT_FOUND: + index = self.device_list.InsertItem(self.device_list.GetItemCount(), type) + self.device_list.SetItem(index, 0, name) + self.device_list.SetItem(index, 1, info.properties.get(b"version").decode()) + self.device_list.SetItem(index, 2, info.properties.get(b"rev").decode()) + 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, 4, info.parsed_addresses()[0]) + + else: + self.device_list.SetItem(index, 0, name) + self.device_list.SetItem(index, 1, info.properties.get(b"version").decode()) + self.device_list.SetItem(index, 2, info.properties.get(b"rev").decode()) + 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, 4, info.parsed_addresses()[0]) + self.device_list.SetItem(index, 5, self.api_handler.check_fs_hash(info.parsed_addresses()[0])) + elif state == "Removed": + if index != wx.NOT_FOUND: + self.device_list.DeleteItem(index) + + def on_click_update_firmware(self, event): + 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 info: + address = info.parsed_addresses()[0] if info.parsed_addresses() else "N/A" + self.start_firmware_update(address, hw_rev) + else: + wx.MessageBox("No service information available for selected device", "Error", wx.ICON_ERROR) + else: + wx.MessageBox("Please select a device to update", "Error", wx.ICON_ERROR) + + def on_click_webui(self, event): + selected_index = self.device_list.GetFirstSelected() + if selected_index != -1: + service_name = self.device_list.GetItemText(selected_index, 0) + info = self.listener.services.get(service_name) + if info: + address = info.parsed_addresses()[0] if info.parsed_addresses() else "N/A" + thread = threading.Thread(target=lambda: webbrowser.open(f"http://{address}")) + thread.start() + + def run_fs_update(self, address, firmware_file, type): + global PROGRESS + PROGRESS = True + espota.PROGRESS = True + global TIMEOUT + TIMEOUT = 10 + espota.TIMEOUT = 10 + + espota.serve(address, "0.0.0.0", 3232, random.randint(10000,60000), "", firmware_file, type, self.call_progress) + + wx.CallAfter(self.update_progress, 100) + self.SetStatusText(f"Finished!") + + def call_progress(self, progress): + progressPerc = int(progress*100) + self.SetStatusText(f"Progress: {progressPerc}%") + wx.CallAfter(self.update_progress, progress) + + def update_progress(self, progress): + self.progress_bar.SetValue(int(progress*100)) + wx.YieldIfNeeded() + + def on_click_update_fs(self, event): + selected_index = self.device_list.GetFirstSelected() + if selected_index != -1: + service_name = self.device_list.GetItemText(selected_index, 0) + info = self.listener.services.get(service_name) + if info: + address = info.parsed_addresses()[0] if info.parsed_addresses() else "N/A" + self.start_fs_update(address) + else: + wx.MessageBox("No service information available for selected device", "Error", wx.ICON_ERROR) + else: + wx.MessageBox("Please select a device to update", "Error", wx.ICON_ERROR) + + def start_firmware_update(self, 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" + + local_filename = f"firmware/{self.release_name}_{model_name}_firmware.bin" + + if os.path.exists(os.path.abspath(local_filename)): + thread = Thread(target=self.run_fs_update, args=(address, os.path.abspath(local_filename), FLASH)) + thread.start() + + def start_fs_update(self, address): + # Path to the firmware file + self.SetStatusText(f"Starting filesystem update") + local_filename = f"firmware/{self.release_name}_littlefs.bin" + + 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() + + wx.CallAfter(self.update_progress, 100) + + def on_click_identify(self, event): + selected_index = self.device_list.GetFirstSelected() + if selected_index != -1: + service_name = self.device_list.GetItemText(selected_index, 0) + info = self.listener.services.get(service_name) + if info: + address = info.parsed_addresses()[0] if info.parsed_addresses() else "N/A" + port = info.port + self.api_handler.identify_btclock(address) + else: + wx.MessageBox("No service information available for selected device", "Error", wx.ICON_ERROR) + else: + wx.MessageBox("Please select a device to make an API call", "Error", wx.ICON_ERROR) + + def fetch_latest_release(self): + repo = "btclock/btclock_v3" + + 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}" + response = requests.get(ref_url) + response.raise_for_status() + ref_info = response.json() + self.commit_hash = ref_info["object"]["sha"] + self.fw_label.SetLabelText(f"Downloaded firmware version: {self.release_name}\nCommit: {self.commit_hash}") + + + else: + wx.CallAfter(self.SetStatusText, f"File {filenames_to_download} not found in latest release") + except requests.RequestException as e: + wx.CallAfter(self.SetStatusText, f"Error fetching release: {e}") + + + + def download_file(self, url, release_name): + local_filename = f"{release_name}_{url.split('/')[-1]}" + response = requests.get(url, stream=True) + total_length = response.headers.get('content-length') + + if os.path.exists(f"firmware/{local_filename}"): + wx.CallAfter(self.SetStatusText, f"{local_filename} is already downloaded") + return + + if total_length is None: + wx.CallAfter(self.SetStatusText, "No content length header") + else: + total_length = int(total_length) + chunk_size = 1024 + num_chunks = total_length // chunk_size + with open(f"firmware/{local_filename}", 'wb') as f: + for i, chunk in enumerate(response.iter_content(chunk_size=chunk_size)): + if chunk: + f.write(chunk) + f.flush() + progress = int((i / num_chunks) * 100) + wx.CallAfter(self.update_progress, progress) + + wx.CallAfter(self.update_progress, 100) + wx.CallAfter(self.SetStatusText, "Download completed") + + def OnAbout(self,e): + dlg = wx.MessageDialog( self, "An updater for BTClocks", "About BTClock OTA Updater", wx.OK) + dlg.ShowModal() + dlg.Destroy() + + def OnExit(self,e): + self.Close(True) \ No newline at end of file diff --git a/app/zeroconf_listener.py b/app/zeroconf_listener.py new file mode 100644 index 0000000..21204ea --- /dev/null +++ b/app/zeroconf_listener.py @@ -0,0 +1,29 @@ +import wx +from zeroconf import Zeroconf, ServiceBrowser, ServiceStateChange + +class ZeroconfListener: + release_name = "" + firmware_file = "" + + def __init__(self, update_callback): + self.update_callback = update_callback + # self.update_service = update_callback + self.services = {} + + def update_service(self, zc: Zeroconf, type: str, name: str) -> None: + if (name.startswith('btclock-')): + info = zc.get_service_info(type, name) + self.services[name] = info + wx.CallAfter(self.update_callback, type, name, "Added", info) + + def remove_service(self, zeroconf, type, name): + if name in self.services: + del self.services[name] + + wx.CallAfter(self.update_callback, type, name, "Removed") + + def add_service(self, zeroconf, type, name): + if (name.startswith('btclock-')): + info = zeroconf.get_service_info(type, name) + self.services[name] = info + wx.CallAfter(self.update_callback, type, name, "Added", info) \ No newline at end of file diff --git a/firmware/.gitkeep b/firmware/.gitkeep new file mode 100644 index 0000000..05c5808 --- /dev/null +++ b/firmware/.gitkeep @@ -0,0 +1,270 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos,windows,linux +# Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,macos,windows,linux + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos,windows,linux +firmware/*.bin \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..111d6d1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Requests==2.31.0 +wxPython==4.2.1 +zeroconf==0.132.2 diff --git a/update-icon.icns b/update-icon.icns new file mode 100644 index 0000000..4766bf8 Binary files /dev/null and b/update-icon.icns differ diff --git a/update-icon.png b/update-icon.png new file mode 100644 index 0000000..476363a Binary files /dev/null and b/update-icon.png differ