Files
forge-invar/i2c.py
T
2026-10-30 00:04:35 +01:00

191 lines
5.7 KiB
Python
Executable File

#!/usr/bin/env python3
# see https://electricalflux.com/mcu-raspberry/i2c-oled-display-raspberry-pi-5-wiring-code
import collections
import pathlib
import sys
import time
from luma.core.error import DeviceNotFoundError
from luma.core.interface.serial import i2c
from luma.oled.device import device, sh1106
from PIL import Image, ImageDraw, ImageFont, ImageMath
TARGET_FILE = pathlib.Path(__file__).parent / "forge_default_save.txt"
TARGET_FILE_BACKUP = pathlib.Path(__file__).parent / "forge_default_save.txt.bck"
ESTIMATED_BOOT_TIME_SECONDS = 18.0
data: dict[str, float] = collections.defaultdict(lambda: 0)
font1 = ImageFont.truetype("./TinyAndChunkyRegular.ttf", 5)
font2 = ImageFont.truetype("./Px437_IBM_BIOS.ttf", 8)
loading_img = Image.open("i2c_loading.png").convert("1")
background_img = Image.open("i2c_background.png").convert("1")
blank_im = Image.new("1", (128, 64), "black")
selected_bounds = [
(10, 14, 28, 32),
(10, 38, 28, 56),
(39, 14, 57, 32),
(39, 38, 57, 56),
(0, 0, 0, 0),
(97, 26, 115, 44),
]
def try_restore_backup() -> bool:
if TARGET_FILE_BACKUP.exists() and readfile(TARGET_FILE_BACKUP):
TARGET_FILE.write_text(TARGET_FILE_BACKUP.read_text())
return True
return False
def readfile(file: pathlib.Path = TARGET_FILE) -> bool:
try:
content = file.read_text()
except: # noqa: E722
return False
for line in content.splitlines():
key, value = line.split("=", maxsplit=2)
data[key] = float(value)
if "time" in data:
TARGET_FILE_BACKUP.write_text(content)
return "time" in data
def open_device() -> device:
# Pin definitions: I2C Bus 1 (SDA=GPIO2, SCL=GPIO3)
# Default SSD1306 I2C address is 0x3C
serial_interface = i2c(port=1, address=0x3C)
try:
# Initialize the device. If using SH1106, change ssd1306 to sh1106
return sh1106(serial_interface, width=128, height=64, rotate=2)
except DeviceNotFoundError:
print("Fatal: OLED not found at 0x3C. Check wiring and run i2cdetect -y 1")
sys.exit(1)
except OSError as e:
if e.errno == 121:
print(
"Fatal: Remote I/O error. I2C bus is locked or physically disconnected."
)
else:
print(f"Fatal OS Error: {e}")
sys.exit(1)
def print_loading(restored: bool, t0: float) -> Image.Image:
im = loading_img.copy()
draw = ImageDraw.Draw(im)
now = time.time()
percent = min(1.0, (now - t0) / ESTIMATED_BOOT_TIME_SECONDS)
draw.rectangle((24, 35, 24 + 80 * percent, 40), fill="white")
if not restored:
draw.circle((4, 4), 1, fill="white")
return im
def print_monitor() -> Image.Image:
im = background_img.copy()
draw = ImageDraw.Draw(im)
im_overlay = blank_im.copy()
draw2 = ImageDraw.Draw(im_overlay)
# tempo
draw.text((42, -2), f"{int(data['tempo'])}", fill="white", font=font1)
draw.rectangle(
(6, 7, 6 + 53 * (data["beats"] - int(data["beats"])), 10), fill="white", width=0
)
# time
draw.text((94, -2), f"{int(data['time'])}", fill="white", font=font1)
draw.rectangle(
(67, 7, 67 + 53 * (data["time"] - int(data["time"])), 10), fill="white", width=0
)
# page
draw.circle((55 + 8 * int(data["page"]), 61), 2, fill="white")
# selected
selected = int(data["selected"])
draw.rectangle(selected_bounds[selected], outline="white", fill=None)
# TODO active fx/src
# fx/mix values
# todo random and time based
fxa_value = 1 - data['value_18_x'] / 127
draw.rectangle((41, 16 + round(14 * fxa_value), 55, 30), fill="white")
fxb_value = 1 - data['value_25_x'] / 127
draw.rectangle((41, 40 + round(14 * fxb_value), 55, 54), fill="white")
mfx_value = 1 - data['value_32_x'] / 127
draw.rectangle((99, 28 + round(14 * mfx_value), 113, 42), fill="white")
mix_value = 1 - data['value_34_x'] / 127
draw.rectangle((70, 28 + round(14 * mix_value), 84, 42), fill="white")
# active shaders
draw2.text((16, 20), hex(int(data["state_0"]))[2].upper(), fill="white", font=font2)
draw2.text((16, 44), hex(int(data["state_1"]))[2].upper(), fill="white", font=font2)
draw2.text((45, 20), hex(int(data["state_2"]))[2].upper(), fill="white", font=font2)
draw2.text((45, 44), hex(int(data["state_3"]))[2].upper(), fill="white", font=font2)
draw2.text(
(103, 32), hex(int(data["state_6"]))[2].upper(), fill="white", font=font2
)
mix_type = data['value_33_x'] == 0
draw2.text((74, 32), "M" if mix_type else "K", fill="white", font=font2) # TODO
return ImageMath.lambda_eval(lambda args: args["convert"](args["a"] ^ args["b"], "1"), a=im, b=im_overlay)
def main():
restored = try_restore_backup() or readfile()
device = open_device()
initial = True
started = False
t0 = time.time()
try:
device.display(print_loading(restored, t0))
while True:
if not readfile():
time.sleep(0.01)
continue
if initial or started:
if not started:
started = "time" in data and data["time"] == 0
if started:
initial = False
device.display(print_loading(restored, t0))
else:
device.display(print_monitor())
if started and data["time"] > 0:
initial = False
started = False
except KeyboardInterrupt:
print("Exiting gracefully...")
except OSError as e:
if e.errno == 121:
print("Runtime Error: I2C bus dropped. Check physical connections.")
device.cleanup()
sys.exit(1)
finally:
device.cleanup()
if __name__ == "__main__":
main()