import machine
import time
import json
import random
from pico_i2c_lcd import I2cLcd
# --- HARDWARE DEFINITIONS ---
I2C_SDA_PIN = 0
I2C_SCL_PIN = 1
SSR_PUMP_PIN = 16
BT_LED_PIN = 21
SCALE_ADC_PIN = 26
# Initialize I2C bus for the HD44780 LCD Controller
i2c = machine.I2C(0, sda=machine.Pin(I2C_SDA_PIN), scl=machine.Pin(I2C_SCL_PIN), freq=400000)
# Connect to the LCD
try:
devices = i2c.scan()
print("Diagnostic: I2C scan found devices at:", devices)
if len(devices) == 0:
print("CRITICAL: LCD not found on I2C bus. Check wiring.")
while True:
pass
I2C_ADDR = 0x27
lcd = I2cLcd(i2c, I2C_ADDR, 2, 16)
except Exception as e:
print("LCD Initialization Error:", e)
while True:
pass
# Output Pins
ssr_pump = machine.Pin(SSR_PUMP_PIN, machine.Pin.OUT)
bt_conn_led = machine.Pin(BT_LED_PIN, machine.Pin.OUT)
# 4x4 Keypad Setup
row_pins = [machine.Pin(i, machine.Pin.OUT) for i in range(2, 6)]
col_pins = [machine.Pin(i, machine.Pin.IN, machine.Pin.PULL_UP) for i in range(6, 10)]
key_map = [
['1', '2', '3', 'A'],
['4', '5', '6', 'B'],
['7', '8', '9', 'C'],
['*', '0', '#', 'D']
]
# Simulated Acaia Lunar Scale
scale_pot = machine.ADC(SCALE_ADC_PIN)
# --- NON-VOLATILE STORAGE (CONFIG) ---
CONFIG_FILE = "brew_settings.json"
cfg = {
"dose": 20.5,
"ratio": 2.0,
"buffer": 2.0
}
def load_config():
"""Loads saved brew parameters from the Pico 2W's internal flash storage."""
global cfg
try:
with open(CONFIG_FILE, "r") as f:
cfg = json.load(f)
except Exception:
save_config()
def save_config():
"""Saves current brew parameters to the Pico 2W's internal flash storage."""
try:
with open(CONFIG_FILE, "w") as f:
json.dump(cfg, f)
except Exception as e:
print("Error saving config:", e)
# --- IDLE MESSAGE CONFIGURATION ---
IDLE_TIMEOUT_MS = 120000 # 2 Minutes as per v0.43/v0.51 specification
# Explicit Dictionary Structure to prevent parsing exceptions and memory buffer overflow
RANDOM_MESSAGES = [
# Single-Stage Humorous Messages
{"type": "single", "lines": ("Error 404:", "Coffee not found")},
{"type": "single", "lines": ("Caffeine level:", "CRITICAL [!!]")},
{"type": "single", "lines": ("Insert beans", "to continue...")},
{"type": "single", "lines": ("Loading sanity..", "Please standby..")},
{"type": "single", "lines": ("Wake up human,", "I am brewing...")},
{"type": "single", "lines": ("Depresso?", "Have espresso!")},
{"type": "single", "lines": ("Life begins", "after espresso")},
{"type": "single", "lines": ("Decaf coffee?", "Does not compute")},
{"type": "single", "lines": ("First caffeine,", "then I do things")},
{"type": "single", "lines": ("Stand back!", "High pressure")},
{"type": "single", "lines": ("Beans: Crushed.", "Human: Saved.")},
{"type": "single", "lines": ("99 problems but", "espresso fix 0")},
{"type": "single", "lines": ("Grind, tamp,", "pull, repeat.")},
{"type": "single", "lines": ("Today's forecast", "100% caffeinated")},
{"type": "single", "lines": ("Procuring the", "magic bean juice")},
{"type": "single", "lines": ("Step away from", "the portafilter")},
{"type": "single", "lines": ("Sleep deprivation", "Counter-measure")},
{"type": "single", "lines": ("Pure adrenaline", "incoming...")},
{"type": "single", "lines": ("Liquid therapy", "is now dispensing")},
{"type": "single", "lines": ("Brain.exe:", "Rebooting now...")},
{"type": "single", "lines": ("Press buttons for", "instant happy")},
{"type": "single", "lines": ("Tamping pressure:", "MAXIMUM FORCE")},
{"type": "single", "lines": ("Survival juice", "extraction active")},
{"type": "single", "lines": ("10 bars of pure", "motivation")},
{"type": "single", "lines": ("Hug in a mug", "under construction")},
# Two-Stage Jokes and Statements
{"type": "two_stage", "stage1": ("What do you call", "sad coffee?"), "stage2": ("Depresso.", "")},
{"type": "two_stage", "stage1": ("How does coffee", "say goodbye?"), "stage2": ("Bean there,", "done that!")},
{"type": "two_stage", "stage1": ("What is a cow's", "favorite drink?"), "stage2": ("De-calf", "lattes.")},
{"type": "two_stage", "stage1": ("What do you call", "a coffee thief?"), "stage2": ("A mug-ger!", "")},
{"type": "two_stage", "stage1": ("Hey you!", "Yeah you!"), "stage2": ("Want some coffee", "You know you do")},
{"type": "two_stage", "stage1": ("* SNIFF SNIFF *", "Smells amazing.."), "stage2": ("Fresh espresso", "is ready now!")},
{"type": "two_stage", "stage1": ("Wake up!", "Rise and grind!"), "stage2": ("Your mug is", "feeling empty.")},
{"type": "two_stage", "stage1": ("Human detector:", "SYS LOW ENERGY"), "stage2": ("Insert caffeine", "to reboot system")},
{"type": "two_stage", "stage1": ("Stop scrolling!", "Look over here!"), "stage2": ("A perfect latte", "awaits you...")},
{"type": "two_stage", "stage1": ("Tired? Sluggish?", "Brain asleep?"), "stage2": ("Liquid energy", "is now brewing.")},
{"type": "two_stage", "stage1": ("Psst... hey...", "Come closer..."), "stage2": ("I can make you", "an espresso now")},
{"type": "two_stage", "stage1": ("NOTICE:", "Mandatory break!"), "stage2": ("You deserve a", "coffee reward.")},
{"type": "two_stage", "stage1": ("Dream big.", "Work hard."), "stage2": ("But first...", "Drink coffee!")},
{"type": "two_stage", "stage1": ("Your attention", "please!"), "stage2": ("Freshly ground", "and ready to go")},
{"type": "two_stage", "stage1": ("Need a spark?", "A little push?"), "stage2": ("This cup will", "power your day.")},
{"type": "two_stage", "stage1": ("Look at me...", "Just look..."), "stage2": ("Smells like", "pure happiness.")},
{"type": "two_stage", "stage1": ("Hello boss!", "Ready to rule?"), "stage2": ("Take this cup", "of liquid gold.")},
{"type": "two_stage", "stage1": ("Current mood:", "Sub-optimal."), "stage2": ("Espresso will", "fix everything.")}
]
# --- FUNCTIONS ---
def read_keypad():
"""Scans the matrix keypad. Returns the key string or None."""
for row_idx, row in enumerate(row_pins):
row.value(0)
for col_idx, col in enumerate(col_pins):
if col.value() == 0:
row.value(1)
return key_map[row_idx][col_idx]
row.value(1)
return None
def get_numeric_input(prompt):
"""Handles keypad input for floating point values mapping to the v0.43/0.51 Value Entry sub-state rules."""
# Wait for the user to release the menu key before proceeding
while read_keypad() is not None:
pass
lcd.clear()
lcd.putstr(prompt[:16])
lcd.move_to(0, 1)
val_str = ""
while True:
key = read_keypad()
if key:
if key == '#': # Accept and Save changes
break
elif key == 'C': # Cancel
lcd.clear()
lcd.putstr("Oper canceled,")
lcd.move_to(0, 1)
lcd.putstr("without changes.")
time.sleep(2.0)
return None
elif key == 'D': # Delete entire line
val_str = ""
lcd.move_to(0, 1)
lcd.putstr(" ") # Push blanks to wipe the physical LCD memory
lcd.move_to(0, 1)
elif key == 'B': # Backspace / Delete single character
if len(val_str) > 0:
val_str = val_str[:-1] # Drop the trailing character via string slicing
lcd.move_to(0, 1)
lcd.putstr(" ")
lcd.move_to(0, 1)
lcd.putstr(val_str)
elif key == '*': # Decimal Point
if "." not in val_str:
val_str += "."
lcd.putchar(".")
elif key in "0123456789":
if len(val_str) < 16: # Protect LCD memory buffer overflow
val_str += key
lcd.putchar(key)
time.sleep(0.25) # Button debounce
try:
return float(val_str) if val_str else 0.0
except ValueError:
return 0.0
def get_simulated_weight():
"""Maps the 16-bit ADC value to a simulated scale reading."""
raw_adc = scale_pot.read_u16()
return (raw_adc / 65535.0) * 100.0
def display_instructions():
"""Cycles through the instruction menu until completed."""
instructions = [
("#: Pull Shot", ""),
(f"A: Bean Dose", f"(curr: {cfg['dose']}g)"),
(f"B: Brew Ratio", f"(curr: {cfg['ratio']})"),
(f"C: End Buffer", f"(curr: {cfg['buffer']}g)"),
("D: Pair BT scale", "")
]
for line1, line2 in instructions:
lcd.clear()
lcd.putstr(line1[:16])
if line2:
lcd.move_to(0, 1)
lcd.putstr(line2[:16])
time.sleep(2.0)
def trigger_random_message():
"""Selects a random message and explicitly unpacks tuples into strings before writing to LCD."""
msg = random.choice(RANDOM_MESSAGES)
if msg["type"] == "two_stage":
s1_line1, s1_line2 = msg["stage1"]
s2_line1, s2_line2 = msg["stage2"]
lcd.clear()
lcd.putstr(s1_line1[:16])
if s1_line2:
lcd.move_to(0, 1)
lcd.putstr(s1_line2[:16])
time.sleep(5.0)
lcd.clear()
lcd.putstr(s2_line1[:16])
if s2_line2:
lcd.move_to(0, 1)
lcd.putstr(s2_line2[:16])
time.sleep(5.0)
else:
line1, line2 = msg["lines"]
lcd.clear()
lcd.putstr(line1[:16])
if line2:
lcd.move_to(0, 1)
lcd.putstr(line2[:16])
time.sleep(5.0)
# --- MAIN AUTOMATION LOGIC ---
def main():
# Hardware initialization: Ensure the pump is OFF (relay unpowered)
ssr_pump.value(0)
bt_conn_led.value(0)
load_config()
is_bt_connected = False
while True:
# Default Ready State
lcd.clear()
lcd.putstr("Ready. Press *")
lcd.move_to(0, 1)
lcd.putstr("for instr")
last_interaction = time.ticks_ms()
cmd = None
# Non-blocking polling loop
while not cmd:
cmd = read_keypad()
if cmd:
break
# Trigger idle message if timeout is reached
current_time = time.ticks_ms()
if time.ticks_diff(current_time, last_interaction) > IDLE_TIMEOUT_MS:
trigger_random_message()
# Restore the UI after the joke finishes
lcd.clear()
lcd.putstr("Ready. Press *")
lcd.move_to(0, 1)
lcd.putstr("for instr")
last_interaction = time.ticks_ms() # Reset the timer
time.sleep(0.05)
time.sleep(0.3) # Debounce the command key
# Process User Command
if cmd == '*':
display_instructions()
elif cmd == 'A':
new_val = get_numeric_input("Enter bean dose:")
if new_val is not None:
cfg['dose'] = new_val
save_config()
elif cmd == 'B':
new_val = get_numeric_input("Enter brew ratio:")
if new_val is not None:
cfg['ratio'] = new_val
save_config()
elif cmd == 'C':
new_val = get_numeric_input("Enter buffer (g):")
if new_val is not None:
cfg['buffer'] = new_val
save_config()
elif cmd == 'D':
lcd.clear()
lcd.putstr("Pairing Acaia...")
time.sleep(1.5)
is_bt_connected = True
bt_conn_led.value(1)
elif cmd == '#':
if not is_bt_connected:
lcd.clear()
lcd.putstr("Check BT")
lcd.move_to(0, 1)
lcd.putstr("connection")
time.sleep(2.0)
continue
current_weight = get_simulated_weight()
if current_weight > 0.5:
lcd.clear()
lcd.putstr("Tare scale first")
time.sleep(2.0)
continue
# Shot Execution State
display_target = cfg['dose'] * cfg['ratio']
trigger_target = display_target - cfg['buffer']
lcd.clear()
# Hardware specific update:
# Because it is wired to the Normally Open (NO) pin, we must supply power (HIGH/1)
# to energize the coil, closing the circuit and allowing the pump to run.
ssr_pump.value(1)
try:
while True:
live_weight = get_simulated_weight()
lcd.move_to(0, 0)
if live_weight > 0.0:
lcd.putstr("Pulling Shot... ")
else:
lcd.putstr("Pull Shot... ")
lcd.move_to(0, 1)
lcd.putstr(f"Wt: {live_weight:4.1f}/{display_target:4.1f}g ")
if live_weight >= trigger_target:
# TARGET REACHED: Drop power (LOW/0) to the relay.
# This springs open the circuit to instantly cut the pump.
ssr_pump.value(0)
break
time.sleep(0.05)
except KeyboardInterrupt:
ssr_pump.value(0) # Absolute safety failsafe
break
# Post-Brew Summary
lcd.clear()
lcd.putstr(f"Done: {live_weight:.1f}g")
lcd.move_to(0, 1)
lcd.putstr("Enjoy! Press #")
while read_keypad() != '#':
time.sleep(0.1)
# Returning to ready state: ensure power to relay is cut off
ssr_pump.value(0)
if __name__ == '__main__':
main()Simulated
Pump (LED)
Acaia Lunar Scale
BT Conn
Pump Switch
Left = OFF