6 Commits
v0.1 ... v0.3

11 changed files with 593 additions and 231 deletions

View File

@@ -5,7 +5,7 @@ A collection of programs run on a Raspberry Pi Pico to control a uv exposure uni
## Hardware
This program is strongly customized to my needs, and my DIY exposure unit has **two buttons** and **one switch** to interact with the software (and a power switch FWIW). Also, a **16*2 display** (maybe 20*4 or others do also work, but these are not tested) can show information to the user.
This program is strongly customized to my needs, and my DIY exposure unit has **two buttons** and **one switch** to interact with the software (and a power switch FWIW). Also, a **16x2 display** (maybe 20x4 or others do also work, but these are not tested) can show information to the user.
A **relais** is used for switching all the LEDs.
| Device Pin | Pi Pico Pin |
@@ -30,19 +30,45 @@ To install this, use [Thonny](https://thonny.org/), open all the files present i
## Configuration
All the configuration can be done in the [config.py](config.py) file, just have a look around there. Some hints for editing this file:
All the configuration can be done in the [config.json](config.json) file in JSON format just have a look around there. Some hints for editing this file:
- When editing the startup section strings, make sure the "STARTUP_PROJECT_NAME" and the "STARTUP_MESSAGE_FINISHED" values have the same length as your display can show (likely 16 characters). Otherwise it could be that some things won't be displayed correctly.
- For a description of all of the attributes, see below
- When editing the startup section strings, make sure the "STARTUP\_PROJECT\_NAME" and the "STARTUP\_MESSAGE\_FINISHED" values have the same length as your display can show (likely 16 characters). Otherwise it could be that some things won't be displayed correctly.
- When changing Pins in the Pinout section, make sure to follow the pinout assignment of your Pi Pico board (e.g. the i2c sda and scl pins)
- If your display doesn't work properly - the first issue might be a wrong i2c address. To find the address of your display, just follow some of the tutorials on the internet on scanning for i2c devices (e.g. [here](https://randomnerdtutorials.com/raspberry-pi-pico-i2c-scanner-micropython/)).
### Attribute table
| Attribute name (on top level in config.json) | Type | Description | Default |
| -------------------------------------------- | ---- | ----------- | ------- |
| "LOG\_LEVEL" | int | defines up to which log level to show log messages in the serial console: warn (0), info (1), debug (2) | 2 |
| "STARTUP\_WELCOME\_SHOW" | bool | show the startup screen? | true |
| "STARTUP\_PROJECT\_NAME" | str | the name shown at the welcome/startup screen | " UV-Belichter " |
| "STARTUP\_MESSAGE\_STARTING | str | the message shown at startup when starting | "Starting..." |
| "STARTUP\_PROJECT\_FINISHED" | str | the message shown at startup when finished | " Started! " |
| "STARTUP\_WELCOME\_CYCLES" | int | how often the starting string shall go over the welcome screen | 1 |
| "PIN\_IN\_BTN\_1" | dict | the dict must contain the "pin" and "pull" keys with respective values | {"pin": 15, "pull": "down"} |
| "PIN\_IN\_BTN\_2" | dict | as above | {"pin": 14, "pull": "down"} |
| "PIN\_IN\_SWITCH" | dict | as above | {"pin": 13, "pull": "down"} |
| "PIN\_OUT\_RELAIS" | int | pin number where the relais is connected | 21 |
| "PIN\_SDA" | int | the pin number of the sda wire connected to the lcd | 8 |
| "PIN\_SCL" | int | the pin number of the scl wire connected to the lcd | 9 |
| "LCD\_I2C\_CH" | int | the channel of the i2c bus used | 0 |
| "LCD\_I2C\_ADDR" | int | the i2c address of the lcd; make sure to convert hexadecimal to decimal numbers | 38 |
| "LCD\_I2C\_NUM\_ROWS" | int | how many rows for character display has the display? | 2 |
| "LCD\_I2C\_NUM\_COLS" | int | and how many characters can it display per row? | 16 |
Note that this software has it's own small wrapper for the config file, e.g. to have instant access to an LCD object generated on the fly. These are all documented in the [utils.py](utils.py) file!
## Used libraries
- PCF8574T - a driver for the i2c multiplexer used to address the 2x16 lcd display
- [ProgramChooserAdapted](https://git.privacynerd.de/BlueFox/ProgramChooser) - an adapted version of ProgramChooser (but I'm planning to merge my changes to the upstream Programchooser)
- [PCF8574T](https://git.privacynerd.de/BlueFox/micropython-libraries/src/branch/main/PCF8574T) - a driver for the i2c multiplexer used to address the 2x16 lcd display
- [WelcomeScreen](https://git.privacynerd.de/BlueFox/WelcomeScreen) - used to display a small welcome message in the beginning
- [lcdMenu](https://git.privacynerd.de/BlueFox/lcdMenu) - used for the menus
## License

18
config.json Normal file
View File

@@ -0,0 +1,18 @@
{
"LOG_LEVEL": 2,
"STARTUP_WELCOME_SHOW": true,
"STARTUP_PROJECT_NAME":" UV-Belichter ",
"STARTUP_MESSAGE_STARTING": "Starting...",
"STARTUP_MESSAGE_FINISHED": " Started! ",
"STARTUP_WELCOME_CYCLES": 1,
"PIN_IN_BTN_1": {"pin": 15, "pull": "down"},
"PIN_IN_BTN_2": {"pin": 14, "pull": "down"},
"PIN_IN_SWITCH": {"pin": 13, "pull": "down"},
"PIN_OUT_RELAIS": 21,
"PIN_SDA": 8,
"PIN_SCL": 9,
"LCD_I2C_CH": 0,
"LCD_I2C_ADDR": 39,
"LCD_I2C_NUM_ROWS": 2,
"LCD_I2C_NUM_COLS": 16,
}

View File

@@ -1,65 +0,0 @@
"""
uv-belichter-software - Configuration file
Copyright (C) 2024 Benjamin Burkhardt
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
"""
---------------------------
----- LOGGING SECTION -----
---------------------------
"""
LOG_LEVEL = 2 # there are three log levels: warn (0), info (1), debug (2)
# this value defines which log messages to show
# e.g. 2 means show [debug], [warn] and [info] messages
"""
---------------------------
----- STARTUP SECTION -----
---------------------------
"""
STARTUP_PROJECT_NAME = " UV-Belichter " # the name to show at startup
STARTUP_MESSAGE_STARTING = "Starting..." # the message to show at startup
STARTUP_MESSAGE_FINISHED = " Started! " # the message to show at startup
STARTUP_WELCOME_SHOW = True # show the name and a startup message
STARTUP_WELCOME_CYCLES = 1 # how often shall "Starting..." run over the screen
"""
--------------------------
----- PINOUT SECTION -----
--------------------------
"""
from machine import Pin
BTN_1 = Pin(15, Pin.IN, Pin.PULL_DOWN) # input of the first btn
BTN_2 = Pin(14, Pin.IN, Pin.PULL_DOWN) # input of the second btn
SWITCH = Pin(13, Pin.IN, Pin.PULL_DOWN) # input of switch
LCD_SDA = Pin(8) # just some standard I2C serial data (SDA) outputs (on I2C channel 0 on Pi Pico)
LCD_SCL = Pin(9) # just some standard I2C serial clock (SCL) outputs (on I2C channel 0 on Pi Pico)
#LCD_SDA = Pin(16) # another pinout (soldered on the original project's circuit board)
#LCD_SCL = Pin(17) # another pinout (soldered on the original project's circuit board)
RELAIS = Pin(21, Pin.OUT) # where the relais is connected (for the UV lights)
"""
-----------------------
----- LCD SECTION -----
-----------------------
"""
from machine import I2C, Pin
from lib.PCF8574T import I2C_LCD
LCD_I2C_ADDR = 0x27 # the i2c adress of the display (yours might be different to this one)
LCD_I2C_NUM_ROWS = 2 # how many rows for character display has the display?
LCD_I2C_NUM_COLS = 16 # and how many characters can it display per row?
LCD = I2C_LCD(I2C(0, sda=LCD_SDA, scl=LCD_SCL, freq=400000),
LCD_I2C_ADDR,
LCD_I2C_NUM_ROWS,
LCD_I2C_NUM_COLS)

View File

@@ -9,11 +9,25 @@ This program is distributed in the hope that it will be useful, but WITHOUT ANY
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import config as cfg
from utils import Config
from time import sleep
cfg = Config()
def run(endless_loop: bool = True, serial_output: bool = True):
while endless_loop:
if cfg.PIN_IN_BTN_1.value() and cfg.PIN_IN_BTN_2.value() and cfg.PIN_IN_SWITCH.value():
cfg.LCD.move_to(0,0)
cfg.LCD.putstr("In: Y1 | G1 | S1 Exiting! ")
if serial_output:
print("All configured inputs pressed! Exiting...")
sleep(0.2)
break
cfg.LCD.move_to(0,0)
cfg.LCD.putstr(f"In: Y{cfg.PIN_IN_BTN_1.value()} | G{cfg.PIN_IN_BTN_2.value()} | S{cfg.PIN_IN_SWITCH.value()}Push all to exit")
if serial_output:
print(f"Y_BTN: {cfg.PIN_IN_BTN_1.value()}; G_BTN: {cfg.PIN_IN_BTN_2.value()}; Lever: {cfg.PIN_IN_SWITCH.value()}")
sleep(0.05)
if __name__ == "__main__":
while True:
print(f"Y_BTN: {cfg.BTN_1.value()}; G_BTN: {cfg.BTN_2.value()}; Lever: {cfg.SWITCH.value()}")
sleep(0.1)
run() # run the program

View File

@@ -1,5 +1,5 @@
"""
An example "program" which can be used with the ProgramChooser library, see also main.py
An example "program" which can be used with the lcdMenu library, see also main.py
Copyright (C) 2024 Benjamin Burkhardt <bluefox@privacynerd.de>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

View File

@@ -1,93 +0,0 @@
"""
ProgramChooser - a program launcher for microPython (adapted to the UV Belichter needs)
Copyright (C) 2024 Benjamin Burkhardt <bluefox@privacynerd.de>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import utils, config
from PCF8574T import I2C_LCD
import time
class ProgramChooser:
def __init__(self, programs, debug=False, run_directly=False):
self.lcd = config.LCD
self.ok_btn = config.BTN_1
self.next_btn = config.BTN_2
self.lcd.move_to(0,0)
self.lcd.putstr("[ PROGRAMS ]< >")
self.current_selection = None # no selection
self.programs = programs # a dictionary of programs and it's callbacks e.g. {"lora_test": some_callback}
self.show_selection()
if run_directly: self.run()
def log(self, msg, is_debug=False):
if is_debug:
utils.log(2, f"[ProgramChooser] {msg}")
else:
utils.log(1, f"[ProgramChooser] {msg}")
def show_selection(self):
self.lcd.move_to(1,1)
if len(self.programs) == 0:
self.lcd.putstr(" No programs!")
return True
if self.current_selection == None: # set it initially
self.current_selection = 0
# the actual displaying process
to_show = list(self.programs.keys())[self.current_selection]
if len(to_show) > 14:
to_show = to_show[:13] + chr(0)
else:
to_show = to_show[:14]
to_show = to_show.center(14)
self.lcd.putstr(to_show)
return True
def run(self):
while True:
if self.next_btn.value() == 1:
former_program_name = list(self.programs.keys())[self.current_selection]
self.current_selection = (self.current_selection+1)%len(list(self.programs.keys()))
self.show_selection()
now_program_name = list(self.programs.keys())[self.current_selection]
self.log(f"Selected next program (\"{former_program_name}\" -> \"{now_program_name}\")")
while self.next_btn.value() == 1: time.sleep(0.01) # wait till release
if self.ok_btn.value() == 1:
program_name = list(self.programs.keys())[self.current_selection]
self.log(f"Running selected program! (\"{program_name}\")")
# shorten the name for displaying (if too long)
if len(program_name) > 14:
program_name = program_name[:13] + chr(0)
else:
program_name = program_name[:14]
program_name = program_name.center(14)
self.lcd.move_to(0,0)
self.lcd.putstr(f" {program_name} Executing... ")
self.execute_selection()
while self.ok_btn.value() == 1: time.sleep(0.01) # wait till release (e.g. if the "program" is a simple send action)
self.lcd.putstr(f" {program_name} Closing... ")
time.sleep(1)
self.lcd.move_to(0,0)
self.lcd.putstr("[ PROGRAMS ]< >")
self.show_selection()
time.sleep(0.01)
def execute_selection(self): # execute the current selected program's callback
self.programs[list(self.programs.keys())[self.current_selection]]()

View File

@@ -0,0 +1,126 @@
"""
WelcomeScreen: A simple library providing a customizable welcome screen fading over an LCD
Copyright (C) 2024 Benjamin Burkhardt
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from time import sleep
class WelcomeScreen:
# __init__() - the constructor
# lcd: an object of I2C_LCD (from the PCF8574T library - https://git.privacynerd.de/BlueFox/micropython-libraries/src/branch/main/PCF8574T)
# ---
# interrupt_pins: a list containing machine.Pin objects exclusively (but if you give these you can still turn interrupting off
# via a parameter in the show method (see below!)
# ---
# subtitle: the text shown below the cycling text (e.g. the device's name, ...) (Lorem ipsum. by default)
# ---
# starting_msg: the text shown while cycling (default: Starting...)
# ---
# started_msg: the text shown while cycling (default: Started!)
def __init__(self, lcd, interrupt_pins=None, subtitle="Lorem ipsum.", starting_msg="Starting...", started_msg="Started!"):
self.lcd = lcd
self.columns = self.lcd.num_columns
self.lines = self.lcd.num_lines
self.interrupt_pins = interrupt_pins
self.subtitle = subtitle
self.starting_msg = starting_msg
self.started_msg = started_msg
# show() - Display the actual message
# ---
# cycles says how often the Starting text goes through
# ---
# wait_after_cycles is a number in seconds (can be float) defining how long to wait before returning OR
# if fading out is activated, the time to wait between end of the cycling animation and the fade out animation
# ---
# fade_down is a dict with following keys:
# - "enabled" - REQUIRED - wether fading is enabled (default: true)
# - "wait_between" - OPTIONAL - the time in seconds to wait between each line fade
# - "wait_after" - OPTIONAL - the time in seconds to wait after the fade out
# ---
# interruptable influences wether the program can be interrupted by pins in the self.interrupt_pins list
# if this list is empty, even when interruptable is set to true, nothing will be able to interrupt!
def show(self, cycles=1, wait_after_cycles=1, fade_down={"enabled": True}, interruptable=True):
if cycles < 1: cycles = 1
padding = " " * self.columns # as much spaces as padding as one display line is long
padding_hyphen = "-" * self.columns # as much hyphens as padding as one display line is long
# mechanism for centering on a 4*20 display
y_offset = 0
if self.lines == 4:
y_offset = 1
# also clear the top and bottom with ----
self.lcd.move_to(0,0)
self.lcd.putstr(padding_hyphen)
self.lcd.move_to(0,4)
self.lcd.putstr(padding_hyphen)
# get the current pin values (only if there are pins specified) (when something changes, the interrupt happens and the cycle stops)
break_flag = False
if self.interrupt_pins:
pin_values = []
for p in self.interrupt_pins:
pin_values.append(p.value())
# cycle the text 'cycles' times and listen for changes on interrupt pins (if any given)
for i in range(cycles):
line1 = padding + self.starting_msg + padding
line2 = self.subtitle.center(self.columns)
for i in range(self.columns + len(self.starting_msg)):
self.lcd.move_to(0,y_offset)
self.lcd.putstr(line1[0:self.columns])
self.lcd.move_to(0,y_offset+1)
self.lcd.putstr(line2[0:self.columns])
line1 = line1[1:]
if self.interrupt_pins:
for i, p in enumerate(self.interrupt_pins):
if pin_values[i] != p.value():
break_flag = True
if break_flag:
break
if break_flag:
break
self.lcd.move_to(0,y_offset)
self.lcd.putstr(self.started_msg.center(16))
sleep(wait_after_cycles)
# now fade down if enabled via the params
if fade_down["enabled"]:
# get all the waiting times
if "wait_between" in fade_down.keys():
wait_between = fade_down["wait_between"]
else:
wait_between = 0.1
if "wait_after" in fade_down.keys():
wait_after = fade_down["wait_after"]
else:
wait_after = 0.3
if self.lines == 4:
old_display = padding_hyphen + self.started_msg.center(16) + self.subtitle.center(self.columns) + padding_hyphen
else:
old_display = self.started_msg.center(16) + self.subtitle.center(self.columns)
self.lcd.move_to(0,0) # move to the start of the lcd
while old_display != "":
old_display = old_display[:-self.columns]
lines_before = " " * (self.columns*self.lines-len(old_display))
self.lcd.putstr(lines_before + old_display)
sleep(wait_between)
sleep(wait_after)

241
lib/lcdMenu/__init__.py Normal file
View File

@@ -0,0 +1,241 @@
"""
lcdMenu - A micropython library, which supports vertical and horizontal scrolling through menu items on both 2x16 and 4x20 LCDs
Copyright (C) 2024 Benjamin Burkhardt <bluefox@privacynerd.de>
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from time import sleep
class lcdMenu:
# lcd: I2C_LCD - an object of the I2C_LCD class (https://git.privacynerd.de/BlueFox/micropython-libraries/src/branch/main/PCF8574T)
# buttons: dict - a dictionary with machine.Pin objects as items with following keys
# - "prev_btn" - OPTIONAL - when pressed, select the previous menu item
# - "next_btn" - REQUIRED - when pressed, select the next menu item
# - "ok_btn" - REQUIRED - when pressed, call the callback function of the menu item
# menu_items: list - a list (-> maintains order!) containing tuples with the following format: (<ENTRY_NAME>,<CALLBACK FUNCTION>)
# scroll_direction: bool - if true, the scrolling direction is horizontal, if false, vertical
# cycle: bool - if true, start again with the first menu entry after the last one (and show the last before the first)
# hide_menu_name: bool - OPTIONAL - if true, hide the menu's name (won't work in combination with a vertical scrolling direction)
# name: str - OPTIONAL - a string with the name of the menu (can be hidden under certain circumstances)
# debounce_time: float - OPTIONAL - the debounce time used by the library to debounce button presses
def __init__(self, lcd, buttons: dict, scroll_direction: bool, cycle: bool, hide_menu_name: bool = False, name: str = "CHOOSE", debounce_time: float = 0.15):
# save the argument variables
self.lcd = lcd
if "prev_btn" in buttons.keys():
self.prev_btn = buttons["prev_btn"]
else:
self.prev_btn = None
self.next_btn = buttons["next_btn"]
self.ok_btn = buttons["ok_btn"]
self.scroll_direction = scroll_direction
self.cycle = cycle
self.hide_menu_name = hide_menu_name
self.name = name
self.debounce_time = debounce_time
self.current_selection = 0 # set a standard value (can/most of the time will be changed directly after __init__ by a call to self.setup())
self.menu_items = [] # set a standard empty (can/most of the time will be changed directly after __init__ by a call to self.setup())
# variables that are very unlikely the user want's to set them (but can be set via <lcdMenuObject>.<attribute_name>, of course)
self.start_execution_msg = "Selected..." # the string displayed when an menu item is selected
self.end_execution_msg = "Closing..." # the string displayed when the callback function of a selected menu item returns self.end_execution_wait = 1 # the time (in seconds) to wait after the callback function of a selected menu item returns
self.start_execution_wait = 0.5 # the time (in seconds) to wait before the callback function of a selected menu item is called
self.end_execution_wait = 1 # the time (in seconds) to wait after the callback function of a selected menu item returns
self.fill_char = '-' # the character used to fill up space (used only on 4x20 displays); MUST BE 1 character long
# menu_items: list - a list (-> maintains order!) containing tuples with the following format: (<ENTRY_NAME>,<CALLBACK FUNCTION>)
# start_selection: int - OPTIONAL - the index of the item selected by default (starting with 0) - DON'T USE NEGATIVE INDEXES
def setup(self, menu_items: list, start_selection: int = 0):
self.menu_items = menu_items
self.current_selection = start_selection
def show_selection(self):
# some checks:
# 1. if you scrolling vertically, I found no elegant way to hide the name (there just need's to be something up there!)
if self.scroll_direction and self.hide_menu_name:
raise TypeError("Hiding the menu name whilst having the scroll direction set to horizontal is impossible!")
# 2. if there are no menu items to display...
if len(self.menu_items) == 0:
raise TypeError("Can't show empty menus! Maybe you forgot calling self.setup() after initializing me?")
# get some often used values into local variables
selection_name = self.menu_items[self.current_selection][0]
lw = self.lcd.num_columns
# fill the custom character fields in the displays memory
self.lcd.custom_char(0, bytearray([0x04,0x0A,0x11,0x00,0x00,0x00,0x00,0x00])) # arrow up
self.lcd.custom_char(1, bytearray([0x00,0x00,0x00,0x00,0x00,0x11,0x0A,0x04])) # arrow down
self.lcd.custom_char(2, bytearray([0x04,0x0A,0x11,0x00,0x00,0x11,0x0A,0x04])) # arrow up and down
self.lcd.custom_char(3, bytearray([0x11,0x0A,0x04,0x00,0x00,0x04,0x0A,0x11])) # no options
self.lcd.custom_char(4, bytearray([0x08,0x08,0x08,0x0F,0x08,0x08,0x08,0x08])) # line with a fork (to show the current selection - v scrolling)
self.lcd.custom_char(5, bytearray([0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x08])) # line without a fork (to show unselected items - v scrolling)
self.lcd.custom_char(6, bytearray([0x00,0x00,0x00,0x00,0x00,0x00,0x15,0x00])) # three dots in a row
# now show it off!
# Horizontal scrolling:
if self.scroll_direction:
self.lcd.move_to(0,0)
if self.lcd.num_lines == 4:
self.lcd.putstr(f"{self.fill_char*lw}{' '*lw*2}{self.fill_char*lw}") # fill the first and last line with 'fill_char's
self.lcd.move_to(0,1) # move to the second line for the starting message below (takes two lines)
self.lcd.putstr(f"[{self.name[0:lw-2].center(lw-2)}]") # the menu name (cannot be hidden in this mode)
self.lcd.putstr(f"<{selection_name[0:lw-2].center(lw-2)}>") # the current selected menu item's name
# Vertical scrolling:
else:
lines_for_display = self.lcd.num_lines
items_before_selection = self.current_selection # e.g. if current selection is the second -> 1; one item is before this
items_after_selection = len(self.menu_items) - self.current_selection - 1
self.lcd.move_to(0,0)
if not self.hide_menu_name:
self.lcd.putstr(f"[{self.name[0:lw-2].center(lw-2)}]") # print the menu name surrounded by sqare brackets []
lines_for_display -= 1 # decrease the amount of lines available for displaying menu items by 1 (as it's used for the menu name)
# now we want to display the selections
# where the currently selected item should be the uppermost if possible
# only at the end of the item list, the current item has to be in a lower line...
# ... to avoid emtpy lines at the end as this would seem unprofessional/unclean to me
# but it adds an noticeable amount of extra work / a complex algorithm
if items_after_selection < (lines_for_display - 1): # if there aren't enough items to fill the display after the current selection
# maybe the following could be done with a crazy math formula - but I want to keep it simpler!
# as there aren't enough menu items after the current selection to fill the display, we have to...
# ... calculate where to place the current selection, how many items there are before it and how many after it
menu_items_cut = self.menu_items[::-1][:lines_for_display][::-1] # cut the menu_items list to the relevant last n ones maintaining order (n = number of lines for display)
current_pos_in_cut = -len(self.menu_items) + self.current_selection + lines_for_display # calculate the current index of the selection in the new cut
# draw all the lines
for i in range(lines_for_display):
if i == current_pos_in_cut: # if drawing the currently selected item
self.lcd.putstr(f"{chr(4)} {menu_items_cut[i][0][0:lw-4]}")
self.lcd.putstr(" " * ((lw-4)-len(menu_items_cut[i][0][0:lw-4]))) # fit the line
else:
self.lcd.putstr(f"{chr(5)} {menu_items_cut[i][0][0:lw-4]}")
self.lcd.putstr(" " * ((lw-4)-len(menu_items_cut[i][0][0:lw-4]))) # fit the line
# now the arrow
if i == 0: # if the first element is drawn, think about printing or not printing the up arrow
if self.current_selection == 0 and not self.cycle: # first item selected and no cycling
self.lcd.putstr(" ") # leave the line with spaces
else:
self.lcd.putstr(" " + chr(0))
elif i == lines_for_display-1: # if the last element is drawn, print a down arrow?
if self.current_selection == (len(self.menu_items)-1) and not self.cycle: # first item selected and no cycling
self.lcd.putstr(" ") # leave the line with spaces
else:
self.lcd.putstr(" " + chr(1)) # arrow down
else: # there are enough items to fill the display after the current selection
# the first line
self.lcd.putstr(f"{chr(4)} {selection_name[0:lw-4]}")
self.lcd.putstr(" " * ((lw-4)-len(selection_name[0:lw-4]))) # fill the line with spaces up to 2 before the end of the line
if len(self.menu_items) <= 1:
self.lcd.putstr(" " + chr(3)) # no options icon (as there's only one menu item!)
elif lines_for_display == 1: # if there is exactly one line to display the menu entries...
if self.current_selection == 0 and not self.cycle: # ...and the first element is selected and cycling is disabled so you can't go back
self.lcd.putstr(" "+chr(1))
elif self.current_selection == (len(self.menu_items)-1) and not self.cycle: # ...as before but with the last element -> you can't go further
self.lcd.putstr(" "+chr(0))
else: # ...or anything else (cycling or in the middle -> you can go in both directions
self.lcd.putstr(" "+chr(2))
elif self.current_selection == 0 and not self.cycle: # first item selected and no cycling
self.lcd.putstr(" ") # leave the line with spaces
else:
self.lcd.putstr(" " + chr(0))
# the other lines... (if existing!)
for i in range(lines_for_display-1):
self.lcd.putstr(f"{chr(5)} {self.menu_items[self.current_selection+i+1][0][0:lw-4]}")
self.lcd.putstr(" " * ((lw-4)-len(self.menu_items[self.current_selection+i+1][0][0:lw-4]))) # fit the line
# check if it's the last line being drawn...
if (i+1) == (lines_for_display-1):
if self.current_selection == (len(self.menu_items)-1) and not self.cycle: # last item selected and no cycling
self.lcd.putstr(" ")
else:
self.lcd.putstr(" " + chr(1))
else: # else: if it's not the last line, leave the last two columns of the line empty
self.lcd.putstr(" ")
def previous_selection(self):
self.current_selection -= 1
if self.current_selection < 0: # after the last element:
if self.cycle: # if cycling is enabled, set it to the index of the last element
self.current_selection = len(self.menu_items)-1
else: # else, go to first element
self.current_selection = 0
def next_selection(self):
self.current_selection += 1
if self.current_selection >= len(self.menu_items): # after the last element:
if self.cycle: # if cycling is enabled, go to first element
self.current_selection = 0
else: # else, set it to the index of the last element
self.current_selection = len(self.menu_items)-1
def execute_selection(self):
selection = self.menu_items[self.current_selection]
lw = self.lcd.num_columns
# if the program executed had no display (so the user notices something happens!)
self.lcd.move_to(0,0)
if self.lcd.num_lines == 4:
self.lcd.putstr(f"{self.fill_char*lw}{' '*lw*2}{self.fill_char*lw}") # fill the first and last line with 'fill_char's
self.lcd.move_to(0,1) # move to the second line for the starting message below (takes two lines)
self.lcd.putstr(f"[{selection[0][0:lw-2].center(lw-2)}]{self.start_execution_msg[0:lw].center(lw)}")
sleep(self.start_execution_wait) # wait some time before execution (so that the text can be read)
# run the program
return_value = selection[1]()
# show a exit when there's no specific return value
if not return_value: # if the return value is None / nothing was returned -> show a closing message
while self.ok_btn.value() == 1: time.sleep(self.debounce_time) # wait till ok_btn release (e.g. if the "program" is a simple send action)
self.lcd.move_to(0,0)
if self.lcd.num_lines == 4:
self.lcd.putstr(f"{self.fill_char*lw}{' '*lw*2}{self.fill_char*lw}") # fill the first and last line with 'fill_char's
self.lcd.move_to(0,1) # move to the second line for the starting message below (takes two lines)
self.lcd.putstr(f"[{selection[0][0:lw].center(lw-2)}]{self.end_execution_msg[0:lw].center(lw)}")
sleep(self.end_execution_wait)
self.show_selection()
else: # -> show no message and quit directly back into the lcdMenu
self.show_selection()
# listen for button presses (this method should be called in an endless loop, see method run)
def loop(self):
if self.prev_btn:
if self.prev_btn.value() == 1:
self.previous_selection()
self.show_selection()
while self.prev_btn.value() == 1: sleep(self.debounce_time) # wait till release
if self.next_btn.value() == 1:
self.next_selection()
self.show_selection()
while self.next_btn.value() == 1: sleep(self.debounce_time) # wait till release
if self.ok_btn.value() == 1:
while self.ok_btn.value() == 1: sleep(self.debounce_time) # wait till release
self.execute_selection()
def stop(self): # here to act as a callback for a menu entry (if the user wants to ofc!)
self.running = False
return True
def run(self):
# show the selection first
self.show_selection()
self.running = True
# then listen on button presses in a loop...
while self.running:
self.loop()
return True # to prevent a "Closing menu ..." in submenu-situations

84
main.py
View File

@@ -10,61 +10,93 @@ You should have received a copy of the GNU General Public License along with thi
"""
import config, utils
from lib.ProgramChooserAdapted import ProgramChooser
import utils
from lcdMenu import lcdMenu
from WelcomeScreen import WelcomeScreen
from time import sleep
import gc # garbage collector for better memory performance
from gc import collect # garbage collector for better memory performance
config = utils.Config()
btn_mapping = {"ok_btn": config.PIN_IN_BTN_1, "next_btn": config.PIN_IN_BTN_2} # the btn mapping for all lcdMenus
# extra functions to access the garbage collector
def manual():
config.LCD.clear()
set_value = config.RELAIS.value()
set_value = config.PIN_OUT_RELAIS.value()
config.LCD.putstr(f"---- MANUAL ---- State: {set_value} ")
while True:
if set_value != config.SWITCH.value():
config.RELAIS.value(config.SWITCH.value())
set_value = config.RELAIS.value()
if set_value != config.PIN_IN_SWITCH.value():
config.PIN_OUT_RELAIS.value(config.PIN_IN_SWITCH.value())
set_value = config.PIN_OUT_RELAIS.value()
config.LCD.putstr(f"---- MANUAL ---- State: {set_value} ")
if config.BTN_1.value() == 1 or config.BTN_2.value() == 1:
return # exit on press of these buttons
if config.PIN_IN_BTN_1.value() == 1 or config.PIN_IN_BTN_2.value() == 1:
return True # exit on press of these buttons
def timer():
# display WIP
config.LCD.clear()
config.LCD.putstr(" Still work-in-progress")
sleep(3)
return True # disable the "Quitting" message from lcdMenu
def uv_on():
config.RELAIS.value(1)
config.LCD.clear()
config.LCD.putstr(" UV turned on ")
config.LCD.putstr("------ UV ------ turned on ")
sleep(1)
return True # disable the "Quitting" message from lcdMenu
def uv_off():
config.RELAIS.value(0)
config.LCD.clear()
config.LCD.putstr("------ UV ------ turned off ")
sleep(1)
return True # disable the "Quitting" message from lcdMenu
def lcd_big_hello():
import lcd_big_hello
lcd_big_hello.run()
gc.collect()
import lcd_big_hello as lbh
lbh.run()
del lbh
collect()
return True
def input_tests():
import input_tests as input_tests
input_tests.run(serial_output=False)
collect()
return True
def settings():
# display WIP
config.LCD.clear()
config.LCD.putstr(" Still work-in-progress")
sleep(3)
return True
def run_demo_menu():
demo_menu = lcdMenu(config.LCD, btn_mapping, scroll_direction=True, cycle=True, hide_menu_name=False, name="DEMOS")
demo_programs = [("LCD Demo", lcd_big_hello),
("Input tests", input_tests),
("Exit", demo_menu.stop)]
demo_menu.setup(demo_programs) # give it the callback list
ret = demo_menu.run()
del demo_menu, demo_programs
collect()
return ret
# create a programs dict, where the items are callables (functions)
programs = {
"Settings": settings,
"LCD Demo": lcd_big_hello,
"UV off": uv_off,
"UV on": uv_on,
"Timer": timer,
"Manual": manual,
}
if config.STARTUP_WELCOME_SHOW:
ws = WelcomeScreen(config.LCD,
interrupt_pins=[config.PIN_IN_BTN_1, config.PIN_IN_BTN_2, config.PIN_IN_SWITCH],
subtitle=config.STARTUP_PROJECT_NAME,
starting_msg=config.STARTUP_MESSAGE_STARTING,
started_msg=config.STARTUP_MESSAGE_FINISHED)
ws.show(cycles=config.STARTUP_WELCOME_CYCLES)
del ws
collect()
# create the menus
main_menu = lcdMenu(config.LCD, btn_mapping, scroll_direction=True, cycle=True, hide_menu_name=False, name="PROGRAMS")
main_programs = [("Timer", timer),
("Manual", manual),
("UV off", uv_off),
("UV on", uv_on),
("Demos", run_demo_menu),
("Settings", settings)]
main_menu.setup(main_programs) # give it the callback list
if config.STARTUP_WELCOME_SHOW: utils.show_welcome()
pc = ProgramChooser(programs) # initialize the ProgramChooser
pc.run() # and run it (will be an endless loop)
# and run the main menu (will be an endless loop)
main_menu.run()

135
utils.py
View File

@@ -9,9 +9,106 @@ This program is distributed in the hope that it will be useful, but WITHOUT ANY
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from gc import collect
import config as cfg
from time import sleep
"""
A small wrapper class as storing machine.Pin, LCD and machine.I2C objects in a file is not that cool :)
Now only pin numbers and strings etc. are stored on the uC, and the complex objects are generated on the fly
"""
class Config:
"""
The initializer method
- config_file: the path to the config file laying on the uC
"""
def __init__(self, config_file: str = "config.json"):
self._attr_list = ["LOG_LEVEL", # there are three log levels: warn (0), info (1), debug (2)
# this value defines which log messages to show
# e.g. 2 means show [debug], [warn] and [info] messages
"STARTUP_WELCOME_SHOW", # show the name and a startup message
"STARTUP_PROJECT_NAME", # the name to show at startup
"STARTUP_MESSAGE_STARTING", # the message to show at startup
"STARTUP_MESSAGE_FINISHED", # the message to show at startup
"STARTUP_WELCOME_CYCLES", # how often shall "Starting..." run over the screen
"PIN_IN_BTN_1", # input of the first btn
"PIN_IN_BTN_2", # input of the second btn
"PIN_IN_SWITCH", # input of the switch
"PIN_OUT_RELAIS", # where the relais is connected (for the UV lights)
"PIN_SDA", # just some standard I2C serial data (SDA) output
"PIN_SCL", # just some standard I2C serial clock (SCL) output
"LCD_I2C_CH", # where the relais is connected (for the UV lights)
"LCD_I2C_ADDR", # the i2c adress of the display
"LCD_I2C_NUM_ROWS", # how many rows for character display has the display?
"LCD_I2C_NUM_COLS", # and how many characters can it display per row?
"LCD"] # the actual lcd object (of the PCF8574T I2C_LCD class, see libraries)
self._config_file = config_file
self.load_config()
def load_config(self):
# prepare the class
with open(self._config_file, "r") as f:
from json import load
self._config = load(f)
del load
collect()
def save_config(self):
with open(self._config_file, "w") as f:
from json import dump
dump(self._config, f)
del dump
collect()
def __getattr__(self, name):
if name.startswith("_"): # make private attributes unaccessible
raise AttributeError(f"'Access to the private attribute '{name}' of the object '{self.__class__.__name__}' is forbidden")
elif name in self._attr_list: # valid attributes (only capital letters and -_ etc. are allowed)
try:
# now some if statements to check if the lcd or some pin object is asked
if name.startswith("PIN_"):
from machine import Pin
if name.startswith("PIN_IN"):
if self._config[name]["pull"].lower() == "down":
p = Pin(self._config[name]["pin"], Pin.IN, Pin.PULL_DOWN)
elif self._config[name]["pull"].lower() == "up":
p = Pin(self._config[name]["pin"], Pin.IN, Pin.PULL_UP)
else:
p = Pin(self._config[name]["pin"], Pin.IN)
elif name.startswith("PIN_OUT"):
p = Pin(self._config[name], Pin.OUT)
else:
p = Pin(self._config[name])
del Pin
collect()
return p
elif name == "LCD":
try:
return self._lcd
except:
from machine import I2C, Pin
from PCF8574T import I2C_LCD
self._lcd = I2C_LCD(I2C(self.LCD_I2C_CH, sda=self.PIN_SDA, scl=self.PIN_SCL, freq=400000),
self.LCD_I2C_ADDR,
self.LCD_I2C_NUM_ROWS,
self.LCD_I2C_NUM_COLS)
del I2C, Pin, I2C_LCD
collect()
return self._lcd
return self._config[name]
except KeyError:
raise AttributeError(f"Attribute '{name}' does not exist in the config file '{self._config_file}'")
else:
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
def __setattr__(self, name, value):
#print(f"Someone tried to edit my poor attributes! Affected: '{name}' should be set to '{value}'")
object.__setattr__(self, name, value)
def __delattr__(self, name):
raise AttributeError(f"You may not delete any attribute of the '{self.__class__.__name__}' object")
cfg = Config()
"""
@@ -27,37 +124,3 @@ def log(log_level: int, message: str):
elif cfg.LOG_LEVEL >= log_level: # if log level is valid
print(f"[{log_mapping[log_level]}] {message}")
"""
Simple function that displays a startup "welcome" screen
Configurable in config.py
"""
def show_welcome(): # cycles says how often the startup text goes through
cycles = cfg.STARTUP_WELCOME_CYCLES
if cycles < 1:
cycles = 1
padding = " "*cfg.LCD_I2C_NUM_COLS
started_str = cfg.STARTUP_MESSAGE_FINISHED
starting_str = cfg.STARTUP_MESSAGE_STARTING
# slide the first line over the display (animated from right to left)
for i in range(cycles):
line1 = padding + starting_str + padding
line2 = cfg.STARTUP_PROJECT_NAME
for i in range(cfg.LCD_I2C_NUM_COLS + len(starting_str)):
cfg.LCD.putstr(line1[0:16])
cfg.LCD.move_to(0,1)
cfg.LCD.putstr(line2[0:16])
line1 = line1[1:]
cfg.LCD.move_to(0,0)
cfg.LCD.putstr(started_str)
# now fade down
sleep(2)
cfg.LCD.move_to(0,0)
cfg.LCD.putstr(padding + started_str)
sleep(0.1)
cfg.LCD.clear()