1
0
forked from me/IronOS-Meta

Merge pull request #1 from Ralim/logos

New logo format
Animation support
Github actions
This commit is contained in:
Ben V. Brown
2022-02-15 19:55:57 +11:00
committed by GitHub
20 changed files with 569 additions and 1 deletions

43
.github/workflows/push.yml vendored Normal file
View File

@@ -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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*/__pycache__/*
*.hex
*.dfu

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

22
Bootup Logos/README.md Normal file
View File

@@ -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

318
Bootup Logos/img2logo.py Executable file
View File

@@ -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,
)

View File

@@ -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("<I", crc)
with open(file_name, "wb") as output:
output.write(data)
if __name__ == "__main__":
import sys
print("DO NOT CALL THIS FILE DIRECTLY")
sys.exit(1)

104
Bootup Logos/output_hex.py Normal file
View File

@@ -0,0 +1,104 @@
import zlib
class HexOutput:
"""
Supports writing a blob of data out in the Intel Hex format
"""
INTELHEX_DATA_RECORD = 0x00
INTELHEX_END_OF_FILE_RECORD = 0x01
INTELHEX_EXTENDED_LINEAR_ADDRESS_RECORD = 0x04
INTELHEX_BYTES_PER_LINE = 16
INTELHEX_MINIMUM_SIZE = 4096
@classmethod
def split16(cls, word):
"""return high and low byte of 16-bit word value as tuple"""
return (word >> 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)

4
Bootup Logos/run.sh Executable file
View File

@@ -0,0 +1,4 @@
#! /bin/sh
echo $1
echo $2
find Images/ -type f -exec python3 img2logo.py {} "$1" "$2" \;

View File

@@ -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.