From 1be6e12b54dcd1ecff7f8db44fbca475c2ef08a5 Mon Sep 17 00:00:00 2001 From: FreedTapestry21 Date: Tue, 23 Sep 2025 12:40:47 +0200 Subject: [PATCH] Finished the menu demo --- config.json | 13 ++ lib/ssd1306.py | 155 +++++++++++++++++++++++ main.py | 115 +++++++++-------- petcard.py | 75 +++++++++++ system.py | 336 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 643 insertions(+), 51 deletions(-) create mode 100644 config.json create mode 100644 lib/ssd1306.py diff --git a/config.json b/config.json new file mode 100644 index 0000000..2bfe55c --- /dev/null +++ b/config.json @@ -0,0 +1,13 @@ +{ + "debug": 1, + "logging": 1, + "loglevel": 4, + "logfile": "petcard.log", + "display": "SSD1306", + "controls": { + "up": "0", + "down": "3", + "select": "1", + "back": "2" + } +} \ No newline at end of file diff --git a/lib/ssd1306.py b/lib/ssd1306.py new file mode 100644 index 0000000..1bb6679 --- /dev/null +++ b/lib/ssd1306.py @@ -0,0 +1,155 @@ +# MicroPython SSD1306 OLED driver, I2C and SPI interfaces + +from micropython import const +import framebuf + + +# register definitions +SET_CONTRAST = const(0x81) +SET_ENTIRE_ON = const(0xA4) +SET_NORM_INV = const(0xA6) +SET_DISP = const(0xAE) +SET_MEM_ADDR = const(0x20) +SET_COL_ADDR = const(0x21) +SET_PAGE_ADDR = const(0x22) +SET_DISP_START_LINE = const(0x40) +SET_SEG_REMAP = const(0xA0) +SET_MUX_RATIO = const(0xA8) +SET_COM_OUT_DIR = const(0xC0) +SET_DISP_OFFSET = const(0xD3) +SET_COM_PIN_CFG = const(0xDA) +SET_DISP_CLK_DIV = const(0xD5) +SET_PRECHARGE = const(0xD9) +SET_VCOM_DESEL = const(0xDB) +SET_CHARGE_PUMP = const(0x8D) + +# Subclassing FrameBuffer provides support for graphics primitives +# http://docs.micropython.org/en/latest/pyboard/library/framebuf.html +class SSD1306(framebuf.FrameBuffer): + def __init__(self, width, height, external_vcc): + self.width = width + self.height = height + self.external_vcc = external_vcc + self.pages = self.height // 8 + self.buffer = bytearray(self.pages * self.width) + super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB) + self.init_display() + + def init_display(self): + for cmd in ( + SET_DISP | 0x00, # off + # address setting + SET_MEM_ADDR, + 0x00, # horizontal + # resolution and layout + SET_DISP_START_LINE | 0x00, + SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0 + SET_MUX_RATIO, + self.height - 1, + SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0 + SET_DISP_OFFSET, + 0x00, + SET_COM_PIN_CFG, + 0x02 if self.width > 2 * self.height else 0x12, + # timing and driving scheme + SET_DISP_CLK_DIV, + 0x80, + SET_PRECHARGE, + 0x22 if self.external_vcc else 0xF1, + SET_VCOM_DESEL, + 0x30, # 0.83*Vcc + # display + SET_CONTRAST, + 0xFF, # maximum + SET_ENTIRE_ON, # output follows RAM contents + SET_NORM_INV, # not inverted + # charge pump + SET_CHARGE_PUMP, + 0x10 if self.external_vcc else 0x14, + SET_DISP | 0x01, + ): # on + self.write_cmd(cmd) + self.fill(0) + self.show() + + def poweroff(self): + self.write_cmd(SET_DISP | 0x00) + + def poweron(self): + self.write_cmd(SET_DISP | 0x01) + + def contrast(self, contrast): + self.write_cmd(SET_CONTRAST) + self.write_cmd(contrast) + + def invert(self, invert): + self.write_cmd(SET_NORM_INV | (invert & 1)) + + def show(self): + x0 = 0 + x1 = self.width - 1 + if self.width == 64: + # displays with width of 64 pixels are shifted by 32 + x0 += 32 + x1 += 32 + self.write_cmd(SET_COL_ADDR) + self.write_cmd(x0) + self.write_cmd(x1) + self.write_cmd(SET_PAGE_ADDR) + self.write_cmd(0) + self.write_cmd(self.pages - 1) + self.write_data(self.buffer) + + +class SSD1306_I2C(SSD1306): + def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False): + self.i2c = i2c + self.addr = addr + self.temp = bytearray(2) + self.write_list = [b"\x40", None] # Co=0, D/C#=1 + super().__init__(width, height, external_vcc) + + def write_cmd(self, cmd): + self.temp[0] = 0x80 # Co=1, D/C#=0 + self.temp[1] = cmd + self.i2c.writeto(self.addr, self.temp) + + def write_data(self, buf): + self.write_list[1] = buf + self.i2c.writevto(self.addr, self.write_list) + + +class SSD1306_SPI(SSD1306): + def __init__(self, width, height, spi, dc, res, cs, external_vcc=False): + self.rate = 10 * 1024 * 1024 + dc.init(dc.OUT, value=0) + res.init(res.OUT, value=0) + cs.init(cs.OUT, value=1) + self.spi = spi + self.dc = dc + self.res = res + self.cs = cs + import time + + self.res(1) + time.sleep_ms(1) + self.res(0) + time.sleep_ms(10) + self.res(1) + super().__init__(width, height, external_vcc) + + def write_cmd(self, cmd): + self.spi.init(baudrate=self.rate, polarity=0, phase=0) + self.cs(1) + self.dc(0) + self.cs(0) + self.spi.write(bytearray([cmd])) + self.cs(1) + + def write_data(self, buf): + self.spi.init(baudrate=self.rate, polarity=0, phase=0) + self.cs(1) + self.dc(1) + self.cs(0) + self.spi.write(buf) + self.cs(1) \ No newline at end of file diff --git a/main.py b/main.py index 8290f58..057c770 100644 --- a/main.py +++ b/main.py @@ -2,70 +2,83 @@ # Petcard Bootloader - v1.0 # Copyright (c) 2025 FreedTapestry21 # +# Licensed under the MIT license +# + +# +# Importing libraries +# from machine import Pin -import network, urequest, machine +import time + +# +# Define global variables +# -# Define variables CONSOLE_WELCOME = """ Petcard Bootloader - v1.0 Copyright (c) 2025 FreedTapestry21 Licensed under the MIT license """ -UPDATE = False -MIRROR = "https://mirror.pixelated.sh/dev/petcard" # Initialize OK button (with pull down resistor) -ok = Pin(0, Pin.IN, Pin.PULL_DOWN) +ok = Pin(1, Pin.IN, Pin.PULL_DOWN) -# Define functions -def download(file, name): - print("Downloading " + name + "...") - f = open(file) - r = urequests.get(MIRROR + "/" + file) - f.write(r.text) - f.close() - r.close - print("Downloaded " + name + "!") +# +# Function(s) +# -def connect(ssid, psk): - nic = network.WLAN(network.WLAN.IF_STA) - nic.active(True) - nic.connect(ssid, psk) - while not sta_if.isconnected(): - pass - -def boot(): - import system - init() - -# Check if button is pressed for recovery/update process -if __name__ == '__main__': - print(CONSOLE_WELCOME) +def main(): + # Check if a boot interuption was triggered (you can trigger this by holding down the OK button) and wait for 10 seconds + # In later revisions I'd like this to trigger some kind of update screen to easily update the Petcard + INTERUPT = False if ok.value() == 1: - UPDATE = True + INTERUPT = True - if UPDATE == True: - print("Petcard bootloader has been interupted by the OK button") - inp = input("Are you sure you want to enter the Petcard recovery process? (y/N)") - if inp == "" or inp == "n" or inp == "N": - print("Rebooting to resume boot process...") - if inp == "y" or inp == "Y": - print("Entering Petcard recovery process...") - print("Please enter your Wi-Fi credentials") - ssid = input("SSID: ") - psk = input("Password: ") - connect(ssid, psk) - print('Connected to network "' + str(ssid) + '"') - - # Downloading system.py - download("system.py", "Petcard system") - download("petcard.py", "Petcard") - print("System recovery complete, restarting system...") - machine.reset() + if INTERUPT == True: return 1 + else: return 0 + +# +# Classes +# + +class Instance: + def __init__(self): + self.config_manager = system.ConfigurationManager() + self.logger = system.Logger(self.config_manager.config) + self.input_controller = system.InputController(self.config_manager.config["controls"], self.logger) + + # Select display driver + if self.config_manager.config["display"] == "SSD1306": + self.display_driver = system.SSD1306Driver() else: - print("'" + inp + "' is not recognized as an option! Rebooting...") - machine.reset() - else: - boot() \ No newline at end of file + self.logger.log(f"No available driver was found for display {self.config_manager.config['display']}!", 0) + self.display_driver = system.DisplayDriver() # DO NOT REMOVE + + self.display = system.Display(self.display_driver, 128, 64, self.logger) + self.ui = system.UserInterface(self.display, self.input_controller, self.logger) + self.power = system.Power(self.display, self.input_controller, self.logger) + +# +# Entry point +# + +if __name__ == '__main__': + if main() == 1: + time.sleep(10) + machine.soft_reset() + print(CONSOLE_WELCOME) + print("Importing SYSTEM module...") + import system + print("Importing APP module...") + import petcard + print("Starting Petcard") + instance = Instance() + app = petcard.Application(instance.config_manager, instance.logger, instance.display_driver, instance.display, instance.input_controller, instance.ui, instance.power) + app.run() + +# +# End Of File (EOF) +# \ No newline at end of file diff --git a/petcard.py b/petcard.py index 7ad7feb..85e6852 100644 --- a/petcard.py +++ b/petcard.py @@ -1,4 +1,79 @@ # # Petcard - v1.0 # Copyright (c) 2025 FreedTapestry21 +# +# Licensed under the MIT license +# + +# +# Importing libraries +# + +import sys, json, machine + +# Import SYSTEM module +import system + +# +# Classes +# + +# Main system & application controller +class Application: + def __init__(self, config_manager, logger, display_driver, display, input_controller, ui, power): + self.config_manager = config_manager + self.logger = logger + self.display_driver = display_driver + self.display = display + self.input_controller = input_controller + self.ui = ui + self.power = power + + # Initializes all system components + def initialize(self): + self.logger.log("Initializing Petcard System...", 3) + self.display.initialize() + self.logger.log("System initialization complete", 3) + + # Main system loop + def run(self): + try: + self.initialize() + + # Create main menu + main_menu_items = [ + system.MenuItem("Food", self._action, "f"), + system.MenuItem("Drinks", self._action, "d"), + system.MenuItem("Play", self._action, "p"), + system.MenuItem("Sleep", self.power.sleep) + ] + + main_menu = system.Menu(main_menu_items, self.logger) + + # Main menu loop + while True: + selected_item = self.ui.show_menu(main_menu) + if selected_item: + result = selected_item.execute() + if result == "exit": + break + + except Exception as e: + self.logger.log(f"System error: {e}", 0) + self.power.exit_system("System encountered an error") + + # Defines what to do when a certain action is triggered + def _action(self, act): + if act == "f": + self.logger.log("Food!", 3) + elif act == "d": + self.logger.log("Drink!", 3) + elif act == "p": + self.logger.log("Play!", 3) + else: self.logger.log(f"Invalid action {act}", 1) + + return + +# +# End Of File (EOF) # \ No newline at end of file diff --git a/system.py b/system.py index 9a5cc94..bddc2c1 100644 --- a/system.py +++ b/system.py @@ -1,4 +1,340 @@ # # Petcard System - v1.0 # Copyright (c) 2025 FreedTapestry21 +# +# Licensed under the MIT license +# + +# +# Importing libraries +# + +import sys +import time +import json +import machine +import framebuf +import ssd1306 +import _thread + +# +# Classes +# + +# Manages system configuration +class ConfigurationManager: + def __init__(self, config_file="config.json"): + self.config_file = config_file + self.config = self._load_config() + + # Load configuration from file + def _load_config(self): + try: + with open(self.config_file, "r") as file: + return json.loads(file.read()) + except Exception as e: + print(f"Failed to load config: {e}") + return self._get_default_config() + + # Return default configuration + def _get_default_config(self): + return { + "debug": 1, + "logging": 1, + "loglevel": 3, + "logfile": "petcard.log", + "display": "SSD1306", + "controls": { + "up": "0", + "down": "3", + "select": "1", + "back": "2" + } + } + +# Handles logging functionality with timestamp formatting +class Logger: + def __init__(self, config): + self.log_level = config.get("loglevel", 0) == 3 + self.logging_enabled = config.get("logging", 0) == 1 + self.log_file = config.get("logfile", "system.log") + + def log(self, message, log_level, ignore_console=False): + timestamp = self._get_timestamp() + + if log_level == 0: + formatted_message = f"[CRITICAL] [{timestamp}] {message}" + elif log_level == 1: + formatted_message = f"[ERROR] [{timestamp}] {message}" + elif log_level == 2: + formatted_message = f"[WARNING] [{timestamp}] {message}" + elif log_level == 3: + formatted_message = f"[INFO] [{timestamp}] {message}" + elif log_level == 4: + formatted_message = f"[DEBUG] [{timestamp}] {message}" + + if self.log_level <= log_level: + if not ignore_console: + print(formatted_message) + + if self.logging_enabled: + self._write_to_file(formatted_message) + + def _get_timestamp(self): + # Generate formatted timestamp + date = time.localtime() + return f"{date[0]}-{date[1]}-{date[2]} {date[3]}:{date[4]}:{date[5]}" + + def _write_to_file(self, message): + # Write message to log file + try: + with open(self.log_file, "a") as file: + file.write(message + '\n') + except Exception as e: + print(f"Failed to write to log file: {e}") + + # Get configuration value + def get(self, key, default=None): + return self.config.get(key, default) + +# Handles input from buttons/controls +class InputController: + def __init__(self, control_config, logger): + self.controls = control_config + self.logger = logger + self.pins = {} + self._initialize_pins() + + # Initialize pin objects for all controls + def _initialize_pins(self): + for control_name, pin_number in self.controls.items(): + try: + self.pins[control_name] = machine.Pin( + int(pin_number), + machine.Pin.IN, + machine.Pin.PULL_DOWN + ) + if control_name == "select": + self.pins["select"].irq(trigger=machine.Pin.IRQ_RISING, handler=lambda p:None) + self.logger.log(f"Set interupt pin to {control_name}", 4) + except Exception as e: + self.logger.log(f"Failed to initialize pin for {control_name}: {e}", 1) + + # Wait for and return the first button press detected + def wait_for_input(self): + while True: + for control_name, pin in self.pins.items(): + try: + if pin.value() == 1: + time.sleep(0.05) # Debounce delay + if pin.value() == 1: # Confirm press + return control_name + except Exception as e: + self.logger.log(f"Error reading pin {control_name}: {e}", 1) + time.sleep(0.01) # Small delay to prevent excessive polling + +# Base class for display drivers - implement all methods in subclasses +class DisplayDriver: + def initialize(self): + raise NotImplementedError("DisplayDriver subclass must implement initialize()") + + def show(self): + raise NotImplementedError("DisplayDriver subclass must implement show()") + + def clear(self): + raise NotImplementedError("DisplayDriver subclass must implement clear()") + + def blit(self, buffer, x=0, y=0): + raise NotImplementedError("DisplayDriver subclass must implement blit()") + +# SSD1306 OLED display driver implementation +class SSD1306Driver(DisplayDriver): + def __init__(self, width=128, height=64, sda_pin=4, scl_pin=5): + self.width = width + self.height = height + self.sda_pin = sda_pin + self.scl_pin = scl_pin + self.display = None + self.i2c = None + + # Initializes the SSD1306 display + def initialize(self): + self.i2c = machine.I2C(0, sda=machine.Pin(self.sda_pin), scl=machine.Pin(self.scl_pin)) + self.display = ssd1306.SSD1306_I2C(self.width, self.height, self.i2c) + + # Updates the physical display + def show(self): + if self.display: + self.display.show() + + # Clears the display + def clear(self): + if self.display: + self.display.fill(0) + + # Blit buffer to display + def blit(self, buffer, x=0, y=0): + if self.display: + self.display.blit(buffer, x, y) + + +# Main display controller with frame buffer management +class Display: + def __init__(self, driver, width, height, logger): + self.width = width + self.height = height + self.driver = driver + self.logger = logger + self.pages = height // 8 + self.buffer = framebuf.FrameBuffer( + bytearray(self.pages * width), + width, + height, + framebuf.MONO_VLSB + ) + + # Initializes the display driver + def initialize(self): + try: + self.driver.initialize() + self.logger.log("Display initialized successfully", 4) + except Exception as e: + self.logger.log(f"Display initialization failed: {e}", 0) + + # Update the physical display with buffer contents + def update(self): + try: + self.driver.blit(self.buffer, 0, 0) + self.driver.show() + except Exception as e: + self.logger.log(f"Display update failed: {e}", 1) + + # Clear the display buffer + def clear(self): + self.buffer.fill(0) + + # Draw text on the buffer + def draw_text(self, text, x, y, color=1): + self.buffer.text(text, x, y, color) + + # Draw filled rectangle on the buffer + def draw_filled_rect(self, x, y, width, height, color=1): + self.buffer.fill_rect(x, y, width, height, color) + +# Object for a single menu item, can be passed through to the Menu class +class MenuItem: + def __init__(self, text, action=None, args=None): + self.text = text + self.action = action + self.args = args + + # Execute the menu item's action + def execute(self): + if callable(self.action) == True: + if self.args: return self.action(self.args) + else: return self.action() + return self.action + +# Menu system with navigation and rendering +class Menu: + + TEXT_HEIGHT = 8 + PADDING = 4 + + def __init__(self, items, logger): + self.items = [item if isinstance(item, MenuItem) else MenuItem(str(item)) for item in items] + self.logger = logger + self.selection = 0 + + # Handles menu navigation selection + def navigate(self, direction): + if direction == "up": + self.selection = (self.selection - 1) % len(self.items) # The percentage ensures the result wraps around within the valid range of the list + elif direction == "down": + self.selection = (self.selection + 1) % len(self.items) + + # Gets the currently selected menu item + def get_selected_item(self): + if 0 <= self.selection < len(self.items): + return self.items[self.selection] + return None + + def render(self, display): + # Render menu to display + display.clear() + + # Draw selection highlight + highlight_y = self.selection * (self.TEXT_HEIGHT + self.PADDING) + 2 + display.draw_filled_rect(0, highlight_y, display.width, 10, 1) + + # Draw menu items + y_pos = self.PADDING + for i, item in enumerate(self.items): + if y_pos < display.height: + if i == self.selection: color = 0 # Normal color for non-selected item + else: color = 1 # Inverted color for selected item + display.draw_text(item.text, 0, y_pos, color) + y_pos += self.TEXT_HEIGHT + self.PADDING + + display.update() + +# Main UI controller managing menus and interactions +class UserInterface: + def __init__(self, display, input_controller, logger): + self.display = display + self.input_controller = input_controller + self.logger = logger + + # Displays the menu and handles navigation until selection is made + def show_menu(self, menu): + menu.render(self.display) + + while True: + try: + user_input = self.input_controller.wait_for_input() + self.logger.log(f"Menu input received: {user_input}", 4) + + if user_input in ["up", "down"]: + menu.navigate(user_input) + menu.render(self.display) + elif user_input == "select": + selected_item = menu.get_selected_item() + if selected_item: + return selected_item + elif user_input == "back": + return None + + time.sleep(0.2) # Prevent input spam + + except Exception as e: + self.logger.log(f"Error in menu navigation: {e}", 1) + +# Handles power management +class Power: + def __init__(self, display, input_controller, logger): + self.display = display + self.input_controller = input_controller + self.logger = logger + + def sleep(self): + self.logger.log("Entering sleep mode", 3) + self.logger.log("Turning off display", 4) + self.display.clear() + self.display.update() + self.logger.log("Waiting for select to be pressed...", 4) + while True: + if self.input_controller.wait_for_input() == "select": + break + self.logger.log("Returning from sleep mode", 4) + return + + def exit_system(self, message=""): + # Exit the system with optional message + if message: + self.logger.log(message, 3) + self.logger.log("System shutting down...", 3) + machine.deepsleep() + +# +# End Of File (EOF) # \ No newline at end of file