Finished the menu demo

This commit is contained in:
Bo⋆˚✿˖° 2025-09-23 12:40:47 +02:00
parent fc2af7031e
commit 1be6e12b54
Signed by: FreedTapestry21
GPG key ID: 5E19D8C7E935C480
5 changed files with 643 additions and 51 deletions

13
config.json Normal file
View file

@ -0,0 +1,13 @@
{
"debug": 1,
"logging": 1,
"loglevel": 4,
"logfile": "petcard.log",
"display": "SSD1306",
"controls": {
"up": "0",
"down": "3",
"select": "1",
"back": "2"
}
}

155
lib/ssd1306.py Normal file
View file

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

115
main.py
View file

@ -2,70 +2,83 @@
# Petcard Bootloader - v1.0 # Petcard Bootloader - v1.0
# Copyright (c) 2025 FreedTapestry21 # Copyright (c) 2025 FreedTapestry21
# #
# Licensed under the MIT license
#
#
# Importing libraries
#
from machine import Pin from machine import Pin
import network, urequest, machine import time
#
# Define global variables
#
# Define variables
CONSOLE_WELCOME = """ CONSOLE_WELCOME = """
Petcard Bootloader - v1.0 Petcard Bootloader - v1.0
Copyright (c) 2025 FreedTapestry21 Copyright (c) 2025 FreedTapestry21
Licensed under the MIT license Licensed under the MIT license
""" """
UPDATE = False
MIRROR = "https://mirror.pixelated.sh/dev/petcard"
# Initialize OK button (with pull down resistor) # 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): # Function(s)
print("Downloading " + name + "...") #
f = open(file)
r = urequests.get(MIRROR + "/" + file)
f.write(r.text)
f.close()
r.close
print("Downloaded " + name + "!")
def connect(ssid, psk): def main():
nic = network.WLAN(network.WLAN.IF_STA) # Check if a boot interuption was triggered (you can trigger this by holding down the OK button) and wait for 10 seconds
nic.active(True) # In later revisions I'd like this to trigger some kind of update screen to easily update the Petcard
nic.connect(ssid, psk) INTERUPT = False
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)
if ok.value() == 1: if ok.value() == 1:
UPDATE = True INTERUPT = True
if UPDATE == True: if INTERUPT == True: return 1
print("Petcard bootloader has been interupted by the OK button") else: return 0
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...") # Classes
if inp == "y" or inp == "Y": #
print("Entering Petcard recovery process...")
print("Please enter your Wi-Fi credentials") class Instance:
ssid = input("SSID: ") def __init__(self):
psk = input("Password: ") self.config_manager = system.ConfigurationManager()
connect(ssid, psk) self.logger = system.Logger(self.config_manager.config)
print('Connected to network "' + str(ssid) + '"') self.input_controller = system.InputController(self.config_manager.config["controls"], self.logger)
# Downloading system.py # Select display driver
download("system.py", "Petcard system") if self.config_manager.config["display"] == "SSD1306":
download("petcard.py", "Petcard") self.display_driver = system.SSD1306Driver()
print("System recovery complete, restarting system...")
machine.reset()
else: else:
print("'" + inp + "' is not recognized as an option! Rebooting...") self.logger.log(f"No available driver was found for display {self.config_manager.config['display']}!", 0)
machine.reset() self.display_driver = system.DisplayDriver() # DO NOT REMOVE
else:
boot() 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)
#

View file

@ -1,4 +1,79 @@
# #
# Petcard - v1.0 # Petcard - v1.0
# Copyright (c) 2025 FreedTapestry21 # 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)
# #

336
system.py
View file

@ -1,4 +1,340 @@
# #
# Petcard System - v1.0 # Petcard System - v1.0
# Copyright (c) 2025 FreedTapestry21 # 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)
# #