Initial commit
This commit is contained in:
commit
10e2c89b63
270
.gitignore
vendored
Normal file
270
.gitignore
vendored
Normal file
@ -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
|
7
app.py
Normal file
7
app.py
Normal file
@ -0,0 +1,7 @@
|
||||
import wx
|
||||
|
||||
from app.gui import BTClockOTAUpdater
|
||||
|
||||
app = wx.App(False)
|
||||
frame = BTClockOTAUpdater(None, 'BTClock OTA updater')
|
||||
app.MainLoop()
|
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
24
app/api.py
Normal file
24
app/api.py
Normal file
@ -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")
|
325
app/espota.py
Normal file
325
app/espota.py
Normal file
@ -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 <ESP_IP_addr> -I <Host_IP_addr> -p <ESP_port> -P <Host_port> [-a password] -f <sketch.bin>
|
||||
# Or to upload SPIFFS image:
|
||||
# python espota.py -i <ESP_IP_addr> -I <Host_IP_addr> -p <ESP_port> -P <HOST_port> [-a password] -s -f <spiffs.bin>
|
||||
#
|
||||
# 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:]))
|
0
app/fw_update.py
Normal file
0
app/fw_update.py
Normal file
313
app/gui.py
Normal file
313
app/gui.py
Normal file
@ -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)
|
29
app/zeroconf_listener.py
Normal file
29
app/zeroconf_listener.py
Normal file
@ -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)
|
270
firmware/.gitkeep
Normal file
270
firmware/.gitkeep
Normal file
@ -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
|
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
Requests==2.31.0
|
||||
wxPython==4.2.1
|
||||
zeroconf==0.132.2
|
BIN
update-icon.icns
Normal file
BIN
update-icon.icns
Normal file
Binary file not shown.
BIN
update-icon.png
Normal file
BIN
update-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.3 KiB |
Loading…
Reference in New Issue
Block a user