Skip to content

Instantly share code, notes, and snippets.

@Gadgetoid
Last active September 4, 2025 11:51
Show Gist options
  • Select an option

  • Save Gadgetoid/8ede61a26899ccf894146c6ac8d8fb61 to your computer and use it in GitHub Desktop.

Select an option

Save Gadgetoid/8ede61a26899ccf894146c6ac8d8fb61 to your computer and use it in GitHub Desktop.
Python scripts to set animations and clock on a Chilkey ND75
from PIL import Image
import numpy
import sys
import math
import pathlib
import struct
try:
import usb
except ImportError:
print("Did you forget to `unzip usb.zip`?")
sys.exit(0)
WIDTH = 135
HEIGHT = 240
BYTES_PER_PIXEL = 2 # RGB565
BYTES_PER_FRAME = WIDTH * HEIGHT * BYTES_PER_PIXEL
BYTES_PER_PACKET = 4096 # Probably matches the flash erase block size
HEADER_SIZE = 256
DEBUG = False
VID = 0x36b5
PID = 0x2ba7
DATA_EP = 0x03
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} image_file")
sys.exit(1)
image_file = pathlib.Path(sys.argv[1])
if not image_file.is_file():
print(f"Image file \"{image_file}\" does not exist?")
sys.exit(1)
img = Image.open(image_file)
def img_to_rgb565(img, w, h):
data = img.convert("RGB").resize((w, h)).tobytes()
data = numpy.array(list(data), dtype=numpy.uint16)
rgb = (data[0::3] & 0b11111000) << 8
rgb |= (data[1::3] & 0b11111100) << 3
rgb |= (data[2::3] & 0b11111000) >> 3
return rgb.tobytes()
def extract_frames(img, w, h):
while True:
try:
# Duration is in 2ms intervals, with a maximum of 255 (510ms)
time = (img.info["duration"] // 2) & 0xff
yield img_to_rgb565(img, w, h), time
img.seek(img.tell() + 1)
except EOFError:
break
if getattr(img, "is_animated", False):
frames = list(extract_frames(img, WIDTH, HEIGHT))
else:
frames = [img_to_rgb565(img)]
num_frames = len(frames)
if num_frames > 59:
print(f"Animation has too many frames! {num_frames} > 59")
sys.exit(1)
dev = usb.core.find(idVendor=VID, idProduct=PID)
if dev is None:
print("ND75 not found!")
sys.exit(1)
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
def hexstr(data):
return ",".join(f"{c:02x}" for c in data)
def send_feature_report(data):
payload = bytearray(64)
payload[0:len(data)] = data
if DEBUG:
print(f"Command: {hexstr(data)}")
dev.ctrl_transfer(0x21, 0x09, 0x300, 0, payload)
response = dev.ctrl_transfer(0xA1, 0x01, 0x300, 0, 64)
if DEBUG:
print(f"Response: {hexstr(response)}")
return response
# The first 256 bytes of the image are the frame count and duration values
# Then we have the WIDTH * HEIGHT * BYTES_PER_PIXEL multiplied by the number of frames
size_needed = WIDTH * HEIGHT * num_frames * BYTES_PER_PIXEL + HEADER_SIZE
# We need to send full 4096 byte packets, these are probably the flash erase block size
# The official software pads them with 0xFF and we should maybe do that
size_rounded = math.ceil(size_needed / BYTES_PER_PACKET) * BYTES_PER_PACKET
# The number of packets to send
num_packets = size_rounded // BYTES_PER_PACKET
print(f"Sending {image_file.name}: {num_frames} frame(s) as {num_packets} packets...")
# Standard command start (same as set clock)
send_feature_report(bytearray((4, 24)))
# Start the transfer and send the number of packets expected
send_feature_report(struct.pack("<8bH", 4, 114, 2, 0, 0, 0, 0, 0, num_packets))
# Allocate a buffer big enough for our frames and header
image = bytearray(size_rounded)
# Fill out the padding byte, presumably 0xff is the flash erase value so this saves us some flash write wear?
for i in range(0, len(image)):
image[i] = 0xff
# Add the number of frames to the header
image[0] = num_frames
# Copy the frame durations and data into our buffer
index = 0
for frame, time in frames:
image[1 + index] = time # frame duration (in 2ms increments I think)
offset = HEADER_SIZE + (index * BYTES_PER_FRAME)
image[offset:offset + BYTES_PER_FRAME] = frame
index += 1
# Chunk the image and write it to the display
total_packets = len(image) // BYTES_PER_PACKET
for n in range(total_packets):
print(f"Packet: {n + 1} / {total_packets} ({(n + 1) / total_packets * 100:0.2f}%)", end="\r")
offset = n * BYTES_PER_PACKET
# For subsequent frames of data
if n > 0:
send_feature_report(bytearray((4, 2)))
dev.write(DATA_EP, image[offset:offset + BYTES_PER_PACKET], 30 * 1000)
response = dev.read(0x84, 64, timeout=30 * 1000)
if DEBUG:
print(f"Response: {hexstr(response)}")
import sys
from datetime import datetime
try:
import usb
except ImportError:
print("Did you forget to `unzip usb.zip`?")
sys.exit(0)
# From a venv try: sudo --preserve-env=PATH python nd75_clock.py
VID = 0x36b5
PID = 0x2ba7
dev = usb.core.find(idVendor=VID, idProduct=PID)
if dev is None:
print("ND75 not found!")
sys.exit(1)
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
def send_feature_report(data):
def hexstr(data):
return ",".join(f"{c:02x}" for c in data)
payload = bytearray(64)
payload[0:len(data)] = data
print(f"Command: {hexstr(data)}")
dev.ctrl_transfer(0x21, 0x09, 0x300, 0, payload)
response = dev.ctrl_transfer(0xA1, 0x01, 0x300, 0, 64)
print(f"Response: {hexstr(response)}")
return response
if len(sys.argv) > 1:
dt = datetime.strptime(sys.argv[1], "%y-%m-%dT%H:%M:%S")
else:
dt = datetime.now()
print(f"\nSetting date/time to {dt} - customise with: `{sys.argv[0]} YY-MM-DDTHH:MM:SS`")
# ???
send_feature_report(bytearray((4, 24)))
# ???
send_feature_report(bytearray((4, 40, 0, 0, 0, 0, 0, 1)))
# Set the time
payload = bytearray(64)
payload[0] = 0
payload[1] = 1
payload[2] = 90
payload[3] = dt.year - 2000 # Year
payload[4] = dt.month # Month
payload[5] = dt.day # Day
payload[6] = dt.hour # Hours
payload[7] = dt.minute # Minutes
payload[8] = dt.second # Seconds
payload[9] = 0 # Unused
payload[10] = dt.isoweekday() # Day of Week
# ... unused
payload[62] = 170
payload[63] = 85
send_feature_report(payload)
# ???
send_feature_report(bytearray((4, 2)))
@Gadgetoid
Copy link
Author

git clone https://gist.github.com/8ede61a26899ccf894146c6ac8d8fb61.git
cd 8ede61a26899ccf894146c6ac8d8fb61
unzip usb.zip
python nd75_clock.py

You'll need some knowledge of Python if you want to upload images, since it uses the PIL/Pillow and Numpy libraries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment