import utime
from machine import Pin, I2C
import ssd1306
#
# =======================
# Pin Definitions
# =======================
#
ENCODER_CLK = 2 # GP2
ENCODER_DT = 3 # GP3
ENCODER_SW = 4 # GP4
I2C_SDA_PIN = 0 # GP0
I2C_SCL_PIN = 1 # GP1
#
# =======================
# I2C + Display Setup
# =======================
#
i2c = I2C(0, sda=Pin(I2C_SDA_PIN), scl=Pin(I2C_SCL_PIN), freq=400_000)
display = ssd1306.SSD1306_I2C(128, 64, i2c)
#
# =======================
# Rotary Encoder Pins
# =======================
#
clk_pin = Pin(ENCODER_CLK, Pin.IN, Pin.PULL_UP)
dt_pin = Pin(ENCODER_DT, Pin.IN, Pin.PULL_UP)
sw_pin = Pin(ENCODER_SW, Pin.IN, Pin.PULL_UP)
#
# =======================
# Screen (Mode) States
# =======================
#
GRINDER = 0
STOVE = 1
STOVE_TIMER = 2
EXTRACTION_TIMER = 3
STATS_SCREEN = 4
mode = GRINDER
#
# =======================
# User Settings
# =======================
#
grinder_setting = 0
stove_setting = 0
#
# =======================
# Timers (Stove/Extraction)
# =======================
#
stove_timer_running = False
stove_start_time_ms = 0
stove_duration_s = 0.0 # final result in seconds
extraction_timer_running = False
extraction_start_time_ms = 0
extraction_duration_s = 0.0 # final result in seconds
#
# For detecting rotary encoder falling edge
#
prev_clk_value = clk_pin.value()
#
# For button debounce
#
last_button_press_ms = 0
DEBOUNCE_MS = 300
#
# =======================
# Helper Functions
# =======================
#
def clamp(value, min_val=0, max_val=100):
"""Clamp an integer 'value' to the range [min_val, max_val]."""
return max(min_val, min(value, max_val))
def stoveAdvice(duration_s):
"""
Provide advice based on the stove timer result.
Target is between 3 and 6 minutes (180–360 seconds).
"""
if duration_s < 180:
return "Reduce heat"
elif duration_s > 360:
return "Increase heat"
else:
return "Dope"
def extractionAdvice(duration_s):
"""
Provide advice based on the extraction timer result.
Target is between 27 and 33 seconds.
"""
if duration_s < 27:
return "Grind finer"
elif duration_s > 33:
return "Grind coarser"
else:
return "Nailed"
def setMode(new_mode):
"""Perform any setup when entering a new mode, then update the display."""
global mode
global stove_timer_running, stove_start_time_ms
global extraction_timer_running, extraction_start_time_ms
mode = new_mode
# If we enter STOVE_TIMER or EXTRACTION_TIMER, start automatically
if mode == STOVE_TIMER:
stove_timer_running = True
stove_start_time_ms = utime.ticks_ms()
elif mode == EXTRACTION_TIMER:
extraction_timer_running = True
extraction_start_time_ms = utime.ticks_ms()
updateDisplay()
def nextMode():
"""Go to the next screen in the sequence, wrapping after STATS_SCREEN."""
new_mode = (mode + 1) % 5 # 5 total screens
setMode(new_mode)
def updateValue(delta):
"""
If we're on the GRINDER or STOVE screen, adjust the value by 'delta',
clamped between 0 and 100.
"""
global grinder_setting, stove_setting
if mode == GRINDER:
grinder_setting = clamp(grinder_setting + delta)
elif mode == STOVE:
stove_setting = clamp(stove_setting + delta)
def updateDisplay():
"""Render text to the SSD1306 display according to the current mode."""
display.fill(0)
if mode == GRINDER:
display.text("GRINDER:", 0, 0)
display.text(str(grinder_setting), 0, 16)
# Show last extraction advice
adv = extractionAdvice(extraction_duration_s)
display.text(adv, 0, 32)
elif mode == STOVE:
display.text("STOVE:", 0, 0)
display.text(str(stove_setting), 0, 16)
# Show last stove advice
adv = stoveAdvice(stove_duration_s)
display.text(adv, 0, 32)
elif mode == STOVE_TIMER:
display.text("STOVE TIMER", 0, 0)
if stove_timer_running:
elapsed_ms = utime.ticks_ms() - stove_start_time_ms
elapsed_s = elapsed_ms / 1000.0
display.text("{:.1f}s".format(elapsed_s), 0, 16)
elif mode == EXTRACTION_TIMER:
display.text("EXTRACTION", 0, 0)
if extraction_timer_running:
elapsed_ms = utime.ticks_ms() - extraction_start_time_ms
elapsed_s = elapsed_ms / 1000.0
display.text("{:.1f}s".format(elapsed_s), 0, 16)
elif mode == STATS_SCREEN:
# 4 lines: stove time, stove advice, extraction time, extraction advice
stove_line = "Stove: {:.1f}s".format(stove_duration_s)
display.text(stove_line, 0, 0)
stove_adv = stoveAdvice(stove_duration_s)
display.text(stove_adv, 0, 16)
extr_line = "Extr.: {:.1f}s".format(extraction_duration_s)
display.text(extr_line, 0, 32)
extr_adv = extractionAdvice(extraction_duration_s)
display.text(extr_adv, 0, 48)
display.show()
#
# =======================
# Initial Display
# =======================
#
updateDisplay()
#
# =======================
# Main Loop
# =======================
#
while True:
# 1) Check button press (active-low)
if sw_pin.value() == 0:
now_ms = utime.ticks_ms()
if (now_ms - last_button_press_ms) > DEBOUNCE_MS:
last_button_press_ms = now_ms
if mode == GRINDER or mode == STOVE:
nextMode()
elif mode == STOVE_TIMER:
# Stop stove timer, record final duration, then next
stove_timer_running = False
elapsed_ms = utime.ticks_ms() - stove_start_time_ms
stove_duration_s = elapsed_ms / 1000.0
nextMode()
elif mode == EXTRACTION_TIMER:
# Stop extraction timer, record final duration, then next
extraction_timer_running = False
elapsed_ms = utime.ticks_ms() - extraction_start_time_ms
extraction_duration_s = elapsed_ms / 1000.0
nextMode()
elif mode == STATS_SCREEN:
nextMode() # Back to Grinder
updateDisplay()
# 2) Check rotary encoder rotation (CLK falling edge)
current_clk_value = clk_pin.value()
if (current_clk_value != prev_clk_value) and (current_clk_value == 0):
# Only adjust a value if on GRINDER or STOVE
if mode in (GRINDER, STOVE):
if dt_pin.value() == 1:
# dt=HIGH => clockwise => +1
updateValue(+1)
else:
# dt=LOW => counterclockwise => -1
updateValue(-1)
updateDisplay()
prev_clk_value = current_clk_value
# 3) If a timer is running, update the display so the user sees real-time progress
if mode == STOVE_TIMER and stove_timer_running:
updateDisplay()
elif mode == EXTRACTION_TIMER and extraction_timer_running:
updateDisplay()
# 4) Small delay to avoid missing fast encoder steps
utime.sleep_ms(2)