From bab4f35fc5c13341fb9cc96a7cb863c5cd5c3f53 Mon Sep 17 00:00:00 2001 From: P. J. McDermott Date: Sun, 01 Oct 2023 16:32:49 -0400 Subject: New upstream version 0.9.9 --- (limited to 'src') diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/__init__.py diff --git a/src/ble_dfu.py b/src/ble_dfu.py new file mode 100644 index 0000000..83d20d6 --- /dev/null +++ b/src/ble_dfu.py @@ -0,0 +1,334 @@ +from array import array +import gatt +import os +from .util import * +import math +from struct import unpack + +class InfiniTimeDFU(gatt.Device): + # Class constants + UUID_DFU_SERVICE = "00001530-1212-efde-1523-785feabcd123" + UUID_CTRL_POINT = "00001531-1212-efde-1523-785feabcd123" + UUID_PACKET = "00001532-1212-efde-1523-785feabcd123" + UUID_VERSION = "00001534-1212-efde-1523-785feabcd123" + + def __init__(self, mac_address, manager, window, firmware_path, datfile_path, verbose): + self.firmware_path = firmware_path + self.datfile_path = datfile_path + self.target_mac = mac_address + self.window = window + self.verbose = verbose + self.current_step = 0 + self.pkt_receipt_interval = 10 + self.pkt_payload_size = 20 + self.size_per_receipt = self.pkt_payload_size * self.pkt_receipt_interval + self.done = False + self.packet_recipt_count = 0 + self.total_receipt_size = 0 + self.update_in_progress = False + self.caffeinator = Caffeinator() + self.success = False + + super().__init__(mac_address, manager) + + def connect(self): + self.successful_connection = True + super().connect() + + def input_setup(self): + """Bin: read binfile into bin_array""" + print( + "preparing " + + os.path.split(self.firmware_path)[1] + + " for " + + self.target_mac + ) + + if self.firmware_path == None: + raise Exception("input invalid") + + name, extent = os.path.splitext(self.firmware_path) + + if extent == ".bin": + self.bin_array = array("B", open(self.firmware_path, "rb").read()) + + self.image_size = len(self.bin_array) + print("Binary image size: %d" % self.image_size) + print( + "Binary CRC32: %d" % crc32_unsigned(array_to_hex_string(self.bin_array)) + ) + return + raise Exception("input invalid") + + def connect_succeeded(self): + super().connect_succeeded() + print("[%s] Connected" % (self.mac_address)) + + def connect_failed(self, error): + super().connect_failed(error) + self.successful_connection = False + print("[%s] Connection failed: %s" % (self.mac_address, str(error))) + + def disconnect_succeeded(self): + super().disconnect_succeeded() + if not self.success: + self.on_failure() + print("[%s] Disconnected" % (self.mac_address)) + + def characteristic_enable_notifications_succeeded(self, characteristic): + if self.verbose and characteristic.uuid == self.UUID_CTRL_POINT: + print("Notification Enable succeeded for Control Point Characteristic") + self.step_one() + + def characteristic_write_value_succeeded(self, characteristic): + if self.verbose and characteristic.uuid == self.UUID_CTRL_POINT: + print( + "Characteristic value was written successfully for Control Point Characteristic" + ) + if self.verbose and characteristic.uuid == self.UUID_PACKET: + print( + "Characteristic value was written successfully for Packet Characteristic" + ) + if self.current_step == 1: + self.step_two() + elif self.current_step == 3: + self.step_four() + elif self.current_step == 5: + self.step_six() + elif self.current_step == 6: + print("Begin DFU") + self.caffeinator.caffeinate() + self.step_seven() + + def characteristic_write_value_failed(self, characteristic, error): + print("[WARN ] write value failed", str(error)) + self.update_in_progress = True + self.disconnect() + + def characteristic_value_updated(self, characteristic, value): + if self.verbose: + if characteristic.uuid == self.UUID_CTRL_POINT: + print( + "Characteristic value was updated for Control Point Characteristic" + ) + if characteristic.uuid == self.UUID_PACKET: + print("Characteristic value was updated for Packet Characteristic") + print("New value is:", value) + + hexval = array_to_hex_string(value) + + if hexval[:4] == "1001": + # Response::StartDFU + if hexval[4:] == "01": + self.step_three() + else: + print("[WARN ] StartDFU failed") + self.disconnect() + elif hexval[:4] == "1002": + # Response::InitDFUParameters + if hexval[4:] == "01": + self.step_five() + else: + print("[WARN ] InitDFUParameters failed") + self.disconnect() + elif hexval[:2] == "11": + # PacketReceiptNotification + self.packet_recipt_count += 1 + self.total_receipt_size += self.size_per_receipt + # verify that the returned size correspond to what was sent + ack_size = unpack(' 0: + print(alert_dict) + self.device.send_notification(alert_dict) + diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..a6cdb94 --- /dev/null +++ b/src/main.py @@ -0,0 +1,51 @@ +import sys +import gi + +gi.require_version("Gtk", "3.0") + +from gi.repository import Gtk, Gio, Gdk +from .window import SigloWindow +from .config import config + + +class Application(Gtk.Application): + def __init__(self): + self.manager = None + self.conf = config() + self.conf.load_defaults() + super().__init__( + application_id="com.github.theironrobin.siglo", flags=Gio.ApplicationFlags.FLAGS_NONE + ) + + def do_activate(self): + win = self.props.active_window + if not win: + win = SigloWindow(application=self) + win.present() + win.do_scanning() + + def do_window_removed(self, window): + win = self.props.active_window + if win: + win.destroy_manager() + self.quit() + + +def main(version): + def gtk_style(): + css = b""" +#multi_mac_label { font-size: 33px; } +#bluetooth_button { background-color: blue; + background-image: none; } + """ + style_provider = Gtk.CssProvider() + style_provider.load_from_data(css) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + style_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + gtk_style() + app = Application() + return app.run(sys.argv) diff --git a/src/meson.build b/src/meson.build new file mode 100644 index 0000000..cf633bd --- /dev/null +++ b/src/meson.build @@ -0,0 +1,41 @@ +pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) +moduledir = join_paths(pkgdatadir, 'siglo') +gnome = import('gnome') + +gnome.compile_resources('siglo', + 'siglo.gresource.xml', + gresource_bundle: true, + install: true, + install_dir: pkgdatadir, +) + +python = import('python') + +conf = configuration_data() +conf.set('PYTHON', python.find_installation('python3', modules: ['gatt']).full_path()) +conf.set('VERSION', meson.project_version()) +conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir'))) +conf.set('pkgdatadir', pkgdatadir) + +configure_file( + input: 'siglo.in', + output: 'siglo', + configuration: conf, + install: true, + install_dir: get_option('bindir') +) + +siglo_sources = [ + '__init__.py', + 'daemon.py', + 'quick_deploy.py', + 'main.py', + 'config.py', + 'window.py', + 'bluetooth.py', + 'ble_dfu.py', + 'ota/util.py', + 'ota/unpacker.py', +] + +install_data(siglo_sources, install_dir: moduledir) diff --git a/src/ota/Fork.txt b/src/ota/Fork.txt new file mode 100644 index 0000000..dbc1056 --- /dev/null +++ b/src/ota/Fork.txt @@ -0,0 +1 @@ +This directory contains source forked from https://github.com/daniel-thompson/ota-dfu-python. diff --git a/src/ota/LICENSE b/src/ota/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/src/ota/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/ota/unpacker.py b/src/ota/unpacker.py new file mode 100644 index 0000000..ab2ecbc --- /dev/null +++ b/src/ota/unpacker.py @@ -0,0 +1,52 @@ +import os.path +import zipfile +import tempfile +import random +import string +import shutil +import re + +from os.path import basename + +class Unpacker(object): + #-------------------------------------------------------------------------- + # + #-------------------------------------------------------------------------- + def entropy(self, length): + return ''.join(random.choice('abcdefghijklmnopqrstuvwxyz') for i in range (length)) + + #-------------------------------------------------------------------------- + # + #-------------------------------------------------------------------------- + def unpack_zipfile(self, file): + + if not os.path.isfile(file): + raise Exception("Error: file, not found!") + + # Create unique working direction into which the zip file is expanded + self.unzip_dir = "{0}/{1}_{2}".format(tempfile.gettempdir(), os.path.splitext(basename(file))[0], self.entropy(6)) + + datfilename = "" + binfilename = "" + + with zipfile.ZipFile(file, 'r') as zip: + files = [item.filename for item in zip.infolist()] + datfilename = [m.group(0) for f in files for m in [re.search('.*\.dat', f)] if m].pop() + binfilename = [m.group(0) for f in files for m in [re.search('.*\.bin', f)] if m].pop() + + zip.extractall(r'{0}'.format(self.unzip_dir)) + + datfile = "{0}/{1}".format(self.unzip_dir, datfilename) + binfile = "{0}/{1}".format(self.unzip_dir, binfilename) + + # print "DAT file: " + datfile + # print "BIN file: " + binfile + + return binfile, datfile + + #-------------------------------------------------------------------------- + # + #-------------------------------------------------------------------------- + def delete(self): + # delete self.unzip_dir and its contents + shutil.rmtree(self.unzip_dir) diff --git a/src/ota/util.py b/src/ota/util.py new file mode 100644 index 0000000..401d494 --- /dev/null +++ b/src/ota/util.py @@ -0,0 +1,70 @@ +import sys +import binascii +import re + +def bytes_to_uint32_le(bytes): + return (int(bytes[3], 16) << 24) | (int(bytes[2], 16) << 16) | (int(bytes[1], 16) << 8) | (int(bytes[0], 16) << 0) + +def uint32_to_bytes_le(uint32): + return [(uint32 >> 0) & 0xff, + (uint32 >> 8) & 0xff, + (uint32 >> 16) & 0xff, + (uint32 >> 24) & 0xff] + +def uint16_to_bytes_le(value): + return [(value >> 0 & 0xFF), + (value >> 8 & 0xFF)] + +def zero_pad_array_le(data, padsize): + for i in range(0, padsize): + data.insert(0, 0) + +def array_to_hex_string(arr): + hex_str = "" + for val in arr: + if val > 255: + raise Exception("Value is greater than it is possible to represent with one byte") + hex_str += "%02x" % val + + return hex_str + +def crc32_unsigned(bytestring): + return binascii.crc32(bytestring.encode('UTF-8')) % (1 << 32) + +def mac_string_to_uint(mac): + parts = list(re.match('(..):(..):(..):(..):(..):(..)', mac).groups()) + ints = [int(x, 16) for x in parts] + + res = 0 + for i in range(0, len(ints)): + res += (ints[len(ints)-1 - i] << 8*i) + + return res + +def uint_to_mac_string(mac): + ints = [0, 0, 0, 0, 0, 0] + for i in range(0, len(ints)): + ints[len(ints)-1 - i] = (mac >> 8*i) & 0xff + + return ':'.join(['{:02x}'.format(x).upper() for x in ints]) + +# Print a nice console progress bar +def print_progress(iteration, total, prefix = '', suffix = '', decimals = 1, barLength = 100): + """ + Call in a loop to create terminal progress bar + @params: + iteration - Required : current iteration (Int) + total - Required : total iterations (Int) + prefix - Optional : prefix string (Str) + suffix - Optional : suffix string (Str) + decimals - Optional : positive number of decimals in percent complete (Int) + barLength - Optional : character length of bar (Int) + """ + formatStr = "{0:." + str(decimals) + "f}" + percents = formatStr.format(100 * (iteration / float(total))) + filledLength = int(round(barLength * iteration / float(total))) + bar = 'x' * filledLength + '-' * (barLength - filledLength) + sys.stdout.write('\r%s |%s| %s%s %s (%d of %d bytes)' % (prefix, bar, percents, '%', suffix, iteration, total)), + if iteration == total: + sys.stdout.write('\n') + sys.stdout.flush() diff --git a/src/quick_deploy.py b/src/quick_deploy.py new file mode 100644 index 0000000..e55079f --- /dev/null +++ b/src/quick_deploy.py @@ -0,0 +1,65 @@ +import requests +import json + +url = "https://api.github.com/repos/JF002/InfiniTime/releases" +version_blacklist = ( + "0.6.0", + "0.6.1", + "0.6.2", + "0.7.0", + "0.7.1", + "0.8.0-develop", + "0.8.1-develop", + "0.8.2-develop", + "0.9.0-develop", + "0.9.0", + "0.8.3", + "0.8.2", + "0.10.0", + "0.11.0", + "0.12.0", + "0.12.1", +) + + +def get_quick_deploy_list(): + try: + r = requests.get(url) + except requests.exceptions.ConnectionError: + return [] + d = json.loads(r.content) + quick_deploy_list = [] + for item in d: + for asset in item["assets"]: + if ( + asset["content_type"] == "application/zip" + and item["tag_name"] not in version_blacklist + ): + helper_dict = { + "tag_name": item["tag_name"], + "name": asset["name"], + "browser_download_url": asset["browser_download_url"], + } + quick_deploy_list.append(helper_dict) + return quick_deploy_list + + +def get_tags(full_list): + tags = set() + for element in full_list: + tags.add(element["tag_name"]) + return sorted(tags, reverse=True) + + +def get_assets_by_tag(tag, full_list): + asset_list = [] + for element in full_list: + if tag == element["tag_name"]: + asset_list.append(element["name"]) + return asset_list + + +def get_download_url(name, tag, full_list): + for element in full_list: + if tag == element["tag_name"] and name == element["name"]: + return element["browser_download_url"] diff --git a/src/siglo.gresource.xml b/src/siglo.gresource.xml new file mode 100644 index 0000000..2c769e8 --- /dev/null +++ b/src/siglo.gresource.xml @@ -0,0 +1,11 @@ + + + + window.ui + watch.svg + watch-icon.svg + watch-check.svg + watch-progress.svg + watch-error.svg + + diff --git a/src/siglo.in b/src/siglo.in new file mode 100755 index 0000000..3f27cea --- /dev/null +++ b/src/siglo.in @@ -0,0 +1,47 @@ +#!@PYTHON@ + + +import os +import sys +import signal +import gettext +import argparse + +VERSION = '@VERSION@' +pkgdatadir = '@pkgdatadir@' +localedir = '@localedir@' + +sys.path.insert(1, pkgdatadir) +signal.signal(signal.SIGINT, signal.SIG_DFL) +gettext.install('siglo', localedir) + +def main(): + p = argparse.ArgumentParser(description="app to sync InfiniTime watch") + p.add_argument('--start', '-d', required=False, action='store_true', help="start daemon") + p.add_argument('--stop', '-x', required=False, action='store_true', help="stop daemon") + args = p.parse_args() + + from siglo import config + config = config.config() + config.load_defaults() + + from siglo import daemon + d = daemon.daemon() + + if args.start: + d.start() + elif args.stop: + d.stop() + else: + import gi + + from gi.repository import Gio + resource = Gio.Resource.load(os.path.join(pkgdatadir, 'siglo.gresource')) + resource._register() + + from siglo import main + sys.exit(main.main(VERSION)) + +if __name__ == '__main__': + main() + diff --git a/src/watch-check.svg b/src/watch-check.svg new file mode 100644 index 0000000..1b9ea75 --- /dev/null +++ b/src/watch-check.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/src/watch-error.svg b/src/watch-error.svg new file mode 100644 index 0000000..577e401 --- /dev/null +++ b/src/watch-error.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/src/watch-icon.svg b/src/watch-icon.svg new file mode 100644 index 0000000..e155e98 --- /dev/null +++ b/src/watch-icon.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/src/watch-progress.svg b/src/watch-progress.svg new file mode 100644 index 0000000..155c782 --- /dev/null +++ b/src/watch-progress.svg @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/src/watch.svg b/src/watch.svg new file mode 100644 index 0000000..0c79681 --- /dev/null +++ b/src/watch.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/src/window.py b/src/window.py new file mode 100644 index 0000000..939299c --- /dev/null +++ b/src/window.py @@ -0,0 +1,381 @@ +import subprocess +import configparser +import threading +import urllib.request +from pathlib import Path +import gatt +from gi.repository import Gtk, GObject, GLib +from .bluetooth import ( + InfiniTimeDevice, + InfiniTimeManager, + BluetoothDisabled, + NoAdapterFound, +) +from .ble_dfu import InfiniTimeDFU +from .unpacker import Unpacker +from .quick_deploy import * +from .config import config + + +class ConnectionThread(threading.Thread): + def __init__(self, manager, mac, callback): + threading.Thread.__init__(self) + self.mac = mac + self.manager = manager + self.callback = callback + self.device = None + + def run(self): + self.device = InfiniTimeDevice( + manager=self.manager, mac_address=self.mac, thread=True + ) + self.device.services_done = self.data_received + self.device.connect() + + def data_received(self): + firmware = bytes(self.device.firmware).decode() + if self.device.battery == -1: + battery = "n/a" + else: + battery = "{}%".format(self.device.battery) + GLib.idle_add(self.callback, [firmware, battery]) + + +@Gtk.Template(resource_path="/com/github/theironrobin/siglo/window.ui") +class SigloWindow(Gtk.ApplicationWindow): + __gtype_name__ = "SigloWindow" + # Navigation + main_stack = Gtk.Template.Child() + header_stack = Gtk.Template.Child() + + # Watches view + watches_listbox = Gtk.Template.Child() + + # Watch view + watch_name = Gtk.Template.Child() + watch_address = Gtk.Template.Child() + watch_firmware = Gtk.Template.Child() + watch_battery = Gtk.Template.Child() + ota_pick_tag_combobox = Gtk.Template.Child() + ota_pick_asset_combobox = Gtk.Template.Child() + firmware_run = Gtk.Template.Child() + firmware_file = Gtk.Template.Child() + firmware_run_file = Gtk.Template.Child() + keep_paired_switch = Gtk.Template.Child() + + # Flasher + dfu_stack = Gtk.Template.Child() + dfu_progress_bar = Gtk.Template.Child() + dfu_progress_text = Gtk.Template.Child() + + def __init__(self, **kwargs): + self.ble_dfu = None + self.ota_file = None + self.manager = None + self.current_mac = None + self.asset = None + self.asset_download_url = None + self.tag = None + self.conf = config() + super().__init__(**kwargs) + GObject.threads_init() + self.full_list = get_quick_deploy_list() + GObject.signal_new( + "flash-signal", + self, + GObject.SIGNAL_RUN_LAST, + GObject.TYPE_PYOBJECT, + (GObject.TYPE_PYOBJECT,), + ) + + def disconnect_paired_device(self): + try: + devices = self.manager.devices() + for d in devices: + if d.mac_address == self.manager.get_mac_address() and d.is_connected(): + d.disconnect() + finally: + self.conf.set_property("paired", "False") + + def destroy_manager(self): + if self.manager: + self.manager.stop() + self.manager = None + + def make_watch_row(self, name, mac): + row = Gtk.ListBoxRow() + grid = Gtk.Grid() + grid.set_hexpand(True) + grid.set_row_spacing(8) + grid.set_column_spacing(8) + grid.set_margin_top(8) + grid.set_margin_bottom(8) + grid.set_margin_left(8) + grid.set_margin_right(8) + row.add(grid) + + icon = Gtk.Image.new_from_resource("/com/github/theironrobin/siglo/watch-icon.svg") + grid.attach(icon, 0, 0, 1, 2) + + label_alias = Gtk.Label(label="Name", xalign=1.0) + label_alias.get_style_context().add_class("dim-label") + grid.attach(label_alias, 1, 0, 1, 1) + value_alias = Gtk.Label(label=name, xalign=0.0) + value_alias.set_hexpand(True) + grid.attach(value_alias, 2, 0, 1, 1) + + label_mac = Gtk.Label(label="Address", xalign=1.0) + label_mac.get_style_context().add_class("dim-label") + grid.attach(label_mac, 1, 1, 1, 1) + value_mac = Gtk.Label(label=mac, xalign=0.0) + grid.attach(value_mac, 2, 1, 1, 1) + + arrow = Gtk.Image.new_from_icon_name("go-next-symbolic", Gtk.IconSize.BUTTON) + grid.attach(arrow, 4, 0, 1, 2) + + row.show_all() + return row + + def do_scanning(self): + print("Start scanning") + self.main_stack.set_visible_child_name("scan") + self.header_stack.set_visible_child_name("scan") + if not self.manager: + # create manager if not present yet + try: + self.manager = InfiniTimeManager() + except (gatt.errors.NotReady, BluetoothDisabled): + print("Bluetooth is disabled") + self.main_stack.set_visible_child_name("nodevice") + except NoAdapterFound: + print("No bluetooth adapter found") + self.main_stack.set_visible_child_name("nodevice") + if not self.manager: + return + + if self.conf.get_property("paired"): + self.disconnect_paired_device() + + self.depopulate_listbox() + self.manager.scan_result = False + try: + self.manager.scan_for_infinitime() + except (gatt.errors.NotReady, gatt.errors.Failed) as e: + print(e) + self.main_stack.set_visible_child_name("nodevice") + self.destroy_manager() + try: + if len(self.manager.get_device_set()) > 0: + self.main_stack.set_visible_child_name("watches") + self.header_stack.set_visible_child_name("watches") + else: + self.main_stack.set_visible_child_name("nodevice") + for mac in self.manager.get_device_set(): + print("Found {}".format(mac)) + row = self.make_watch_row(self.manager.aliases[mac], mac) + row.mac = mac + row.alias = self.manager.aliases[mac] + self.watches_listbox.add(row) + except AttributeError as e: + print(e) + self.main_stack.set_visible_child_name("nodevice") + self.destroy_manager() + self.populate_tagbox() + + def depopulate_listbox(self): + children = self.watches_listbox.get_children() + for child in children: + self.watches_listbox.remove(child) + + def populate_tagbox(self): + self.ota_pick_tag_combobox.remove_all() + for tag in get_tags(self.full_list): + self.ota_pick_tag_combobox.append_text(tag) + + def populate_assetbox(self): + self.ota_pick_asset_combobox.remove_all() + for asset in get_assets_by_tag(self.tag, self.full_list): + self.ota_pick_asset_combobox.append_text(asset) + + def callback_device_connect(self, data): + firmware, battery = data + + self.watch_firmware.set_text(firmware) + self.watch_battery.set_text(battery) + + @Gtk.Template.Callback() + def on_watches_listbox_row_activated(self, widget, row): + mac = row.mac + self.current_mac = mac + alias = row.alias + + if self.keep_paired_switch.get_active(): + # Start daemon + subprocess.Popen(["systemctl", "--user", "start", "siglo"]) + self.conf.set_property("paired", "True") + + if self.manager is not None: + thread = ConnectionThread(self.manager, mac, self.callback_device_connect) + thread.daemon = True + thread.start() + + self.watch_name.set_text(alias) + self.watch_address.set_text(mac) + self.main_stack.set_visible_child_name("watch") + self.header_stack.set_visible_child_name("watch") + + @Gtk.Template.Callback() + def on_back_to_devices_clicked(self, *args): + self.main_stack.set_visible_child_name("watches") + self.header_stack.set_visible_child_name("watches") + + @Gtk.Template.Callback() + def ota_pick_tag_combobox_changed_cb(self, widget): + self.tag = self.ota_pick_tag_combobox.get_active_text() + self.populate_assetbox() + + @Gtk.Template.Callback() + def ota_pick_asset_combobox_changed_cb(self, widget): + self.asset = self.ota_pick_asset_combobox.get_active_text() + if self.asset is not None: + self.firmware_run.set_sensitive(True) + self.asset_download_url = get_download_url( + self.asset, self.tag, self.full_list + ) + else: + self.firmware_run.set_sensitive(False) + self.asset_download_url = None + + @Gtk.Template.Callback() + def firmware_file_file_set_cb(self, widget): + print("File set!") + filename = widget.get_filename() + self.ota_file = filename + self.firmware_run_file.set_sensitive(True) + + @Gtk.Template.Callback() + def rescan_button_clicked(self, widget): + self.do_scanning() + + @Gtk.Template.Callback() + def on_bluetooth_settings_clicked(self, widget): + subprocess.Popen(["gnome-control-center", "bluetooth"]) + + @Gtk.Template.Callback() + def ota_file_selected(self, widget): + filename = widget.get_filename() + self.ota_file = filename + self.main_info.set_text("File: " + filename.split("/")[-1]) + self.ota_picked_box.set_visible(True) + self.ota_selection_box.set_visible(False) + self.ota_picked_box.set_sensitive(True) + + @Gtk.Template.Callback() + def firmware_run_file_clicked_cb(self, widget): + self.dfu_stack.set_visible_child_name("ok") + self.main_stack.set_visible_child_name("firmware") + + self.firmware_mode = "manual" + + self.start_flash() + + @Gtk.Template.Callback() + def on_firmware_run_clicked(self, widget): + self.dfu_stack.set_visible_child_name("ok") + self.main_stack.set_visible_child_name("firmware") + + self.firmware_mode = "auto" + + file_name = "/tmp/" + self.asset + + print("Downloading {}".format(self.asset_download_url)) + + local_filename, headers = urllib.request.urlretrieve( + self.asset_download_url, file_name + ) + self.ota_file = local_filename + + self.start_flash() + + def start_flash(self): + unpacker = Unpacker() + try: + binfile, datfile = unpacker.unpack_zipfile(self.ota_file) + except Exception as e: + print("ERR") + print(e) + pass + + self.ble_dfu = InfiniTimeDFU( + mac_address=self.current_mac, + manager=self.manager, + window=self, + firmware_path=binfile, + datfile_path=datfile, + verbose=False, + ) + self.ble_dfu.on_failure = self.on_flash_failed + self.ble_dfu.on_success = self.on_flash_done + self.ble_dfu.input_setup() + self.dfu_progress_text.set_text(self.get_prog_text()) + self.ble_dfu.connect() + + def on_flash_failed(self): + self.dfu_stack.set_visible_child_name("fail") + + def on_flash_done(self): + self.dfu_stack.set_visible_child_name("done") + + @Gtk.Template.Callback() + def on_dfu_retry_clicked(self, widget): + if self.firmware_mode == "auto": + self.on_firmware_run_clicked(widget) + + @Gtk.Template.Callback() + def flash_it_button_clicked(self, widget): + if self.deploy_type == "quick": + file_name = "/tmp/" + self.asset + local_filename, headers = urllib.request.urlretrieve( + self.asset_download_url, file_name + ) + self.ota_file = local_filename + + @Gtk.Template.Callback() + def deploy_type_toggled(self, widget): + if ( + self.conf.get_property("deploy_type") == "manual" + and self.auto_switch_deploy_type + ): + self.auto_switch_deploy_type = False + else: + if self.conf.get_property("deploy_type") == "quick": + self.conf.set_property("deploy_type", "manual") + else: + self.conf.set_property("deploy_type", "quick") + self.rescan_button.emit("clicked") + + def update_progress_bar(self): + self.dfu_progress_bar.set_fraction( + self.ble_dfu.total_receipt_size / self.ble_dfu.image_size + ) + self.dfu_progress_text.set_text(self.get_prog_text()) + + def get_prog_text(self): + return ( + str(self.ble_dfu.total_receipt_size) + + " / " + + str(self.ble_dfu.image_size) + + " bytes received" + ) + + def show_complete(self, success): + if success: + self.rescan_button.set_sensitive("True") + self.main_info.set_text("OTA Update Complete") + else: + self.main_info.set_text("OTA Update Failed") + self.bt_spinner.set_visible(False) + self.dfu_progress_box.set_visible(False) + self.ota_picked_box.set_visible(True) + if self.conf.get_property("deploy_type") == "quick": + self.auto_bbox_scan_pass.set_visible(True) diff --git a/src/window.ui b/src/window.ui new file mode 100644 index 0000000..e183823 --- /dev/null +++ b/src/window.ui @@ -0,0 +1,1355 @@ + + + + + + True + False + + + True + False + Manual OTA File + True + + + + + + True + False + + + + + -- cgit v0.9.1