diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..883953c --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,43 @@ +name: CI + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-20.04 + container: + image: alpine:3.15 + strategy: + matrix: + include: + - args: "-m" + model: "miniware" + - args: "-p" + model: "pinecil" + fail-fast: true + + steps: + - name: Install dependencies (apk) + run: apk add --no-cache git python3 py3-pip zlib py3-pillow + + - uses: actions/checkout@v2 + with: + submodules: true + + - name: prep + run: mkdir -p /tmp/${{ matrix.model }} + + - name: build all files for the device + run: cd Bootup\ Logos && ./run.sh /tmp/${{ matrix.model }}/ ${{matrix.args}} + + - name: build logo erase file + run: cd Bootup\ Logos && python3 img2logo.py -E erase_stored_image /tmp/${{ matrix.model }}/ ${{matrix.args}} + + - name: Archive artifacts + uses: actions/upload-artifact@v2 + with: + name: ${{ matrix.model }} + path: | + /tmp/${{ matrix.model }}/*.hex + /tmp/${{ matrix.model }}/*.dfu + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..699b26b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*/__pycache__/* +*.hex +*.dfu diff --git a/Bootup Logos/Images/IronOS.gif b/Bootup Logos/Images/IronOS.gif new file mode 100644 index 0000000..fc9daa0 Binary files /dev/null and b/Bootup Logos/Images/IronOS.gif differ diff --git a/Bootup Logos/Images/IronOS.png b/Bootup Logos/Images/IronOS.png new file mode 100644 index 0000000..a728fad Binary files /dev/null and b/Bootup Logos/Images/IronOS.png differ diff --git a/Bootup Logos/Images/IronOS_L.gif b/Bootup Logos/Images/IronOS_L.gif new file mode 100644 index 0000000..b40f3d8 Binary files /dev/null and b/Bootup Logos/Images/IronOS_L.gif differ diff --git a/Bootup Logos/Images/IronOS_L.png b/Bootup Logos/Images/IronOS_L.png new file mode 100644 index 0000000..befd3aa Binary files /dev/null and b/Bootup Logos/Images/IronOS_L.png differ diff --git a/Bootup Logos/Images/Pinecil.png b/Bootup Logos/Images/Pinecil.png new file mode 100644 index 0000000..29d7ce1 Binary files /dev/null and b/Bootup Logos/Images/Pinecil.png differ diff --git a/Bootup Logos/Images/Pinecil_L.png b/Bootup Logos/Images/Pinecil_L.png new file mode 100644 index 0000000..00e50f7 Binary files /dev/null and b/Bootup Logos/Images/Pinecil_L.png differ diff --git a/Bootup Logos/Images/TS100.png b/Bootup Logos/Images/TS100.png new file mode 100644 index 0000000..4af7903 Binary files /dev/null and b/Bootup Logos/Images/TS100.png differ diff --git a/Bootup Logos/Images/TS100_L.png b/Bootup Logos/Images/TS100_L.png new file mode 100644 index 0000000..82c5ba2 Binary files /dev/null and b/Bootup Logos/Images/TS100_L.png differ diff --git a/Bootup Logos/Images/TS80.png b/Bootup Logos/Images/TS80.png new file mode 100644 index 0000000..a22185a Binary files /dev/null and b/Bootup Logos/Images/TS80.png differ diff --git a/Bootup Logos/Images/TS80P.png b/Bootup Logos/Images/TS80P.png new file mode 100644 index 0000000..c07ce17 Binary files /dev/null and b/Bootup Logos/Images/TS80P.png differ diff --git a/Bootup Logos/Images/TS80P_L.png b/Bootup Logos/Images/TS80P_L.png new file mode 100644 index 0000000..beae7b9 Binary files /dev/null and b/Bootup Logos/Images/TS80P_L.png differ diff --git a/Bootup Logos/Images/TS80_L.png b/Bootup Logos/Images/TS80_L.png new file mode 100644 index 0000000..e2127a9 Binary files /dev/null and b/Bootup Logos/Images/TS80_L.png differ diff --git a/Bootup Logos/README.md b/Bootup Logos/README.md new file mode 100644 index 0000000..95dd36f --- /dev/null +++ b/Bootup Logos/README.md @@ -0,0 +1,22 @@ +## Boot up logo's are logos or animations shown on boot of IronOS + +These are programmed into the device just like the normal firmware. +They can be (re)programmed as many times as desired after flashing the normal firmware. + +### Data storage format + +The data is stored into the second last page of flash, this gives 1024 bytes of space for the entire payload of bootup logo data. + +The first byte is marked purely to indicate that the page is programmed and which revision of the boot logo logic it is +The next byte indicates the frame timing in milliseconds, or `0` to indicate only show first frame for whole bootloader duration (still image mode) +Then the OLED buffer is cleared to black, then every frame is encoded as either: + +### Full frame updates + +`[0xFF][Full framebuffer of data]` + +### Delta frame update + +`[count of updates][[index,data][index,data][index,data][index,data]]` +Where index is byte location into screen buffer, and data is the new byte to plonk down there +This just overwrites individual bytes in the output buffer diff --git a/Bootup Logos/img2logo.py b/Bootup Logos/img2logo.py new file mode 100755 index 0000000..ba30177 --- /dev/null +++ b/Bootup Logos/img2logo.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python +# coding=utf-8 +from __future__ import division +import argparse +import copy +import os, sys + +from output_hex import HexOutput +from output_dfu import DFUOutput + +try: + from PIL import Image, ImageOps +except ImportError as error: + raise ImportError("{}: {} requres Python Imaging Library (PIL). " "Install with `pip` (pip3 install pillow) or OS-specific package " "management tool.".format(error, sys.argv[0])) + +VERSION_STRING = "1.0" + +LCD_WIDTH = 96 +LCD_HEIGHT = 16 +LCD_NUM_BYTES = LCD_WIDTH * LCD_HEIGHT // 8 +LCD_PAGE_SIZE = 1024 + +DATA_PROGRAMMED_MARKER = 0xAA +FULL_FRAME_MARKER = 0xFF + + +class MiniwareSettings: + IMAGE_ADDRESS = 0x0800F800 + DFU_TARGET_NAME = b"IronOS-dfu" + DFU_PINECIL_ALT = 0 + DFU_PINECIL_VENDOR = 0x1209 + DFU_PINECIL_PRODUCT = 0xDB42 + + +class PinecilSettings: + IMAGE_ADDRESS = 0x0801F800 + DFU_TARGET_NAME = b"Pinecil" + DFU_PINECIL_ALT = 0 + DFU_PINECIL_VENDOR = 0x28E9 + DFU_PINECIL_PRODUCT = 0x0189 + + +def still_image_to_bytes(image: Image, negative: bool, dither: bool, threshold: int, preview_filename): + # convert to luminance + # do even if already black/white because PIL can't invert 1-bit so + # can't just pass thru in case --negative flag + # also resizing works better in luminance than black/white + # also no information loss converting black/white to grayscale + if image.mode != "L": + image = image.convert("L") + # Resize to lcd size using bicubic sampling + if image.size != (LCD_WIDTH, LCD_HEIGHT): + image = image.resize((LCD_WIDTH, LCD_HEIGHT), Image.BICUBIC) + + if negative: + image = ImageOps.invert(image) + threshold = 255 - threshold # have to invert threshold as well + + if dither: + image = image.convert("1") + else: + image = image.point(lambda pixel: 0 if pixel < threshold else 1, "1") + + if preview_filename: + image.save(preview_filename) + # pad to this size (also will be repeated in output Intel hex file) + data = [] + + # convert to LCD format + for ndx in range(LCD_WIDTH * LCD_HEIGHT // 8): + bottom_half_offset = 0 if ndx < LCD_WIDTH else 8 + byte = 0 + for y in range(8): + if image.getpixel((ndx % LCD_WIDTH, y + bottom_half_offset)): + byte |= 1 << y + data.append(byte) + + """ DEBUG + for row in range(LCD_HEIGHT): + for column in range(LCD_WIDTH): + if image.getpixel((column, row)): sys.stderr.write('█') + else: sys.stderr.write(' ') + sys.stderr.write('\n') + """ + return data + + +def calculate_frame_delta_encode(previous_frame: bytearray, this_frame: bytearray): + damage = [] + for i in range(0, len(this_frame)): + if this_frame[i] != previous_frame[i]: + damage.append(i) + damage.append(this_frame[i]) + return damage + + +def get_screen_blob(previous_frame: bytearray, this_frame: bytearray): + """ + Given two screens, returns the smaller representation + Either a full screen update + OR + A delta encoded form + """ + outputData = [] + delta = calculate_frame_delta_encode(previous_frame, this_frame) + if len(delta) < (len(this_frame)): + outputData.append(len(delta)) + outputData.extend(delta) + # print("delta encoded frame") + else: + outputData.append(FULL_FRAME_MARKER) + outputData.extend(this_frame) + # print("full encoded frame") + return outputData + + +def animated_image_to_bytes(imageIn: Image, negative: bool, dither: bool, threshold: int): + """ + Convert the gif into our best effort startup animation + We are delta-encoding on a byte by byte basis + + So we convert every frame into its binary representation + The compare these to figure out the encoding + + The naïve implementation would save the frame 5 times + But if we delta encode; we can make far more frames of animation for _some_ types of animations. + This means reveals are better than moves. + Data is stored in the byte blobs, so if you change one pixel, changing another pixel in that column on that row is "free" + """ + + frameData = [] + frameTiming = None + for framenum in range(0, imageIn.n_frames): + imageIn.seek(framenum) + image = imageIn + + frameb = still_image_to_bytes(image, negative, dither, threshold, None) + frameData.append(frameb) + # Store inter-frame duration + frameDuration_ms = image.info["duration"] + if frameDuration_ms > 255: + frameDuration_ms = 255 + if frameTiming is None or frameTiming == 0: + frameTiming = frameDuration_ms + print(f"Found {len(frameData)} frames, interval {frameTiming}ms") + # We have no mangled the image into our frambuffers + + # Now we can build our output data blob + # First we always start with a full first frame; future optimisation to check if we should or not + outputData = [DATA_PROGRAMMED_MARKER] + outputData.append(frameTiming) + outputData.extend(get_screen_blob([0x00] * (LCD_NUM_BYTES), frameData[0])) + + """ + Format for each frame block is: + [length][ [delta block][delta block][delta block][delta block] ] + Where [delta block] is just [index,new value] + + OR + [0xFF][Full frame data] + """ + for id in range(1, len(frameData)): + frameBlob = get_screen_blob(frameData[id - 1], frameData[id]) + if (len(outputData) + len(frameBlob)) > LCD_PAGE_SIZE: + print(f"Truncating animation after {id} frames as we are out of space") + break + print(f"Frame {id} encoded to {len(frameBlob)} bytes") + outputData.extend(frameBlob) + return outputData + + +def img2hex( + input_filename, + preview_filename=None, + threshold=128, + dither=False, + negative=False, + isPinecil=False, + make_erase_image=False, + output_filename_base="out", +): + """ + Convert 'input_filename' image file into Intel hex format with data + formatted for display on LCD and file object. + Input image is converted from color or grayscale to black-and-white, + and resized to fit LCD screen as necessary. + Optionally write resized/thresholded/black-and-white preview image + to file specified by name. + Optional `threshold' argument 8 bit value; grayscale pixels greater than + this become 1 (white) in output, less than become 0 (black). + Unless optional `dither', in which case PIL grayscale-to-black/white + dithering algorithm used. + Optional `negative' inverts black/white regardless of input image type + or other options. + """ + if make_erase_image: + data = [0xFF] * 1024 + else: + try: + image = Image.open(input_filename) + except BaseException as e: + raise IOError('error reading image file "{}": {}'.format(input_filename, e)) + + if getattr(image, "is_animated", False): + data = animated_image_to_bytes(image, negative, dither, threshold) + else: + # magic/required header + data = [DATA_PROGRAMMED_MARKER, 0x00] # Timing value of 0 + image_bytes = still_image_to_bytes(image, negative, dither, threshold, preview_filename) + data.extend(get_screen_blob([0] * LCD_NUM_BYTES, image_bytes)) + + # Pad up to the full page size + if len(data) < LCD_PAGE_SIZE: + pad = [0] * (LCD_PAGE_SIZE - len(data)) + data.extend(pad) + deviceSettings = MiniwareSettings + if isPinecil: + deviceSettings = PinecilSettings + # Generate both possible outputs + output_name = output_filename_base + os.path.basename(input_filename) + DFUOutput.writeFile( + output_name + ".dfu", + data, + deviceSettings.IMAGE_ADDRESS, + deviceSettings.DFU_TARGET_NAME, + deviceSettings.DFU_PINECIL_ALT, + deviceSettings.DFU_PINECIL_PRODUCT, + deviceSettings.DFU_PINECIL_VENDOR, + ) + HexOutput.writeFile(output_name + ".hex", data, deviceSettings.IMAGE_ADDRESS) + + +def parse_commandline(): + parser = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + description="Convert image file for display on IronOS OLED at startup", + ) + + def zero_to_255(text): + value = int(text) + if not 0 <= value <= 255: + raise argparse.ArgumentTypeError("must be integer from 0 to 255 ") + return value + + parser.add_argument("input_filename", help="input image file") + + parser.add_argument("output_filename", help="output file base name") + + parser.add_argument( + "-P", + "--preview", + help="filename of image preview", + ) + + parser.add_argument( + "-n", + "--negative", + action="store_true", + help="photo negative: exchange black and white " "in output", + ) + + parser.add_argument( + "-t", + "--threshold", + type=zero_to_255, + default=128, + help="0 to 255: gray (or color converted to gray) " "above this becomes white, below becomes black; " "ignored if using --dither", + ) + + parser.add_argument( + "-d", + "--dither", + action="store_true", + help="use dithering (speckling) to convert gray or " "color to black and white", + ) + + parser.add_argument( + "-E", + "--erase", + action="store_true", + help="generate a logo erase file instead of a logo", + ) + + parser.add_argument("-p", "--pinecil", action="store_true", help="generate files for Pinecil") + parser.add_argument("-m", "--miniware", action="store_true", help="generate files for miniware") + parser.add_argument( + "-v", + "--version", + action="version", + version="%(prog)s version " + VERSION_STRING, + help="print version info", + ) + + return parser.parse_args() + + +if __name__ == "__main__": + + args = parse_commandline() + + if args.preview and os.path.exists(args.preview) and not args.force: + sys.stderr.write('Won\'t overwrite existing file "{}" (use --force ' "option to override)\n".format(args.preview)) + sys.exit(1) + + if args.miniware == False and args.pinecil == False: + sys.stderr.write("You must provide --miniware or --pinecil to select your model") + sys.exit(1) + + img2hex( + input_filename=args.input_filename, + output_filename_base=args.output_filename, + preview_filename=args.preview, + threshold=args.threshold, + dither=args.dither, + negative=args.negative, + make_erase_image=args.erase, + isPinecil=args.pinecil, + ) diff --git a/Bootup Logos/output_dfu.py b/Bootup Logos/output_dfu.py new file mode 100644 index 0000000..5194a60 --- /dev/null +++ b/Bootup Logos/output_dfu.py @@ -0,0 +1,68 @@ +import struct, zlib + + +class DFUOutput: + + DFU_PREFIX_SIZE = 11 + DFU_SUFFIX_SIZE = 16 + + @classmethod + def compute_crc(cls, data): + return 0xFFFFFFFF & -zlib.crc32(data) - 1 + + @classmethod + def writeFile( + cls, + file_name: str, + data_in: bytearray, + data_address: int, + tagetName: str, + alt_number: int, + product_id: int, + vendor_id: int, + ): + data: bytearray = bytearray(data_in) + + data = struct.pack("<2I", data_address, len(data)) + data + data = ( + struct.pack( + "<6sBI255s2I", + b"Target", + alt_number, + 1, + tagetName, + len(data), + 1, + ) + + data + ) + data = ( + struct.pack( + "<5sBIB", + b"DfuSe", + 1, + cls.DFU_PREFIX_SIZE + len(data) + cls.DFU_SUFFIX_SIZE, + 1, + ) + + data + ) + data += struct.pack( + "<4H3sB", + 0, + product_id, + vendor_id, + 0x011A, + b"UFD", + cls.DFU_SUFFIX_SIZE, + ) + crc = cls.compute_crc(data) + data += struct.pack("> 8) & 0xFF, word & 0xFF + + @classmethod + def compute_crc(cls, data): + return 0xFFFFFFFF & -zlib.crc32(data) - 1 + + @classmethod + def intel_hex_line(cls, record_type, offset, data): + """generate a line of data in Intel hex format""" + # length, address offset, record type + record_length = len(data) + yield ":{:02X}{:04X}{:02X}".format(record_length, offset, record_type) + + # data + for byte in data: + yield "{:02X}".format(byte) + + # compute and write checksum (now using unix style line endings for DFU3.45 compatibility + yield "{:02X}\n".format( + ( + ( + ( + sum( + data, # sum data ... + record_length # ... and other ... + + sum(cls.split16(offset)) # ... fields ... + + record_type, + ) # ... on line + & 0xFF + ) # low 8 bits + ^ 0xFF + ) # two's ... + + 1 + ) # ... complement + & 0xFF + ) # low 8 bits + + @classmethod + def writeFile(cls, file_name: str, data: bytearray, data_address: int): + """write block of data in Intel hex format""" + with open(file_name, "w", newline="\r\n") as output: + + def write(generator): + output.write("".join(generator)) + + if len(data) % cls.INTELHEX_BYTES_PER_LINE != 0: + raise ValueError( + "Program error: Size of LCD data is not evenly divisible by {}".format( + cls.INTELHEX_BYTES_PER_LINE + ) + ) + + address_lo = data_address & 0xFFFF + address_hi = (data_address >> 16) & 0xFFFF + + write( + cls.intel_hex_line( + cls.INTELHEX_EXTENDED_LINEAR_ADDRESS_RECORD, + 0, + cls.split16(address_hi), + ) + ) + + size_written = 0 + while size_written < cls.INTELHEX_MINIMUM_SIZE: + offset = address_lo + for line_start in range(0, len(data), cls.INTELHEX_BYTES_PER_LINE): + write( + cls.intel_hex_line( + cls.INTELHEX_DATA_RECORD, + offset, + data[line_start : line_start + cls.INTELHEX_BYTES_PER_LINE], + ) + ) + size_written += cls.INTELHEX_BYTES_PER_LINE + if size_written >= cls.INTELHEX_MINIMUM_SIZE: + break + offset += cls.INTELHEX_BYTES_PER_LINE + + write(cls.intel_hex_line(cls.INTELHEX_END_OF_FILE_RECORD, 0, ())) + + +if __name__ == "__main__": + import sys + + print("DO NOT CALL THIS FILE DIRECTLY") + sys.exit(1) diff --git a/Bootup Logos/run.sh b/Bootup Logos/run.sh new file mode 100755 index 0000000..2959aa3 --- /dev/null +++ b/Bootup Logos/run.sh @@ -0,0 +1,4 @@ +#! /bin/sh +echo $1 +echo $2 +find Images/ -type f -exec python3 img2logo.py {} "$1" "$2" \; diff --git a/README.md b/README.md index 52d7885..e30bc91 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ # IronOS-Meta -Storing meta information about devices that dont need to be in the main repo + +Storing meta information for IronOS. +This are things that are not part of the core "OS". +This includes photographs of hardware, datasheets, schematics and of course **bootup logos**. + +This repository uses github actions to automagically build the logos for each device. +Periodically a "release" will be tagged and pre-compiled logo's will be put there as well to make it easy.