diff --git a/.checkignore b/.checkignore new file mode 100644 index 0000000..c899ef4 --- /dev/null +++ b/.checkignore @@ -0,0 +1 @@ +ez_setup.py diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f0374cc --- /dev/null +++ b/.travis.yml @@ -0,0 +1,44 @@ +env: +- TOXENV=py34 +- TOXENV=py33 +- TOXENV=py27 +- TOXENV=py26 +- TOXENV=pypy + +install: pip install -U tox + +language: python + +matrix: + include: + - python: 3.5 + env: TOXENV=py35 + +script: tox +cache: + directories: + - .tox + - $HOME/.cache/pip + +deploy: + provider: pypi + password: + secure: !!binary | + VHNRS0I3QlFmTFlzVVhJQk5zM1JUWUxjUkpidU5ZMk0vYS9PM3NuYjMyV2sxQm9uTkk4UzIzT1Zh + cFZnUnpZdDdaTU9xRlJDWmF0VENRWnlPRE9vSldqa1BXQ3lMNVZXODJWVWlrcEM3TjhES3ByOEM4 + L2xITkNaeHJhNmtWY3Y4S21PWVZaNUJCSksvY2dGQ2JJYURPN2E2L3E2SmZoRG80YmJLcDAwbmZX + ejh1d1I5ejg2V2FCbm9aQVdrOSs3ZGc2MW43WXJHaU9mSGozRFhLZWdyUlhmMHBNcTRVWUJjZTAv + eFlDWHdYcFNMQnNMZUZvU0p1bmczeDhGNWY2WXcwSWRzaXFFTnVENmQvYkZzK1JYaEd2REQ1RjJF + ZXMycnYrM3VVZzZwUEJMWk1mbjBTeGEwMDNkWG1YczhhSXFRL2hzR2oxNTl3MjE2MW5nNzJaRDlq + YVJuV2Z5RDhJdzZpWm9XeHh6ei9kaXJ5VVU5NVBRdUN1UzI0dndaZ1ZNVXp2SXB2RG5nV2JYYWwy + K05PcnNwdUtEeVpQR2trTmxlUUVGcS92MkY4NFY4aGtPNDV3TStmRGlCVkNuQmtpUUQ4aEhzRTB5 + TUpCSFpjRTM1ajZPdHlwVHBtMXJ5aXhlSFpxcXo2ckNGVVM3MzNjaTNTZi9RWndodklMMGZ6L29Q + YUllZlRZYmZ6WXVmWEgxaU5QNEJpMGxpZjVBVnpMZTZYZGdjY3VzY25OK2c4MWZHeVB2MlgxR3pD + S2pCclpSTWZFOHVhK3I0MnZkOU01L2R2dkM3dzFIV3ZMSVRvTHZyeVlrd0lPdU94L0dudFRxN3pP + c1licDhiOU1UMkplbXkzR0daa0ZNNlkwWnhsRWZKMGNHdVFXNnY3ZDUyZEZ2VUE1UDM5Q2J6b1E9 + user: Ban3 + distributions: sdist bdist_wheel + on: + condition: $TOXENV == py27 + repo: Ban3/python-evic + tags: true diff --git a/MANIFEST.in b/MANIFEST.in index d7ab55f..06b6e8f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,10 @@ include LICENSE include README.rst +include Makefile recursive-exclude * __pycache__ recursive-exclude * *.py[co] -recursive-include * *.rst Makefile +recursive-include * *.rst + +recursive-include udev * diff --git a/Makefile b/Makefile index c2daaef..ab4678f 100644 --- a/Makefile +++ b/Makefile @@ -4,12 +4,15 @@ help: @echo "clean - remove all build, test, coverage and Python artifacts" @echo "clean-build - remove build artifacts" @echo "clean-pyc - remove Python file artifacts" + @echo "clean-test - remove test artifacts" @echo "lint - check style with flake8" + @echo "test - run tests quickly with the default Python" + @echo "test-all - run tests on every Python version with tox" @echo "release - package and upload a release" @echo "dist - package" @echo "install - install the package to the active Python's site-packages" -clean: clean-build clean-pyc +clean: clean-build clean-pyc clean-test clean-build: rm -fr build/ @@ -24,9 +27,18 @@ clean-pyc: find . -name '*~' -exec rm -f {} + find . -name '__pycache__' -exec rm -fr {} + +clean-test: + rm -fr .tox/ + lint: flake8 evic tests +test: + python setup.py test + +test-all: + tox + release: clean python setup.py sdist upload python setup.py bdist_wheel upload diff --git a/README.rst b/README.rst index c474025..3e426d1 100644 --- a/README.rst +++ b/README.rst @@ -2,28 +2,70 @@ Evic =============================== +.. image:: https://travis-ci.org/Ban3/python-evic.svg?branch=master + :target: https://travis-ci.org/Ban3/python-evic -Evic decrypts/encrypts Joyetech Evic firmware images and uploads them using USB. +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. Supported devices --------------------- +* eVic VTwo* * Evic VTC Mini +* Cuboid Mini +* Cuboid +* eVic VTC Dual* +* eGrip II* +* eVic AIO* +* eVic VTwo mini* +* eVic Basic* +* iStick TC100W* +* ASTER* +* iStick Pico +* iStick Pico Mega* +* iPower* +* Presa TC75W* +* Vaporflask Classic* +* Vaporflask Lite* +* Vaporflask Stout* +* Reuleaux RX200* +* CENTURION* +* Reuleaux RX2/3* +* Reuleaux RX200S* + +\*Untested - Tested firmware versions ----------------------------- -* Evic VTC Mini 1.10 -* Evic VTC Mini 1.20 -* Evic VTC Mini 1.30 -* Evic VTC Mini 2.00 -* Presa TC75W 1.02 +* Evic VTC Mini <=3.03 +* Presa TC75W 1.02\* +* iStick Pico 1.01 +* Binaries built with `evic-sdk `_ + +\*Flashing Presa firmware to a VTC Mini requires changing the hardware version +on some devices. Backup your data flash before flashing! Installation ------------- Install from source: +^^^^^^^^^^^^^^^^^^^^^^ + +Using ``evic-usb`` requires ``cython-hidapi``. You can install it using ``pip``: + +:: + + $ pip install hidapi + +Building ``cython-hidapi`` requires libusb headers and cython. On Arch Linux they can be obtained from the repositories by installing packages ``libusb`` and ``cython``. Debian based distributions will have packages ``libusb-1.0-0-dev`` and ``cython``. + +On Windows you will also need the correct compiler for your Python version. See `this `_ +page for more information on setting up the compiler. + +| + +Building python-evic: :: @@ -31,35 +73,55 @@ Install from source: $ cd python-evic $ python setup.py install + +Allowing non-root access to the device +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The file ``udev/99-nuvoton-hid.rules`` contains an example set of rules for setting the device permissions to ``0666``. Copy the file to the directory ``/etc/udev/rules.d/`` to use it. + Usage ------- +See ``--help`` for more information on a given command. -Encrypt/decrypt a firmware image: +evic-convert +^^^^^^^^^^^^ +``evic-convert`` is a tool to encrypt/decrypt firmware images: :: - $ evic convert in.bin -o out.bin + $ evic-convert in.bin -o out.bin + +evic-usb +^^^^^^^^^^^^ +``evic-usb`` is a tool for interfacing with the device through USB. + Dump device data flash to a file: :: - $ evic dump-dataflash -o out.bin + $ evic-usb dump-dataflash -o out.bin Upload an encrypted firmware image to the device: :: - $ evic upload firmware.bin + $ evic-usb upload firmware.bin Upload an unencrypted firmware image to the device: :: - $ evic upload -u firmware.bin + $ evic-usb upload -u firmware.bin Upload a firmware image using data flash from a file: :: - $ evic upload -d data.bin firmware.bin + $ evic-usb upload -d data.bin firmware.bin + +Use ``--no-verify`` to disable verification for APROM or data flash. To disable both: + +:: + + $ evic-usb upload --no-verify aprom --no-verify dataflash firmware.bin diff --git a/evic/__init__.py b/evic/__init__.py index f04c47b..bc42e11 100644 --- a/evic/__init__.py +++ b/evic/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Evic decrypts/encrypts Joyetech Evic firmware images and uploads them using USB. +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. Copyright © Jussi Timperi This program is free software: you can redistribute it and/or modify @@ -20,6 +20,7 @@ __version__ = '0.1.dev0' -from .device import VTCMini, DataFlash -from .helpers import cal_checksum -from .binfile import BinFile, FirmwareException +from .device import HIDTransfer +from .aprom import APROM, APROMError +from .dataflash import DataFlash, DataFlashError +from .logo import Logo, LogoConversionError diff --git a/evic/aprom.py b/evic/aprom.py new file mode 100644 index 0000000..2f78aa4 --- /dev/null +++ b/evic/aprom.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +""" +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. +Copyright © Jussi Timperi + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import struct + + +class APROMError(Exception): + """APROM verification error.""" + + pass + + +class APROM(object): + """APROM file class + + Attributes: + data: A bytearray containing the binary data of the firmware. + """ + + def __init__(self, data): + self.data = bytearray(data) + + @staticmethod + def _genfun(filesize, index): + """Generator function for decrypting/encrypting the binary file. + + Args: + filesize: Filesize of the APROM file in bytes. + index: Index of the byte being converted. + """ + + return filesize + 408376 + index - filesize // 408376 + + def convert(self): + """Decrypts/Encrypts the binary data. + + Returns: + A Bytearray containing decrypted/encrypted APROM image. + """ + + data = bytearray(len(self.data)) + for i in range(0, len(self.data)): + data[i] = (self.data[i] ^ + self._genfun(len(self.data), i)) & 0xFF + return data + + def verify(self, product_ids, hw_version): + """Verifies the contained data. + + Data needs to be unencrypted. + + Args: + product_ids: A list of supported product IDs for the device. + hw_version: An integer device hardware version. + + Raises: + FirmwareException: Verification failed. + + """ + + # Does the APROM contain the string "Joyetech APROM"? + if b'Joyetech APROM' not in self.data: + raise APROMError("Firmware manufacturer verification failed.") + + id_ind = 0 + max_hw_version = 0 + # Try to locate supported product IDs + for product_id in product_ids: + try: + product_id = product_id.encode() + id_ind = self.data.index(product_id) + # Maximum hardware version follows the product ID + max_hw_ind = id_ind + len(product_id) + max_hw_version = struct.unpack("=I", bytes(b'\x00' + + self.data[max_hw_ind:max_hw_ind+3]))[0] + break + # Product ID was not found, try the next one + except ValueError: + continue + + # Raise an error if none of the supported product IDs were found + if not id_ind: + raise APROMError("Firmware device name verification failed.") + + # Raise an error if the maximum supported hardware version is less than + # the supplied hardware version + if max_hw_version < hw_version: + raise APROMError("Firmware hardware version verification failed.") diff --git a/evic/binfile.py b/evic/binfile.py deleted file mode 100644 index 7b5b7a5..0000000 --- a/evic/binfile.py +++ /dev/null @@ -1,76 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Evic decrypts/encrypts Joyetech Evic firmware images and uploads them using USB. -Copyright © Jussi Timperi - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - - -class FirmwareException(Exception): - """Exception for firmware verification""" - - pass - - -class BinFile(object): - """Firmware binary file - - Attributes: - data: binary data of the firmware image - - """ - def __init__(self, data): - self.data = bytearray(data) - - @staticmethod - def _genfun(filesize, index): - """Generator function for decrypting/encrypting the binary file - - Args: - filesize: An integer, filesize of the binary file - index: An integer, index of the byte that is being decrypted - - """ - return filesize + 408376 + index - filesize // 408376 - - def convert(self): - """ Decrypts/Encrypts the binary data. - - Returns: - A Bytearray containing decrypted/encrypted APROM image - """ - data = bytearray(len(self.data)) - for i in range(0, len(self.data)): - data[i] = (self.data[i] ^ - self._genfun(len(self.data), i)) & 0xFF - return data - - def verify(self, product_names): - """Verifies that the unencrypted APROM is correct - - Args: - product_names: A list of supported product names for the device - - Raises: - FirmwareException: Verification failed. - - """ - if b'Joyetech APROM' not in self.data: - raise FirmwareException( - "Firmware manufacturer verification failed") - for name in product_names: - if name in self.data: - return - raise FirmwareException("Firmware device name verification failed") diff --git a/evic/cli.py b/evic/cli.py index fe64790..9d81038 100644 --- a/evic/cli.py +++ b/evic/cli.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Evic decrypts/encryps Joyetech Evic firmware images and uploads them using USB. +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. Copyright © Jussi Timperi This program is free software: you can redistribute it and/or modify @@ -19,167 +19,359 @@ import sys +import os +import copy import struct -import argparse from time import sleep -from array import array +from contextlib import contextmanager -import usb.core +import click import evic -DEVICE_NAMES = {b'E052': "eVic-VTC Mini", b'W007': "Presa TC75W"} +from .device import DeviceInfo +@contextmanager +def handle_exceptions(*exceptions): + """Context for handling exceptions.""" -def main(): - """Console application's main entry point""" - - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers() - - parser_upload = subparsers.add_parser('upload', - help='Write firmware from INPUT \ - into device.') - parser_upload.add_argument('input', type=argparse.FileType('rb')) - parser_upload.add_argument('--unencrypted', '-u', action='store_true', - help='Use unencrypted firmware image.') - parser_upload.add_argument('--dataflash', '-d', - type=argparse.FileType('rb'), - help='Use data flash file insted the one on \ - the device') - parser_upload.set_defaults(which='upload') - - parser_convert = subparsers.add_parser('convert', - help='Decrypt/Encrypt firmware \ - from INPUT to OUTPUT.') - parser_convert.add_argument('input', type=argparse.FileType('rb')) - parser_convert.add_argument('--output', '-o', type=argparse.FileType('wb'), - required=True) - parser_convert.set_defaults(which='convert') - - parser_dumpdataflash = subparsers.add_parser('dump-dataflash', - help='Dump dataflash \ - to OUTPUT') - parser_dumpdataflash.add_argument('--output', '-o', - type=argparse.FileType('wb'), - required=True) - parser_dumpdataflash.set_defaults(which='dump-dataflash') - - if len(sys.argv) == 1: - parser.print_help() - sys.exit() - args = parser.parse_args() - - dev = evic.VTCMini() - if args.which in ['upload', 'convert']: - binfile = evic.BinFile(args.input.read()) - - if args.which == 'convert' or not args.unencrypted: - aprom = evic.BinFile(binfile.convert()) - else: - aprom = binfile + try: + yield + click.secho("OK", fg='green', bold=True) + except exceptions as error: + click.secho("FAIL", fg='red', bold=True) + click.echo(str(error), err=True) + sys.exit(1) - if args.which == 'convert': - try: - args.output.write(aprom.data) - except IOError: - print("Error: Can't write converted file.") - sys.exit() - try: - dev.attach() - - print("\nFound device:") - print("\tManufacturer: {0}".format(dev.device.manufacturer)) - print("\tProduct: {0}".format(dev.device.product)) - print("\tSerial No: {0}\n".format(dev.device.serial_number)) - - if args.which == 'upload': - try: - aprom.verify(dev.supported_device_names) - except evic.FirmwareException as error: - print(error) - sys.exit() - - print("Reading data flash...\n") - data_flash = evic.DataFlash(dev.get_sys_data()) - ldrom = data_flash.fw_version == 0 - - if args.which == 'upload' and args.dataflash: - df_file = array('B') - df_file.fromfile(args.dataflash, 2048) - data_flash = evic.DataFlash(df_file) - - if data_flash.device_name in DEVICE_NAMES: - devicename = DEVICE_NAMES[data_flash.device_name] +@click.group() +def usb(): + """A USB programmer for devices based on the Joyetech Evic VTC Mini.""" + + pass + + +def connect(dev): + """Connects the USB device. + + Args: + dev: evic.HIDTransfer object. + + Raises: + IOerror: The device was not found + """ + + # Connect the device + with handle_exceptions(IOError): + click.echo("\nFinding device...", nl=False) + dev.connect() + if not dev.manufacturer: + raise IOError("Device not found.") + + +def print_usb_info(dev): + """Prints the USB information attributes of the device + + Args: + dev: evic.HIDTransfer object + """ + + click.echo("\tManufacturer: ", nl=False) + click.secho(dev.manufacturer, bold=True) + click.echo("\tProduct: ", nl=False) + click.secho(dev.product, bold=True) + click.echo("\tSerial No: ", nl=False) + click.secho(dev.serial, bold=True) + click.echo("") + + +def read_dataflash(dev, verify): + """Reads the device data flash. + + Args: + dev: evic.HIDTransfer object. + verify: A Boolean set to True to verify the data flash. + + Returns: + evic.DataFlash object containing the device data flash. + """ + + # Read the data flash + with handle_exceptions(IOError): + click.echo("Reading data flash...", nl=False) + dataflash, checksum = dev.read_dataflash() + + # Verify the data flash + if verify: + verify_dataflash(dataflash, checksum) + + return dataflash + + +def print_device_info(device_info, dataflash): + """Prints the device information found from data flash. + + Args: + device_info: device.DeviceInfo tuple. + dataflash: evic.DataFlash object. + """ + + # Print out the information + click.echo("\tDevice name: ", nl=False) + click.secho(device_info.name, bold=True) + click.echo("\tFirmware version: ", nl=False) + click.secho("{0:.2f}".format(dataflash.fw_version / 100.0), bold=True) + click.echo("\tHardware version: ", nl=False) + click.secho("{0:.2f}\n".format(dataflash.hw_version / 100.0), bold=True) + + # Issue a warning about unset hardware version number + if dataflash.hw_version > 1000: + click.echo("Please set the hardware version.") + + +def verify_dataflash(dataflash, checksum): + """Verifies that the data flash is correct. + + Args: + data_flash: evic.DataFlash object. + checksum: Checksum used for the verification. + """ + + with handle_exceptions(evic.DataFlashError): + click.echo("Verifying data flash...", nl=False) + dataflash.verify(checksum) + + +@usb.command() +@click.argument('inputfile', type=click.File('rb')) +@click.option('--encrypted/--unencrypted', '-e/-u', default=True, + help='Use encrypted/unencrypted image. Defaults to encrypted.') +@click.option('--dataflash', 'dataflashfile', '-d', type=click.File('rb'), + help='Use data flash from a file.') +@click.option('--no-verify', 'noverify', + type=click.Choice(['aprom', 'dataflash']), multiple=True, + help='Disable verification for APROM or data flash.') +def upload(inputfile, encrypted, dataflashfile, noverify): + """Upload an APROM image to the device.""" + + dev = evic.HIDTransfer() + + # Connect the device + connect(dev) + + # Print the USB info of the device + print_usb_info(dev) + + # Read the data flash + verify = 'dataflash' not in noverify + dataflash = read_dataflash(dev, verify) + dataflash_original = copy.deepcopy(dataflash) + + # Get the device info + device_info = dev.devices.get(dataflash.product_id, + DeviceInfo("Unknown device", None, None)) + + # Print the device information + print_device_info(device_info, dataflash) + + # Read the APROM image + aprom = evic.APROM(inputfile.read()) + if encrypted: + aprom = evic.APROM(aprom.convert()) + + # Verify the APROM image + if 'aprom' not in noverify: + with handle_exceptions(evic.APROMError): + click.echo("Verifying APROM...", nl=False) + + supported_product_ids = [dataflash.product_id] + if device_info.supported_product_ids: + supported_product_ids.extend(device_info.supported_product_ids) + + aprom.verify(supported_product_ids, dataflash.hw_version) + + # Are we using a data flash file? + if dataflashfile: + buf = bytearray(dataflashfile.read()) + # We used to store the checksum inside the file + if len(buf) == 2048: + checksum = struct.unpack("=I", bytes(buf[0:4]))[0] + dataflash = evic.DataFlash(buf[4:], 0) else: - devicename = "Unknown device" - - print("\tDevice name: {0}".format(devicename)) - print("\tFirmware version: {0:.2f}".format(data_flash.fw_version)) - print("\tHardware version: {0:.2f}\n".format( - data_flash.hw_version / 100.0)) - - if evic.cal_checksum(data_flash.data[4:]) == data_flash.checksum \ - and data_flash.checksum \ - | struct.unpack("=I", data_flash.data[268:268+4])[0]: - if data_flash.hw_version > 1000: - print("Please set the hardware version.\n") - - if struct.unpack("=I", data_flash.data[264:264+4]) == 0 \ - or not data_flash.fw_version: - if args.which == 'upload' and not args.dataflash: - print("Reading data flash...\n") - data_flash = evic.DataFlash(dev.get_sys_data()) - - if args.which == 'dump-dataflash': - try: - print("Writing data flash to the file...\n") - args.output.write(data_flash.data) - except IOError: - print("Error: Can't write data flash file.") - sys.exit() - - new_df = data_flash.data - # Bootflag? - # 0 = APROM - # 1 = LDROm - new_df[13] = 1 - - # Flashing Presa firmware requires HW version 1.03 - if b'W007' in aprom.data and data_flash.device_name == b'E052' \ - and data_flash.hw_version in [106, 108, 109, 111]: - print("Changing HW version to 1.03...\n") - new_hw_version = bytearray(struct.pack("=I", 103)) - for i in range(4): - new_df[8+i] = new_hw_version[i] - - # Calculate new checksum - checksum = bytearray(struct.pack("=I", - evic.cal_checksum(new_df[4:]))) - for i in range(4): - new_df[i] = checksum[i] - - data_flash = evic.DataFlash(new_df) - - print("Writing data flash...\n") + checksum = sum(buf) + dataflash = evic.DataFlash(buf, 0) + if 'dataflash' not in noverify: + verify_dataflash(dataflash, checksum) + + # We want to boot to LDROM on restart + if not dev.ldrom: + dataflash.bootflag = 1 + + # Flashing Presa firmware requires HW version <=1.03 on type A devices + if b'W007' in aprom.data and dataflash.product_id == 'E052' \ + and dataflash.hw_version in [106, 108, 109, 111]: + click.echo("Changing HW version to 1.03...", nl=False) + dataflash.hw_version = 103 + click.secho("OK", fg='green', bold=True) + + # Write data flash to the device + with handle_exceptions(IOError): + if dataflash.array != dataflash_original.array: + click.echo("Writing data flash...", nl=False) + sleep(0.1) + dev.write_dataflash(dataflash) + click.secho("OK", fg='green', bold=True) + + # We should only restart if we're not in LDROM + if not dev.ldrom: + # Restart + click.echo("Restarting the device...", nl=False) + dev.reset() + sleep(2) + click.secho("OK", fg='green', nl=False, bold=True) + # Reconnect + connect(dev) + + # Write APROM to the device + click.echo("Writing APROM...", nl=False) + dev.write_aprom(aprom) + + +@usb.command('upload-logo') +@click.argument('inputfile', type=click.File('rb')) +@click.option('--invert', '-i', is_flag=True, + help='Invert the colors used in the image.') +@click.option('--no-verify', 'noverify', is_flag=True, + help='Disable data flash verification.') +def uploadlogo(inputfile, invert, noverify): + """Upload a logo to the device.""" + + dev = evic.HIDTransfer() + + # Connect the device + connect(dev) + + # Print the USB info of the device + print_usb_info(dev) + + # Read the data flash + dataflash = read_dataflash(dev, noverify) + dataflash_original = copy.deepcopy(dataflash) + + # Get the device info + device_info = dev.devices.get(dataflash.product_id, + DeviceInfo("Unknown device", None, None)) + + # Print the device information + print_device_info(device_info, dataflash) + + # Convert the image + with handle_exceptions(evic.LogoConversionError): + click.echo("Converting logo...", nl=False) + + # Check supported logo dimensions + logo_dimensions = device_info.logo_dimensions + if not logo_dimensions: + raise evic.LogoConversionError("Device doesn't support logos.") + + # Perform the actual conversion + logo = evic.logo.fromimage(inputfile, invert) + if (logo.width, logo.height) != logo_dimensions: + raise evic.LogoConversionError("Device only supports {}x{} logos." + .format(*logo_dimensions)) + + # We want to boot to LDROM on restart + if not dev.ldrom: + dataflash.bootflag = 1 + + # Write data flash to the device + with handle_exceptions(IOError): + if dataflash.array != dataflash_original.array: + click.echo("Writing data flash...", nl=False) + sleep(0.1) + dev.write_dataflash(dataflash) + click.secho("OK", fg='green', bold=True) + + # We should only restart if we're not in LDROM + if not dev.ldrom: + # Restart + click.echo("Restarting the device...", nl=False) + dev.reset() sleep(2) - dev.set_sys_data(data_flash) - if not ldrom: - print("Restarting the device...\n") - try: - dev.reset_system() - except usb.core.USBError: - print("Restart failed. Assuming the device is already \ - restarted to LDROM\n") - sleep(2) - print("Reconnecting the device...\n") - dev.attach() - - print("Uploading APROM...\n") - dev.upload_aprom(aprom) - print("Firmware upload complete!") - - except AssertionError as error: - print(error) - sys.exit() + click.secho("OK", fg='green', nl=False, bold=True) + # Reconnect + connect(dev) + + # Write logo to the device + click.echo("Writing logo...", nl=False) + dev.write_logo(logo) + + +@usb.command('dump-dataflash') +@click.option('--output', '-o', type=click.File('wb'), required=True) +@click.option('--no-verify', 'noverify', is_flag=True, + help='Disable verification.') +def dumpdataflash(output, noverify): + """Write device data flash to a file.""" + + dev = evic.HIDTransfer() + + # Connect the device + connect(dev) + + # Print the USB info of the device + print_usb_info(dev) + + # Read the data flash + dataflash = read_dataflash(dev, noverify) + + # Get the device info + device_info = dev.devices.get(dataflash.product_id, + DeviceInfo("Unknown device", None, None)) + + # Print the device information + print_device_info(device_info, dataflash) + + # Write the data flash to the file + with handle_exceptions(IOError): + click.echo("Writing data flash to the file...", nl=False) + output.write(dataflash.array) + + +@usb.command('reset-dataflash') +def resetdataflash(): + """Reset device data flash.""" + + dev = evic.HIDTransfer() + + # Connect the device + connect(dev) + + # Print the USB info of the device + print_usb_info(dev) + + # Reset data flash + with handle_exceptions(IOError): + click.echo("Resetting data flash...", nl=False) + dev.reset_dataflash() + + +@click.group() +def main(): + """A USB programmer for devices based on the Joyetech Evic VTC Mini.""" + + pass + + +@main.command() +@click.argument('inputfile', type=click.File('rb')) +@click.option('--output', '-o', type=click.File('wb'), required=True) +def convert(inputfile, output): + """Decrypt/encrypt an APROM image.""" + + binfile = evic.APROM(inputfile.read()) + + with handle_exceptions(IOError): + click.echo("Writing APROM image...", nl=False) + output.write(binfile.convert()) + os.chmod(output.name, os.stat(inputfile.name).st_mode) diff --git a/evic/dataflash.py b/evic/dataflash.py new file mode 100644 index 0000000..31abb2a --- /dev/null +++ b/evic/dataflash.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +""" +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. +Copyright © Jussi Timperi + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import binstruct + + +class DataFlashError(Exception): + """Data flash verification error.""" + + pass + + +class DataFlash(binstruct.StructTemplate): + """Device data flash class. + + Attributes: + hw_version: An integer hardware version number. + bootflag: 0 or 1. Controls whether APROM or LDROM is booted + when the device is restarted. + 0 = APROM + 1 = LDROM + product_id: Product ID string. + fw_version: An integer firmware version number. + ldrom_version: An integer LDROM versrion number. + """ + + hw_version = binstruct.Int32Field(4) + bootflag = binstruct.Int8Field(9) + product_id = binstruct.StringField(312, 4) + fw_version = binstruct.Int32Field(256) + ldrom_version = binstruct.Int32Field(260) + + def verify(self, checksum): + """Verifies the data flash against given checksum. + + Args: + checksum: Checksum of the data. + + Raises: + DataFlashError: Data flash verification failed. + """ + + if sum(self.array) != checksum: + raise DataFlashError("Data flash verification failed.") diff --git a/evic/device.py b/evic/device.py index e8bf203..383c234 100644 --- a/evic/device.py +++ b/evic/device.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Evic decrypts/encrypts Joyetech Evic firmware images and uploads them using USB. +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. Copyright © Jussi Timperi This program is free software: you can redistribute it and/or modify @@ -18,207 +18,270 @@ """ import struct +from collections import namedtuple -import usb.core -import usb.util +try: + import hid + HIDAPI_AVAILABLE = True +except ImportError: + HIDAPI_AVAILABLE = False -from .helpers import cal_checksum +from .dataflash import DataFlash +DeviceInfo = namedtuple('DeviceInfo', + 'name supported_product_ids logo_dimensions') -class Cmd(object): - """Nuvoton HID command class - - Available HID command codes: - 0x35: Read data flash. - 0x53: Write data flash. - 0xB4: Reset device. - 0xC3: Write APROM. +class HIDTransfer(object): + """Generic Nuvoton HID Transfer device class. Attributes: - cmd: A bytearray object for HID command code (1 byte) - length: A bytearray object for HID command length (1 byte) - arg1: A bytearray object for the first HID command argument (4 bytes) - arg2: A bytearray object for the second HID command argument (4 bytes) - signature: A bytearray object for HID command signature (4 bytes) - checksum: A bytearray object for the checksum of the HID command - (4 bytes) - fullcmd: A bytearray object for the full command (18 bytes) + vid: USB vendor ID. + pid: USB product ID. + devices: A dictionary mapping product IDs to DeviceInfo tuples. + hid_signature: A bytearray containing the HID command signature + (4 bytes). + device: A HIDAPI device. + manufacturer: A string containing the device manufacturer. + product: A string containing the product name. + serial: A string conraining the product serial number. + ldrom: A Boolean value set to True if the device is booted to LDROM. """ - signature = bytearray(struct.pack('=I', 0x43444948)) - # Do not count the last 4 bytes (checksum) - length = bytearray(struct.pack('=B', 14)) + vid = 0x0416 + pid = 0x5020 + devices = {'E043': DeviceInfo("eVic VTwo", None, (64, 40)), + 'E052': DeviceInfo("eVic-VTC Mini", ['W007'], (64, 40)), + 'E056': DeviceInfo("CUBOID MINI", None, (64, 40)), + 'E060': DeviceInfo("Cuboid", None, (64, 40)), + 'E079': DeviceInfo("eVic VTC Dual", None, (64, 40)), + 'E083': DeviceInfo("eGrip II", None, (64, 40)), + 'E092': DeviceInfo("eVic AIO", None, (64, 40)), + 'E115': DeviceInfo("eVic VTwo mini", None, (64, 40)), + 'E150': DeviceInfo("eVic Basic", None, (64, 40)), + 'M011': DeviceInfo("iStick TC100W", None, None), + 'M037': DeviceInfo("ASTER", None, (96, 16)), + 'M041': DeviceInfo("iStick Pico", None, (96, 16)), + 'M045': DeviceInfo("iStick Pico Mega", None, (96, 16)), + 'M046': DeviceInfo("iPower", None, (96, 16)), + 'W007': DeviceInfo("Presa TC75W", ['E052'], None), + 'W010': DeviceInfo("Classic", None, None), + 'W011': DeviceInfo("Lite", None, None), + 'W013': DeviceInfo("Stout", None, None), + 'W014': DeviceInfo("Reuleaux RX200", None, None), + 'W016': DeviceInfo("CENTURION", None, None), + 'W018': DeviceInfo("Reuleaux RX2/3", None, (64, 48)), + 'W026': DeviceInfo("Reuleaux RX75", None, (64, 48)), + 'W033': DeviceInfo("Reuleaux RX200S", None, None) + } + + # 0x43444948 + hid_signature = bytearray(b'HIDC') - def __init__(self, cmd, arg1, arg2): - self.cmd = bytearray(struct.pack('=B', cmd)) - self.arg1 = bytearray(struct.pack('=I', arg1)) - self.arg2 = bytearray(struct.pack('=I', arg2)) - self.fullcmd = self.cmd + self.length + self.arg1 + self.arg2 +\ - self.signature - self.checksum = bytearray(struct.pack('=I', - cal_checksum(self.fullcmd))) - self.fullcmd += self.checksum + def __init__(self): + if HIDAPI_AVAILABLE: + self.device = hid.device() + else: + self.device = None + self.manufacturer = None + self.product = None + self.serial = None + self.ldrom = False + + @classmethod + def hidcmd(cls, cmdcode, arg1, arg2): + """Generates a Nuvoton HID command. + Args: + cmdcode: A byte long HID command. + arg1: First HID command argument. + arg2: Second HID command argument. -class DataFlash(object): - """ Device data flash class + Returns: + A bytearray containing the full HID command. + """ - Attributes: - data: An array containing binary data of the data flash - device_name: A bytestring containing device name. - hw_version: An integer hardware version. - fw_version: A float firmware version. - checksum: An integer checksum for data flash. + # Do not count the last 4 bytes (checksum) + length = bytearray([14]) - """ + # Construct the command + cmdcode = bytearray(struct.pack('=B', cmdcode)) + arg1 = bytearray(struct.pack('=I', arg1)) + arg2 = bytearray(struct.pack('=I', arg2)) + cmd = cmdcode + length + arg1 + arg2 + cls.hid_signature - def __init__(self, df): - self.data = df - self.device_name = df[316:316+4].tostring() - self.hw_version = struct.unpack("=I", df[8:8+4])[0] - self.fw_version = struct.unpack("=I", df[260:260+4])[0] / 100.0 - self.checksum = struct.unpack("=I", df[0:4])[0] + # Return the command with checksum tacked at the end + return cmd + bytearray(struct.pack('=I', sum(cmd))) + def connect(self): + """Connects the USB device. -class VTCMini(object): - """Evic VTC Mini + Connects the device and saves the USB device info attributes. + """ - Attributes: - vid = USB vendor ID as an integer. - pid = USB product ID as an integer. - supported_device_names: A list of bytestrings containing the name of - the product with compatible firmware - device: PyUSB device for the VTC Mini. + self.device.open(self.vid, self.pid) + if not self.manufacturer: + self.manufacturer = self.device.get_manufacturer_string() + self.product = self.device.get_product_string() + self.serial = self.device.get_serial_number_string() - """ + def send_command(self, cmd, arg1, arg2): + """Sends a HID command to the device. - vid = 0x0416 - pid = 0x5020 - supported_device_names = [b'E052', b'W007'] + Args: + cmd: Byte long HID command + arg1: First argument to the command (integer) + arg2: Second argument to the command (integer) + """ - def __init__(self): - self.device = None + command = self.hidcmd(cmd, arg1, arg2) + self.write(command) - def attach(self): - """Detaches kernel drivers from the device and claims it + def read_dataflash(self): + """Reads the device data flash. - Raises: - AssertionError: If device could not be opened. + ldrom attribute will be set to to True if the device is in LDROM. + Returns: + A tuple containing the data flash and its checksum. """ - self.device = usb.core.find(idVendor=self.vid, idProduct=self.pid) - assert self.device, "Device not found" - try: - if self.device.is_kernel_driver_active(0): - self.device.detach_kernel_driver(0) - self.device.set_configuration() - usb.util.claim_interface(self.device, 0) - except NotImplementedError: - pass - def send_cmd(self, cmd): - """Sends a HID command + start = 0 + end = 2048 - Writes a HID command to the device. + # Send the command for reading the data flash + self.send_command(0x35, start, end) - Args: - cmd: A bytearray object for the HID command in the form of - Cmd.fullcommand + # Read the dataflash + buf = self.read(end) + dataflash = DataFlash(buf[4:], 0) - Returns: - An integer count of bytes written to the device - """ - return self.device.write(0x2, cmd, 1000) + # Get the checksum from the beginning of the data flash transfer + checksum = struct.unpack('=I', bytes(buf[0:4]))[0] - def get_sys_data(self): - """Sends the HID command for reading data flash (0x35) + # Are we booted to LDROM? + self.ldrom = dataflash.ldrom_version or not dataflash.fw_version - Writes the HID command to the device and returns the data flash from - the device. + return (dataflash, checksum) - Returns: - An array containing the binary data of the data flash. + def write(self, data): + """Writes data to the device. + + Args: + data: An iterable containing the binary data. Raises: - AssertionError: Correct amount of bytes was not written to the - device. (18 bytes) + IOError: Incorrect amount of bytes was written. """ - start = 0 - end = 2048 + bytes_written = 0 + + # Split the data into 64 byte long chunks + chunks = [bytearray(data[i:i+64]) for i in range(0, len(data), 64)] - cmd = Cmd(0x35, start, end) - assert self.send_cmd(cmd.fullcmd) == 18,\ - "Error: Sending read data flash command failed." + # Write the chunks to the device + for chunk in chunks: + buf = bytearray([0]) + chunk # First byte is the report number + bytes_written += self.device.write(buf) - 1 - return self.read_data(end) + # Windows always writes full pages + if bytes_written > len(data): + bytes_written -= 64 - (len(data) % 64) - def read_data(self, count): - """Reads data from the device + # Raise IOerror if the amount sent doesn't match what we wanted + if bytes_written != len(data): + raise IOError("HID Write failed.") + + def read(self, length): + """Reads data from the device. Args: - count: An integer, count of bytes to read. + length: Amount of bytes to read. Returns: - An array object of the data read. + A bytearray containing the binary data. Raises: - AssertionError: Incorrect amount of bytes was read. + IOError: Incorrect amount of bytes was read. """ - data = self.device.read(0x81, count) - assert len(data) == count, 'Error: Read failed' - return data - def set_sys_data(self, df): - """Sends the HID command for writing data flash (0x53) + data = [] + pages, rem = divmod(length, 64) + for _ in range(0, pages): + data += self.device.read(64) + if rem: + data += self.device.read(rem) - Writes the HID command to the device and writes 2048 bytes from - the df argument to the device data flash. + # Windows always reads full pages + if len(data) > length: + data = data[:length] - Args: - df: A DataFlash object containing the data flash data + # Raise IOerror if the amount read doesn't match what we wanted + if len(data) != length: + raise IOError("HID read failed") - Raises: - AssertionError: Incorrect amount of bytes was written. + return bytearray(data) + def write_dataflash(self, dataflash): + """Writes the data flash to the device. + + Args: + dataflash: A DataFlash object. """ + # We want 2048 bytes start = 0 end = 2048 - cmd = Cmd(0x53, start, end) + # Send the command for writing the data flash + self.send_command(0x53, start, end) - assert self.send_cmd(cmd.fullcmd) == 18,\ - "Error: Sending write data flash command failed." + # Add checksum of the data in front of it + buf = bytearray(struct.pack("=I", sum(dataflash.array))) + \ + dataflash.array - assert self.device.write(0x2, df.data, 100000) == 2048,\ - "Error: Writing data flash failed" + self.write(buf) - def reset_system(self): - """Sends the HID command for reseting the system (0xB4) + def reset_dataflash(self): + """Resets the device data flash. + Sends a data flash reset request to the firmware. """ - cmd = Cmd(0xB4, 0, 0) - assert self.send_cmd(cmd.fullcmd) == 18,\ - "Error: Sending reset command failed." + self.send_command(0x7C, 0, 0) + + def reset(self): + """Sends the HID command for resetting the system (0xB4)""" - def upload_aprom(self, aprom): - """Writes APROM to the the device. (0xC3) + self.send_command(0xB4, 0, 0) + + def write_flash(self, data, start): + """Writes data to the flash memory. Args: - aprom: A BinFile object containing unencrypted APROM image + start: Start address. + """ - Raises: - AssertionError: Incorrect amount of bytes was written. + end = len(data) + + # Send the command for writing the data + self.send_command(0xC3, start, end) + + self.write(data) + + def write_aprom(self, aprom): + """Writes the APROM to the device. + Args: + aprom: A BinFile object containing an unencrypted APROM image. """ - start = 0 - end = len(aprom.data) - cmd = Cmd(0xC3, start, end) - assert self.send_cmd(cmd.fullcmd) == 18,\ - "Error: Sending write APROM command failed." + self.write_flash(aprom.data, 0) + + def write_logo(self, logo): + """Writes the logo to the the device. + + Args: + logo: A Logo object. + """ - assert self.device.write(0x2, aprom.data, 1000000) == len(aprom.data),\ - "Error: APROM write failed" + self.write_flash(logo.array, 102400) diff --git a/evic/helpers.py b/evic/helpers.py deleted file mode 100644 index fb7a87f..0000000 --- a/evic/helpers.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Evic decrypts/encrypts Joyetech Evic firmware images and uploads them using USB. -Copyright © Jussi Timperi - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - - -def cal_checksum(data): - """Calculates a checksum for the data - - Args: - data: An iterable. - - """ - return sum(data) diff --git a/evic/logo.py b/evic/logo.py new file mode 100644 index 0000000..c2a5a0b --- /dev/null +++ b/evic/logo.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +""" +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. +Copyright © Jussi Timperi + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import binstruct +from bitarray import bitarray +from PIL import Image + + +class LogoConversionError(Exception): + """Logo conversion error.""" + + pass + + +class Logo(binstruct.StructTemplate): + """Logo class. + + Attributes: + width: Logo width (integer). + height: Logo height (integer). + """ + + width = binstruct.Int8Field(0) + height = binstruct.Int8Field(1) + + +def fromimage(image, invert=False): + """Converts an image file to a Logo object. + + Args: + image: The image that will be converted (file). + invert: True will invert colors from the source image (boolean). + + Returns: + An instance of Logo class containing the converted image. + """ + + img = Image.open(image) + + width, height = img.size + + if width % 8 != 0 or height % 8 != 0: + raise LogoConversionError("Image dimensions must be multiples of 8.") + if width * height > 4080: + raise LogoConversionError("Image is too big.") + + # Convert to b/w + if img.mode != '1': + img = img.convert('L') + img = img.point(lambda x: 0 if x < 32 else 255, '1') + + # 1 bit per pixel + bits = bitarray(list(img.getdata())) + + # Convert to paged column-major order + # 1 bit per pixel, 8 rows per page, LSB topmost + imgpixels = img.load() + pagedbits = bitarray(endian='little') + for page in range(0, height // 8): + for x in range(0, width): + for y in range(0, 8): + pagedbits.append(imgpixels[x, page*8 + y]) + + # Invert colors + if invert: + bits.invert() + pagedbits.invert() + + # Convert the bitarrays to bytes + imgbytes = bits.tobytes() if hasattr( + bits, 'tobytes') else bits.tostring() + pagedbytes = pagedbits.tobytes() if hasattr( + pagedbits, 'tobytes') else pagedbits.tostring() + + # Create a buffer for the logo + buff = bytearray(1024) + buff[0], buff[512] = (width,)*2 + buff[1], buff[513] = (height,)*2 + + # Copy logo to the buffer + buff[2:len(imgbytes) + 2] = imgbytes + buff[514:len(pagedbytes) + 514] = pagedbytes + + return Logo(buff, 0) diff --git a/ez_setup.py b/ez_setup.py new file mode 100644 index 0000000..9715bdc --- /dev/null +++ b/ez_setup.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python + +""" +Setuptools bootstrapping installer. + +Run this script to install or upgrade setuptools. +""" + +import os +import shutil +import sys +import tempfile +import zipfile +import optparse +import subprocess +import platform +import textwrap +import contextlib +import json +import codecs + +from distutils import log + +try: + from urllib.request import urlopen +except ImportError: + from urllib2 import urlopen + +try: + from site import USER_SITE +except ImportError: + USER_SITE = None + +LATEST = object() +DEFAULT_VERSION = LATEST +DEFAULT_URL = "https://pypi.python.org/packages/source/s/setuptools/" +DEFAULT_SAVE_DIR = os.curdir + + +def _python_cmd(*args): + """ + Execute a command. + + Return True if the command succeeded. + """ + args = (sys.executable,) + args + return subprocess.call(args) == 0 + + +def _install(archive_filename, install_args=()): + """Install Setuptools.""" + with archive_context(archive_filename): + # installing + log.warn('Installing Setuptools') + if not _python_cmd('setup.py', 'install', *install_args): + log.warn('Something went wrong during the installation.') + log.warn('See the error message above.') + # exitcode will be 2 + return 2 + + +def _build_egg(egg, archive_filename, to_dir): + """Build Setuptools egg.""" + with archive_context(archive_filename): + # building an egg + log.warn('Building a Setuptools egg in %s', to_dir) + _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) + # returning the result + log.warn(egg) + if not os.path.exists(egg): + raise IOError('Could not build the egg.') + + +class ContextualZipFile(zipfile.ZipFile): + + """Supplement ZipFile class to support context manager for Python 2.6.""" + + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + self.close() + + def __new__(cls, *args, **kwargs): + """Construct a ZipFile or ContextualZipFile as appropriate.""" + if hasattr(zipfile.ZipFile, '__exit__'): + return zipfile.ZipFile(*args, **kwargs) + return super(ContextualZipFile, cls).__new__(cls) + + +@contextlib.contextmanager +def archive_context(filename): + """ + Unzip filename to a temporary directory, set to the cwd. + + The unzipped target is cleaned up after. + """ + tmpdir = tempfile.mkdtemp() + log.warn('Extracting in %s', tmpdir) + old_wd = os.getcwd() + try: + os.chdir(tmpdir) + with ContextualZipFile(filename) as archive: + archive.extractall() + + # going in the directory + subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) + os.chdir(subdir) + log.warn('Now working in %s', subdir) + yield + + finally: + os.chdir(old_wd) + shutil.rmtree(tmpdir) + + +def _do_download(version, download_base, to_dir, download_delay): + """Download Setuptools.""" + egg = os.path.join(to_dir, 'setuptools-%s-py%d.%d.egg' + % (version, sys.version_info[0], sys.version_info[1])) + if not os.path.exists(egg): + archive = download_setuptools(version, download_base, + to_dir, download_delay) + _build_egg(egg, archive, to_dir) + sys.path.insert(0, egg) + + # Remove previously-imported pkg_resources if present (see + # https://bitbucket.org/pypa/setuptools/pull-request/7/ for details). + if 'pkg_resources' in sys.modules: + _unload_pkg_resources() + + import setuptools + setuptools.bootstrap_install_from = egg + + +def use_setuptools( + version=DEFAULT_VERSION, download_base=DEFAULT_URL, + to_dir=DEFAULT_SAVE_DIR, download_delay=15): + """ + Ensure that a setuptools version is installed. + + Return None. Raise SystemExit if the requested version + or later cannot be installed. + """ + version = _resolve_version(version) + to_dir = os.path.abspath(to_dir) + + # prior to importing, capture the module state for + # representative modules. + rep_modules = 'pkg_resources', 'setuptools' + imported = set(sys.modules).intersection(rep_modules) + + try: + import pkg_resources + pkg_resources.require("setuptools>=" + version) + # a suitable version is already installed + return + except ImportError: + # pkg_resources not available; setuptools is not installed; download + pass + except pkg_resources.DistributionNotFound: + # no version of setuptools was found; allow download + pass + except pkg_resources.VersionConflict as VC_err: + if imported: + _conflict_bail(VC_err, version) + + # otherwise, unload pkg_resources to allow the downloaded version to + # take precedence. + del pkg_resources + _unload_pkg_resources() + + return _do_download(version, download_base, to_dir, download_delay) + + +def _conflict_bail(VC_err, version): + """ + Setuptools was imported prior to invocation, so it is + unsafe to unload it. Bail out. + """ + conflict_tmpl = textwrap.dedent(""" + The required version of setuptools (>={version}) is not available, + and can't be installed while this script is running. Please + install a more recent version first, using + 'easy_install -U setuptools'. + + (Currently using {VC_err.args[0]!r}) + """) + msg = conflict_tmpl.format(**locals()) + sys.stderr.write(msg) + sys.exit(2) + + +def _unload_pkg_resources(): + del_modules = [ + name for name in sys.modules + if name.startswith('pkg_resources') + ] + for mod_name in del_modules: + del sys.modules[mod_name] + + +def _clean_check(cmd, target): + """ + Run the command to download target. + + If the command fails, clean up before re-raising the error. + """ + try: + subprocess.check_call(cmd) + except subprocess.CalledProcessError: + if os.access(target, os.F_OK): + os.unlink(target) + raise + + +def download_file_powershell(url, target): + """ + Download the file at url to target using Powershell. + + Powershell will validate trust. + Raise an exception if the command cannot complete. + """ + target = os.path.abspath(target) + ps_cmd = ( + "[System.Net.WebRequest]::DefaultWebProxy.Credentials = " + "[System.Net.CredentialCache]::DefaultCredentials; " + '(new-object System.Net.WebClient).DownloadFile("%(url)s", "%(target)s")' + % locals() + ) + cmd = [ + 'powershell', + '-Command', + ps_cmd, + ] + _clean_check(cmd, target) + + +def has_powershell(): + """Determine if Powershell is available.""" + if platform.system() != 'Windows': + return False + cmd = ['powershell', '-Command', 'echo test'] + with open(os.path.devnull, 'wb') as devnull: + try: + subprocess.check_call(cmd, stdout=devnull, stderr=devnull) + except Exception: + return False + return True +download_file_powershell.viable = has_powershell + + +def download_file_curl(url, target): + cmd = ['curl', url, '--silent', '--output', target] + _clean_check(cmd, target) + + +def has_curl(): + cmd = ['curl', '--version'] + with open(os.path.devnull, 'wb') as devnull: + try: + subprocess.check_call(cmd, stdout=devnull, stderr=devnull) + except Exception: + return False + return True +download_file_curl.viable = has_curl + + +def download_file_wget(url, target): + cmd = ['wget', url, '--quiet', '--output-document', target] + _clean_check(cmd, target) + + +def has_wget(): + cmd = ['wget', '--version'] + with open(os.path.devnull, 'wb') as devnull: + try: + subprocess.check_call(cmd, stdout=devnull, stderr=devnull) + except Exception: + return False + return True +download_file_wget.viable = has_wget + + +def download_file_insecure(url, target): + """Use Python to download the file, without connection authentication.""" + src = urlopen(url) + try: + # Read all the data in one block. + data = src.read() + finally: + src.close() + + # Write all the data in one block to avoid creating a partial file. + with open(target, "wb") as dst: + dst.write(data) +download_file_insecure.viable = lambda: True + + +def get_best_downloader(): + downloaders = ( + download_file_powershell, + download_file_curl, + download_file_wget, + download_file_insecure, + ) + viable_downloaders = (dl for dl in downloaders if dl.viable()) + return next(viable_downloaders, None) + + +def download_setuptools( + version=DEFAULT_VERSION, download_base=DEFAULT_URL, + to_dir=DEFAULT_SAVE_DIR, delay=15, + downloader_factory=get_best_downloader): + """ + Download setuptools from a specified location and return its filename. + + `version` should be a valid setuptools version number that is available + as an sdist for download under the `download_base` URL (which should end + with a '/'). `to_dir` is the directory where the egg will be downloaded. + `delay` is the number of seconds to pause before an actual download + attempt. + + ``downloader_factory`` should be a function taking no arguments and + returning a function for downloading a URL to a target. + """ + version = _resolve_version(version) + # making sure we use the absolute path + to_dir = os.path.abspath(to_dir) + zip_name = "setuptools-%s.zip" % version + url = download_base + zip_name + saveto = os.path.join(to_dir, zip_name) + if not os.path.exists(saveto): # Avoid repeated downloads + log.warn("Downloading %s", url) + downloader = downloader_factory() + downloader(url, saveto) + return os.path.realpath(saveto) + + +def _resolve_version(version): + """ + Resolve LATEST version + """ + if version is not LATEST: + return version + + resp = urlopen('https://pypi.python.org/pypi/setuptools/json') + with contextlib.closing(resp): + try: + charset = resp.info().get_content_charset() + except Exception: + # Python 2 compat; assume UTF-8 + charset = 'UTF-8' + reader = codecs.getreader(charset) + doc = json.load(reader(resp)) + + return str(doc['info']['version']) + + +def _build_install_args(options): + """ + Build the arguments to 'python setup.py install' on the setuptools package. + + Returns list of command line arguments. + """ + return ['--user'] if options.user_install else [] + + +def _parse_args(): + """Parse the command line for options.""" + parser = optparse.OptionParser() + parser.add_option( + '--user', dest='user_install', action='store_true', default=False, + help='install in user site package (requires Python 2.6 or later)') + parser.add_option( + '--download-base', dest='download_base', metavar="URL", + default=DEFAULT_URL, + help='alternative URL from where to download the setuptools package') + parser.add_option( + '--insecure', dest='downloader_factory', action='store_const', + const=lambda: download_file_insecure, default=get_best_downloader, + help='Use internal, non-validating downloader' + ) + parser.add_option( + '--version', help="Specify which version to download", + default=DEFAULT_VERSION, + ) + parser.add_option( + '--to-dir', + help="Directory to save (and re-use) package", + default=DEFAULT_SAVE_DIR, + ) + options, args = parser.parse_args() + # positional arguments are ignored + return options + + +def _download_args(options): + """Return args for download_setuptools function from cmdline args.""" + return dict( + version=options.version, + download_base=options.download_base, + downloader_factory=options.downloader_factory, + to_dir=options.to_dir, + ) + + +def main(): + """Install or upgrade setuptools and EasyInstall.""" + options = _parse_args() + archive = download_setuptools(**_download_args(options)) + return _install(archive, _build_install_args(options)) + +if __name__ == '__main__': + sys.exit(main()) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..f05f933 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[aliases] +test=pytest + +[bdist_wheel] +universal=1 diff --git a/setup.py b/setup.py index 3a8125f..acff250 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -Evic decrypts/encrypts Joyetech Evic firmware images and uploads them using USB. +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. Copyright © Jussi Timperi This program is free software: you can redistribute it and/or modify @@ -21,13 +21,19 @@ try: from setuptools import setup except ImportError: - from distutils.core import setup + import ez_setup + ez_setup.use_setuptools() + from setuptools import setup + with open('README.rst') as readme_file: readme = readme_file.read() -requirements = [ - 'pyusb' +REQUIREMENTS = [ + 'binstruct', + 'bitarray', + 'click', + 'pillow' ] setup( @@ -35,12 +41,15 @@ version="0.1", author="Jussi Timperi", author_email="jussi.timperi@iki.fi", - description=("A tool to decrypt/encrypt and USB upload Evic firmware."), + description=("Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini."), license="GPL", - keywords="ecig electronic cigarette evic joyetech", + keywords="ecig electronic cigarette evic joyetech presa wismec", url="https://github.com/Ban3/python-evic", packages=['evic'], - install_requires=requirements, + setup_requires=['pytest-runner'], + tests_require=['pytest'], + install_requires=REQUIREMENTS, + data_files=[('udev', ['udev/99-nuvoton-hid.rules'])], long_description=readme, classifiers=[ "Development Status :: 4 - Beta", @@ -50,8 +59,15 @@ 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', ], + extras_require={ + 'USB': ['hidapi>=0.7.99'], + }, entry_points={ - 'console_scripts': ['evic=evic.cli:main'], + 'console_scripts': [ + 'evic-convert=evic.cli:convert', + 'evic=evic.cli:main', + 'evic-usb=evic.cli:usb [USB]'], }, ) diff --git a/testdata/helloworld.bin b/testdata/helloworld.bin new file mode 100644 index 0000000..92d6858 Binary files /dev/null and b/testdata/helloworld.bin differ diff --git a/testdata/src/helloworld/Makefile b/testdata/src/helloworld/Makefile new file mode 100644 index 0000000..72ce5c6 --- /dev/null +++ b/testdata/src/helloworld/Makefile @@ -0,0 +1,5 @@ +TARGET = helloworld + +OBJS = main.o + +include $(EVICSDK)/make/Base.mk diff --git a/testdata/src/helloworld/main.c b/testdata/src/helloworld/main.c new file mode 100644 index 0000000..147ca9d --- /dev/null +++ b/testdata/src/helloworld/main.c @@ -0,0 +1,30 @@ +/* + * This file is part of eVic SDK. + * + * eVic SDK is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * eVic SDK is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with eVic SDK. If not, see . + * + * Copyright (C) 2015-2016 ReservedField + */ + +#include +#include +#include + +int main() { + // Blit text + Display_PutText(8, 56, "Hello,\nWorld.", FONT_DEJAVU_8PT); + + // Update display + Display_Update(); +} diff --git a/testdata/test_dataflash.bin b/testdata/test_dataflash.bin new file mode 100644 index 0000000..77a7410 Binary files /dev/null and b/testdata/test_dataflash.bin differ diff --git a/tests/test_aprom.py b/tests/test_aprom.py new file mode 100644 index 0000000..90f3f99 --- /dev/null +++ b/tests/test_aprom.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +""" +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. +Copyright © Jussi Timperi + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import pytest + +import evic + + +class TestAPROM: + + def test_aprom_convert(self): + with open("testdata/helloworld.bin", "rb") as apromfile: + aprom_data = bytearray(apromfile.read()) + aprom = evic.APROM(aprom_data) + aprom_unencrypted = evic.APROM(aprom.convert()) + + assert aprom_data == aprom_unencrypted.convert() + + def test_aprom_verify(self): + with open("testdata/helloworld.bin", "rb") as apromfile: + aprom = evic.APROM(apromfile.read()) + aprom_unencrypted = evic.APROM(aprom.convert()) + + aprom_unencrypted.verify(['E052'], 106) + + with pytest.raises(evic.APROMError): + aprom_unencrypted.verify(['W007'], 106) + aprom_unencrypted.verify(['E052'], 999) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..781dcdd --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. +Copyright © Jussi Timperi + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from click.testing import CliRunner + +from evic import cli + + +class TestCli: + + def test_cli_convert(self): + with open('testdata/helloworld.bin', 'rb') as apromfile: + aprom_data = apromfile.read() + + runner = CliRunner() + with runner.isolated_filesystem(): + with open('test_aprom.bin', 'wb') as apromfile: + apromfile.write(aprom_data) + + result = runner.invoke(cli.convert, ['test_aprom.bin', + '-o', + 'test_aprom_unencrypted.bin']) + assert result.exit_code == 0 + + result = runner.invoke(cli.convert, ['test_aprom_unencrypted.bin', + '-o', + 'test_aprom2.bin']) + assert result.exit_code == 0 + + with open('test_aprom2.bin', 'rb') as apromfile: + assert aprom_data == apromfile.read() diff --git a/tests/test_dataflash.py b/tests/test_dataflash.py new file mode 100644 index 0000000..8993a16 --- /dev/null +++ b/tests/test_dataflash.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +""" +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. +Copyright © Jussi Timperi + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import struct +import pytest + +import evic + + +class TestDataFlash: + + def test_dataflash_from_file(self): + with open("testdata/test_dataflash.bin", "rb") as dataflashfile: + dataflash = evic.DataFlash(bytearray(dataflashfile.read()), 0) + assert dataflash.hw_version == 103 + assert dataflash.bootflag == 0 + assert dataflash.product_id == "E052" + assert dataflash.fw_version == 300 + + def test_set_dataflash_attributes(self): + with open("testdata/test_dataflash.bin", "rb") as dataflashfile: + dataflash = evic.DataFlash(bytearray(dataflashfile.read()), 0) + dataflash.bootflag = 1 + dataflash.hw_version = 106 + + assert dataflash.bootflag == 1 + assert dataflash.array[9] == 1 + assert dataflash.hw_version == 106 + assert struct.unpack("=I", bytes(dataflash.array[4:8]))[0] == 106 + + def test_dataflash_verify(self): + with open("testdata/test_dataflash.bin", "rb") as dataflashfile: + dataflash = evic.DataFlash(bytearray(dataflashfile.read()), 0) + checksum = sum(dataflash.array) + + dataflash.verify(checksum) + + with pytest.raises(evic.DataFlashError): + dataflash.verify(0) diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 0000000..cac0279 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +""" +Evic is a USB programmer for devices based on the Joyetech Evic VTC Mini. +Copyright © Jussi Timperi + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import evic + + +class TestDevice: + + def test_hidtransfer_hidcmd(self): + read_df_cmd = bytearray(b'5\x0e\x00\x00\x00\x00\x00\x08\x00\x00HIDCc\x01\x00\x00') + write_df_cmd = bytearray(b'S\x0e\x00\x00\x00\x00\x00\x08\x00\x00HIDC\x81\x01\x00\x00') + reset_cmd = bytearray(b'\xb4\x0e\x00\x00\x00\x00\x00\x00\x00\x00HIDC\xda\x01\x00\x00') + write_aprom_cmd = bytearray(b'\xc3\x0e\x00\x00\x00\x00\x00\x00\x00\x00HIDC\xe9\x01\x00\x00') + + assert evic.HIDTransfer.hidcmd(0xC3, 0, 0) == write_aprom_cmd + assert evic.HIDTransfer.hidcmd(0xB4, 0, 0) == reset_cmd + assert evic.HIDTransfer.hidcmd(0x35, 0, 2048) == read_df_cmd + assert evic.HIDTransfer.hidcmd(0x53, 0, 2048) == write_df_cmd diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..f66b93b --- /dev/null +++ b/tox.ini @@ -0,0 +1,9 @@ +[tox] +envlist = py26, py27, py33, py34, py35 + +[testenv] +deps = + setuptools>=19.6 +setenv = + PYTHONPATH = {toxinidir}:{toxinidir}/evic +commands = python setup.py test diff --git a/udev/99-nuvoton-hid.rules b/udev/99-nuvoton-hid.rules new file mode 100644 index 0000000..0ee1b6f --- /dev/null +++ b/udev/99-nuvoton-hid.rules @@ -0,0 +1,5 @@ +# HIDAPI/libusb +SUBSYSTEM=="usb", ATTRS{idVendor}=="0416", ATTRS{idProduct}=="5020", MODE="0666" + +# HIDAPI/hidraw +KERNEL=="hidraw*", ATTRS{busnum}=="1", ATTRS{idVendor}=="0416", ATTRS{idProduct}=="5020", MODE="0666"