diff --git a/.gitignore b/.gitignore index fed370c..cea6c0e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ error.glsl forge_default_save.txt build tmp* +debug.png +forge_default_save.txt.bck diff --git a/2026-10-28_21-38.png b/2026-10-28_21-38.png deleted file mode 100644 index 87256a4..0000000 Binary files a/2026-10-28_21-38.png and /dev/null differ diff --git a/Makefile b/Makefile index 881c2d5..6bd0d36 100644 --- a/Makefile +++ b/Makefile @@ -53,7 +53,7 @@ setup-services: .PHONY: setup-bashrc setup-bashrc: - $(PROJECT_DIR)/line_in_file.sh "make -C $(PROJECT_DIR) --no-print-directory start" "$(HOME)/.bashrc" + $(PROJECT_DIR)/scripts/line_in_file.sh "make -C $(PROJECT_DIR) --no-print-directory start" "$(HOME)/.bashrc" .PHONY: install install: remove-passwd apt-install pip-install setup-weston remove-motd disable-cloud-init setup-services setup-bashrc @@ -71,7 +71,7 @@ run: $(BUILD_DIR)/$(TARGET) .PHONY: start-bg start-bg: - @python3 $(PROJECT_DIR)/i2c.py >> $(PROJECT_DIR)/python.log& + @python3 $(PROJECT_DIR)/__main__.py &> $(PROJECT_DIR)/python.log & .PHONY: start-fg start-fg: diff --git a/__main__.py b/__main__.py new file mode 100755 index 0000000..7a95cc1 --- /dev/null +++ b/__main__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from src.forge_invar import main + +if __name__ == "__main__": + main() diff --git a/host_generate.py b/host_generate.py deleted file mode 100644 index 007b0b0..0000000 --- a/host_generate.py +++ /dev/null @@ -1,106 +0,0 @@ -from PIL import Image, ImageDraw, ImageFont, ImageMath - -# TODO clean - -font = ImageFont.truetype("./TinyAndChunkyRegular.ttf", 5) -font2 = ImageFont.truetype("./Px437_IBM_BIOS.ttf", 8) - -im0 = Image.new("1", (128, 64), "black") - -draw = ImageDraw.Draw(im0) - -draw.text((26, 24), "F.O.R.G.E.", fill="white", font=font2) -draw.rectangle((24, 35, 104, 40), outline="white", fill="black") - -im0.save("i2c_loading.png") - -im = Image.new("1", (128, 64), "black") - -draw = ImageDraw.Draw(im) - - -# tempo -draw.text((7, -2), "TEMPO:", fill="white", font=font) -draw.rectangle((6, 6, 60, 11), outline="white", fill="black") - -# time -draw.text((68, -2), "TIME:", fill="white", font=font) -draw.rectangle((67, 6, 121, 11), outline="white", fill="black") - -# pipes -draw.rectangle((26, 22, 78, 24), fill="white") -draw.rectangle((26, 46, 78, 48), fill="white") -draw.rectangle((78, 34, 128, 36), fill="white") -draw.rectangle((76, 23, 78, 46), fill="white") - -# squares -draw.rectangle((12, 16, 26, 30), outline="white", fill="black") -draw.rectangle((41, 16, 55, 30), outline="white", fill="black") - -draw.rectangle((12, 40, 26, 54), outline="white", fill="black") -draw.rectangle((41, 40, 55, 54), outline="white", fill="black") - -draw.rectangle((70, 28, 84, 42), outline="white", fill="black") -draw.rectangle((99, 28, 113, 42), outline="white", fill="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), -] - -# page -draw.circle((55, 61), 2, outline="white", fill="black") -draw.circle((63, 61), 2, outline="white", fill="black") -draw.circle((71, 61), 2, outline="white", fill="black") - -im.save("i2c_background.png") - -# --- - -# tempo -draw.text((42, -2), "240", fill="white", font=font) -draw.rectangle((6, 7, 6 + 53 * 1, 10), fill="white", width=0) - -# time -draw.text((94, -2), "1234", fill="white", font=font) -draw.rectangle((67, 7, 67 + 53 * 1, 10), fill="white", width=0) - -draw.circle((55, 61), 2, fill="white") - -# selected - -for i in range(len(selected_bounds)): - if selected_bounds[i][0] > 0: - draw.rectangle(selected_bounds[i], outline="white", fill=None) - -im_overlay = Image.new("1", (128, 64), "black") -draw2 = ImageDraw.Draw(im_overlay) - - -# shaders -draw2.text((16, 20), "0", fill="white", font=font2) -draw2.text((45, 20), "2", fill="white", font=font2) -draw2.text((16, 44), "1", fill="white", font=font2) -draw2.text((45, 44), "3", fill="white", font=font2) -draw2.text((74, 32), "K", fill="white", font=font2) -draw2.text((103, 32), "6", fill="white", font=font2) - -im = ImageMath.lambda_eval(lambda args: args["convert"](args["a"] ^ args["b"], "1"), a=im, b=im_overlay) - -# --- - -im = im.resize((1280, 640)).convert("RGB") - -draw2 = ImageDraw.Draw(im) - -for x in range(1, 128): - draw2.line((x * 10, 0, x * 10, 640), fill=(50, 50, 50)) -for y in range(1, 64): - draw2.line((0, y * 10, 1280, y * 10), fill=(50, 50, 50)) - - -im.resize((1280, 640)).save("tmp.png") diff --git a/i2c.py b/i2c.py deleted file mode 100755 index bbb612d..0000000 --- a/i2c.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/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() diff --git a/pyproject.toml b/pyproject.toml index 70e8895..4625127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,23 @@ [project] name = "forge-invar" -version = "0.0.0" +version = "0.1.0" +description = "FORGE invar background process for Raspberry Pi" +readme = "README.md" +authors = [ + { name = "klemek", email = "klemek.dev@proton.me" } +] requires-python = ">=3.14" dependencies = [ "luma-oled>=3.16.0", ] +[project.scripts] +forge-invar = "forge_invar:main" + +[build-system] +requires = ["uv_build>=0.13.0,<0.14.0"] +build-backend = "uv_build" + [dependency-groups] dev = [ "ruff>=0.17.3", diff --git a/Px437_IBM_BIOS.ttf b/resources/Px437_IBM_BIOS.ttf similarity index 100% rename from Px437_IBM_BIOS.ttf rename to resources/Px437_IBM_BIOS.ttf diff --git a/TinyAndChunkyRegular.ttf b/resources/TinyAndChunkyRegular.ttf similarity index 100% rename from TinyAndChunkyRegular.ttf rename to resources/TinyAndChunkyRegular.ttf diff --git a/i2c_background.png b/resources/background.png similarity index 100% rename from i2c_background.png rename to resources/background.png diff --git a/i2c_loading.png b/resources/loading.png similarity index 100% rename from i2c_loading.png rename to resources/loading.png diff --git a/resources/mock_data.txt b/resources/mock_data.txt new file mode 100644 index 0000000..69e0dd7 --- /dev/null +++ b/resources/mock_data.txt @@ -0,0 +1,130 @@ +time=226.380635 +beats=175.725006 +demo=0 +auto_random=0 +auto_random_cycle=4 +tempo=60 +page=0 +selected=0 +seed_0=898 +state_0=0 +seed_1=472 +state_1=9 +seed_2=243 +state_2=3 +seed_3=547 +state_3=14 +seed_4=524 +state_4=13 +seed_5=251 +state_5=10 +seed_6=808 +state_6=4 +active_0=0 +active_1=0 +active_2=0 +value_0_x=127 +value_0_y=0 +value_0_z=0 +value_1_x=111 +value_1_y=100 +value_1_z=71 +value_2_x=0 +value_2_y=127 +value_2_z=127 +value_3_x=56 +value_3_y=19 +value_3_z=71 +value_4_x=0 +value_4_y=0 +value_4_z=127 +value_5_x=38 +value_5_y=116 +value_5_z=82 +value_6_x=127 +value_6_y=127 +value_6_z=127 +value_7_x=32 +value_7_y=114 +value_7_z=113 +value_8_x=127 +value_8_y=0 +value_8_z=127 +value_9_x=83 +value_9_y=93 +value_9_z=62 +value_10_x=127 +value_10_y=127 +value_10_z=0 +value_11_x=92 +value_11_y=121 +value_11_z=1 +value_12_x=0 +value_12_y=127 +value_12_z=127 +value_13_x=20 +value_13_y=105 +value_13_z=64 +value_14_x=127 +value_14_y=0 +value_14_z=127 +value_15_x=78 +value_15_y=115 +value_15_z=28 +value_16_x=127 +value_16_y=127 +value_16_z=127 +value_17_x=89 +value_17_y=114 +value_17_z=115 +value_18_x=22 +value_18_y=112 +value_18_z=0 +value_19_x=0 +value_19_y=0 +value_19_z=127 +value_20_x=33 +value_20_y=102 +value_20_z=121 +value_21_x=0 +value_21_y=0 +value_21_z=127 +value_22_x=34 +value_22_y=117 +value_22_z=92 +value_23_x=127 +value_23_y=127 +value_23_z=127 +value_24_x=24 +value_24_y=1 +value_24_z=103 +value_25_x=62 +value_25_y=43 +value_25_z=127 +value_26_x=127 +value_26_y=127 +value_26_z=127 +value_27_x=62 +value_27_y=28 +value_27_z=88 +value_28_x=127 +value_28_y=0 +value_28_z=127 +value_29_x=76 +value_29_y=64 +value_29_z=8 +value_30_x=127 +value_30_y=0 +value_30_z=0 +value_31_x=74 +value_31_y=76 +value_31_z=10 +value_32_x=11 +value_32_y=67 +value_32_z=0 +value_33_x=127 +value_33_y=0 +value_33_z=0 +value_34_x=103 +value_34_y=57 +value_34_z=0 diff --git a/append_to_file.sh b/scripts/append_to_file.sh similarity index 100% rename from append_to_file.sh rename to scripts/append_to_file.sh diff --git a/line_in_file.sh b/scripts/line_in_file.sh similarity index 100% rename from line_in_file.sh rename to scripts/line_in_file.sh diff --git a/src/forge_invar/__init__.py b/src/forge_invar/__init__.py new file mode 100644 index 0000000..3db892f --- /dev/null +++ b/src/forge_invar/__init__.py @@ -0,0 +1,40 @@ +import argparse +import time + +from .display import ( + debug_image, + generate_background_image, + generate_loading_image, + get_display, +) +from .forge_data import ForgeData +from .i2c import I2C + + +def host_generate() -> None: + data = ForgeData().mock() + generate_loading_image() + generate_background_image() + image = get_display(data, 0) + debug_image(image) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--generate", action="store_true") + args = parser.parse_args() + + if args.generate: + host_generate() + return + + t0 = time.time() + with ForgeData() as data, I2C() as device: + device.update_display(get_display(data, t0)) + + while True: + if not data.update(): + time.sleep(0.01) + continue + + device.update_display(get_display(data, t0)) diff --git a/src/forge_invar/constants.py b/src/forge_invar/constants.py new file mode 100644 index 0000000..c552bec --- /dev/null +++ b/src/forge_invar/constants.py @@ -0,0 +1,37 @@ +import pathlib + +PROJECT_PATH = pathlib.Path(__file__).parent.parent.parent +RESOURCES_PATH = PROJECT_PATH / "resources" + +FORGE_SAVE_PATH = PROJECT_PATH / "forge_default_save.txt" +FORGE_SAVE_BACKUP_PATH = PROJECT_PATH / "forge_default_save.txt.bck" +MOCK_SAVE_PATH = RESOURCES_PATH / "mock_data.txt" + + +FONT_1_PATH = RESOURCES_PATH / "TinyAndChunkyRegular.ttf" +FONT_2_PATH = RESOURCES_PATH / "Px437_IBM_BIOS.ttf" + +LOADING_IMG_PATH = RESOURCES_PATH / "loading.png" +BACKGROUND_IMG_PATH = RESOURCES_PATH / "background.png" + +ESTIMATED_BOOT_TIME_SECONDS = 18.0 + +SELECTED_RECT_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), +] + +ACTIVE_SRC_RECT_BOUNDS = [ + (10, 32, 28, 32), + (10, 56, 28, 56), +] + +ACTIVE_FX_RECT_BOUNDS = [ + (39, 32, 57, 32), + (39, 56, 57, 56), + (97, 44, 115, 44), +] diff --git a/src/forge_invar/display.py b/src/forge_invar/display.py new file mode 100644 index 0000000..7545608 --- /dev/null +++ b/src/forge_invar/display.py @@ -0,0 +1,167 @@ +import time + +from PIL import Image, ImageDraw, ImageFont, ImageMath + +from .constants import ( + ACTIVE_FX_RECT_BOUNDS, + ACTIVE_SRC_RECT_BOUNDS, + BACKGROUND_IMG_PATH, + ESTIMATED_BOOT_TIME_SECONDS, + FONT_1_PATH, + FONT_2_PATH, + LOADING_IMG_PATH, + SELECTED_RECT_BOUNDS, +) +from .enums import ForgeState +from .forge_data import ForgeData + +FONT_1 = ImageFont.truetype(FONT_1_PATH, 5) +FONT_2 = ImageFont.truetype(FONT_2_PATH, 8) +LOADING_IMG = Image.open(LOADING_IMG_PATH) +BACKGROUND_IMG = Image.open(BACKGROUND_IMG_PATH) +BLANK_IMG = Image.new("1", (128, 64), "black") + + +def __xor(img1: Image.Image, img2: Image.Image) -> Image.Image: + return ImageMath.lambda_eval( + lambda args: args["convert"](args["a"] ^ args["b"], "1"), a=img1, b=img2 + ) + + +def __get_loading_display(data: ForgeData, 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 data.state == ForgeState.INITIAL: + draw.circle((4, 4), 1, fill="white") + + return im + + +def __get_running_display(data: ForgeData) -> Image.Image: + im = BACKGROUND_IMG.copy() + draw = ImageDraw.Draw(im) + + im_overlay = BLANK_IMG.copy() + draw2 = ImageDraw.Draw(im_overlay) + + # tempo + draw.text((42, -2), f"{int(data.tempo)}", fill="white", font=FONT_1) + draw.rectangle((6, 7, 6 + int(53 * data.beat_progress), 10), fill="white", width=0) + + # time + draw.text((94, -2), f"{int(data.time)}", fill="white", font=FONT_1) + draw.rectangle( + (67, 7, 67 + int(53 * data.time_progress), 10), fill="white", width=0 + ) + + # page + draw.circle((55 + 8 * data.page, 61), 2, fill="white") + + # selected + draw.rectangle(SELECTED_RECT_BOUNDS[data.selected], outline="white", fill=None) + + # active + draw.rectangle(ACTIVE_SRC_RECT_BOUNDS[data.active(0)], fill="white") + draw.rectangle(ACTIVE_FX_RECT_BOUNDS[data.active(1)], fill="white") + + # fx/mix values + draw.rectangle((41, 16 + round(14 * (1 - data.fxa_value)), 55, 30), fill="white") + draw.rectangle((41, 40 + round(14 * (1 - data.fxb_value)), 55, 54), fill="white") + draw.rectangle((99, 28 + round(14 * (1 - data.mfx_value)), 113, 42), fill="white") + draw.rectangle((70, 28 + round(14 * (1 - data.mix_value)), 84, 42), fill="white") + + # active shaders + draw2.text((16, 20), hex(data.frag_state(0))[2].upper(), fill="white", font=FONT_2) + draw2.text((16, 44), hex(data.frag_state(1))[2].upper(), fill="white", font=FONT_2) + draw2.text((45, 20), hex(data.frag_state(2))[2].upper(), fill="white", font=FONT_2) + draw2.text((45, 44), hex(data.frag_state(3))[2].upper(), fill="white", font=FONT_2) + draw2.text((103, 32), hex(data.frag_state(5))[2].upper(), fill="white", font=FONT_2) + # TODO print when random + + draw2.text((74, 32), "K" if data.mix_mode_key else "M", fill="white", font=FONT_2) + + # TODO show feedback + + return __xor(im, im_overlay) + + +def get_display(data: ForgeData, t0: float) -> Image.Image: + if data.state == ForgeState.RUNNING: + return __get_running_display(data) + return __get_loading_display(data, t0) + + +def generate_loading_image() -> None: + global LOADING_IMG + + im = BLANK_IMG.copy() + + draw = ImageDraw.Draw(im) + + draw.text((26, 24), "F.O.R.G.E.", fill="white", font=FONT_2) + draw.rectangle((24, 35, 104, 40), outline="white", fill="black") + + im.save(LOADING_IMG_PATH) + + LOADING_IMG = im + + +def generate_background_image() -> None: + global BACKGROUND_IMG + + im = BLANK_IMG.copy() + + draw = ImageDraw.Draw(im) + + # tempo + draw.text((7, -2), "TEMPO:", fill="white", font=FONT_1) + draw.rectangle((6, 6, 60, 11), outline="white", fill="black") + + # time + draw.text((68, -2), "TIME:", fill="white", font=FONT_1) + draw.rectangle((67, 6, 121, 11), outline="white", fill="black") + + # pipes + draw.rectangle((26, 22, 78, 24), fill="white") + draw.rectangle((26, 46, 78, 48), fill="white") + draw.rectangle((78, 34, 128, 36), fill="white") + draw.rectangle((76, 23, 78, 46), fill="white") + + # squares + draw.rectangle((12, 16, 26, 30), outline="white", fill="black") + draw.rectangle((41, 16, 55, 30), outline="white", fill="black") + + draw.rectangle((12, 40, 26, 54), outline="white", fill="black") + draw.rectangle((41, 40, 55, 54), outline="white", fill="black") + + draw.rectangle((70, 28, 84, 42), outline="white", fill="black") + draw.rectangle((99, 28, 113, 42), outline="white", fill="black") + + # page + draw.circle((55, 61), 2, outline="white", fill="black") + draw.circle((63, 61), 2, outline="white", fill="black") + draw.circle((71, 61), 2, outline="white", fill="black") + + im.save(BACKGROUND_IMG_PATH) + + BACKGROUND_IMG = im + + +def debug_image(im: Image.Image) -> None: + im = im.resize((1280, 640)).convert("RGB") + + draw2 = ImageDraw.Draw(im) + + for x in range(1, 128): + draw2.line((x * 10, 0, x * 10, 640), fill=(50, 50, 50)) + for y in range(1, 64): + draw2.line((0, y * 10, 1280, y * 10), fill=(50, 50, 50)) + + im.resize((1280, 640)).save("debug.png") diff --git a/src/forge_invar/enums.py b/src/forge_invar/enums.py new file mode 100644 index 0000000..da0f500 --- /dev/null +++ b/src/forge_invar/enums.py @@ -0,0 +1,8 @@ +import enum + + +class ForgeState(enum.Enum): + INITIAL = enum.auto() + RESTORED = enum.auto() + STARTED = enum.auto() + RUNNING = enum.auto() diff --git a/src/forge_invar/forge_data.py b/src/forge_invar/forge_data.py new file mode 100644 index 0000000..da397cb --- /dev/null +++ b/src/forge_invar/forge_data.py @@ -0,0 +1,132 @@ +import collections +import pathlib +import typing + +from .constants import FORGE_SAVE_BACKUP_PATH, FORGE_SAVE_PATH, MOCK_SAVE_PATH +from .enums import ForgeState + + +class ForgeRawData: + def __init__(self) -> None: + self.__data: dict[str, float] = collections.defaultdict(lambda: 0) + + def try_restore_backup(self) -> bool: + if FORGE_SAVE_BACKUP_PATH.exists() and self.read(FORGE_SAVE_BACKUP_PATH): + FORGE_SAVE_PATH.write_text(FORGE_SAVE_BACKUP_PATH.read_text()) + return True + return False + + def read(self, file: pathlib.Path = FORGE_SAVE_PATH) -> bool: + try: + content = file.read_text() + except: # noqa: E722 + return False + for line in content.splitlines(): + key, value = line.split("=", maxsplit=2) + self.__data[key] = float(value) + if "time" in self.__data: + FORGE_SAVE_BACKUP_PATH.write_text(content) + return True + return False + + def __getitem__(self, key: str) -> float: + return self.__data[key] + + def __contains__(self, key: str) -> bool: + return key in self.__data + + +class ForgeData: + def __init__(self) -> None: + self.__data = ForgeRawData() + self.state = ForgeState.INITIAL + + def __enter__(self) -> typing.Self: + if self.__data.try_restore_backup() or self.__data.read(): + self.state = ForgeState.RESTORED + return self + + def __exit__(self, *_) -> None: + pass + + def __update_state(self) -> None: + if self.state == ForgeState.RUNNING: + return + elif self.state == ForgeState.STARTED: + if self.time > 0: + self.state = ForgeState.RUNNING + elif "time" in self.__data and self.time == 0: + self.state = ForgeState.STARTED + + def update(self) -> bool: + if self.__data.read(): + self.__update_state() + return True + return False + + def value(self, n: int) -> tuple[float, float, float]: + return ( + self.__data[f"value_{n}_x"] / 127, + self.__data[f"value_{n}_y"] / 127, + self.__data[f"value_{n}_z"] / 127, + ) + + def frag_state(self, n: int) -> int: + return int(self.__data[f"state_{n}"]) + + def active(self, n: int) -> int: + return int(self.__data[f"active_{n}"]) + + @property + def time(self) -> float: + return self.__data["time"] + + @property + def time_progress(self) -> float: + return self.time - int(self.time) + + @property + def tempo(self) -> float: + return self.__data["tempo"] + + @property + def beats(self) -> float: + return self.__data["beats"] + + @property + def beat_progress(self) -> float: + return self.beats - int(self.beats) + + @property + def page(self) -> int: + return int(self.__data["page"]) + + @property + def selected(self) -> int: + return int(self.__data["selected"]) + + @property + def fxa_value(self) -> float: + # todo random and time based + return self.value(18)[0] + + @property + def fxb_value(self) -> float: + return self.value(25)[0] + + @property + def mfx_value(self) -> float: + return self.value(32)[0] + + @property + def mix_value(self) -> float: + return self.value(34)[0] + + @property + def mix_mode_key(self) -> bool: + return self.value(33)[0] > 0. + + def mock(self) -> typing.Self: + self.__data.read(MOCK_SAVE_PATH) + self.state = ForgeState.RUNNING + return self diff --git a/src/forge_invar/i2c.py b/src/forge_invar/i2c.py new file mode 100644 index 0000000..c01f560 --- /dev/null +++ b/src/forge_invar/i2c.py @@ -0,0 +1,49 @@ +import sys +import typing + +from luma.core.error import DeviceNotFoundError +from luma.core.interface.serial import i2c +from luma.oled.device import device, sh1106 +from PIL import Image + + +class I2C: + def __init__(self) -> None: + self.__device: device | None = None + + def __enter__(self) -> typing.Self: + self.__open_device() + return self + + def __exit__(self, *_) -> None: + assert self.__device is not None + self.__device.cleanup() + + def __open_device(self) -> None: + # 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 + self.__device = sh1106(serial_interface, width=128, height=64, rotate=0) + 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 update_display(self, image: Image.Image) -> None: + assert self.__device is not None + try: + self.__device.display(image) + except OSError as e: + if e.errno == 121: + print("Runtime Error: I2C bus dropped. Check physical connections.") + raise diff --git a/uv.lock b/uv.lock index 816593d..c966893 100644 --- a/uv.lock +++ b/uv.lock @@ -44,8 +44,8 @@ wheels = [ [[package]] name = "forge-invar" -version = "0.0.0" -source = { virtual = "." } +version = "0.1.0" +source = { editable = "." } dependencies = [ { name = "luma-oled" }, ]