# main.py
# =============================================================================
# EcoPulse 9 - Interfaz OLED navegable de 5 pantallas
# ESP32 + SSD1306 128x64 + DHT + LDR + 2 botones + 2 LEDs
#
# Archivos que deben estar en el ESP32:
# main.py
# ssd1306.py
# oled_campus_hud.py
#
# Navegacion:
# Boton ROJO - GPIO13 - anterior (activo en HIGH / pull-down)
# Boton VERDE - GPIO14 - siguiente (activo en LOW / pull-up)
#
# Mapa de pines:
# DHT -> GPIO15
# LDR / ADC -> GPIO34
# OLED SCL -> GPIO18
# OLED SDA -> GPIO19
# LED rojo -> GPIO32 Wokwi / GPIO26 montaje fisico definitivo
# LED verde -> GPIO33 Wokwi / GPIO25 montaje fisico definitivo
# =============================================================================
from machine import Pin, I2C, ADC
from utime import sleep_ms, ticks_ms, ticks_diff
import dht
from ssd1306 import SSD1306_I2C
import oled_campus_hud as campus_ui
# =============================================================================
# 1) CONFIGURACION GENERAL
# =============================================================================
# Para el montaje definitivo de la imagen use "PHYSICAL".
# Para la simulacion adjunta de Wokwi use "WOKWI".
MODE = "WOKWI" # "WOKWI" | "PHYSICAL"
DHT_READ_INTERVAL_MS = 2000 # El DHT necesita tiempo entre lecturas
LDR_READ_INTERVAL_MS = 1500
FRAME_INTERVAL_MS = 90 # ~11 FPS para animaciones suaves
BUTTON_DEBOUNCE_MS = 180
LED_FEEDBACK_MS = 140
ADC_MAX_VALUE = 4095
LDR_INVERTED = False # Cambiar a True si luz/oscuridad queda invertida
LDR_SAMPLES = 8
# El montaje con LDR no es un luxometro calibrado.
# Este valor SOLO crea una escala visual compatible con oled_campus_hud.py.
# Pantalla 4 muestra el dato correcto como porcentaje relativo + ADC.
HUD_LUX_VISUAL_MAX = 500
STATION_ID = "EP9"
TOTAL_SCREENS = 5
# =============================================================================
# 2) MAPA DE PINES
# =============================================================================
DHT_GPIO = 15
LDR_ADC_GPIO = 34
SCL_PIN_GPIO = 18
SDA_PIN_GPIO = 19
# Boton rojo: pulldown, pulsado = 1
BUTTON_BACK_GPIO = 13
BUTTON_BACK_ACTIVE = 1
# Boton verde: pullup, pulsado = 0
BUTTON_NEXT_GPIO = 14
BUTTON_NEXT_ACTIVE = 0
# En las dos imagenes adjuntas cambia solamente el par de GPIO de los LEDs.
if MODE == "WOKWI":
RED_LED_GPIO = 32
GREEN_LED_GPIO = 33
else:
RED_LED_GPIO = 26
GREEN_LED_GPIO = 25
# =============================================================================
# 3) OLED / I2C
# =============================================================================
OLED_W = 128
OLED_H = 64
i2c = I2C(
0,
scl=Pin(SCL_PIN_GPIO),
sda=Pin(SDA_PIN_GPIO),
freq=400000
)
# =============================================================================
# 4) DHT
# =============================================================================
if MODE == "PHYSICAL":
sensor_dht = dht.DHT11(Pin(DHT_GPIO))
else:
sensor_dht = dht.DHT22(Pin(DHT_GPIO))
# =============================================================================
# 5) LDR
# =============================================================================
ldr = ADC(Pin(LDR_ADC_GPIO))
try:
ldr.atten(ADC.ATTN_11DB)
except Exception:
pass
try:
ldr.width(ADC.WIDTH_12BIT)
except Exception:
pass
# =============================================================================
# 6) BOTONES Y LEDs
# =============================================================================
button_back = Pin(BUTTON_BACK_GPIO, Pin.IN, Pin.PULL_DOWN)
button_next = Pin(BUTTON_NEXT_GPIO, Pin.IN, Pin.PULL_UP)
led_red = Pin(RED_LED_GPIO, Pin.OUT)
led_green = Pin(GREEN_LED_GPIO, Pin.OUT)
led_red.value(0)
led_green.value(0)
# =============================================================================
# 7) VARIABLES DE ESTADO
# =============================================================================
temperatura = None
humedad = None
dht_error = None
ldr_raw = 0
ldr_pct = 0
ldr_error = None
screen = 0
last_dht_ms = ticks_ms() - DHT_READ_INTERVAL_MS
last_ldr_ms = ticks_ms() - LDR_READ_INTERVAL_MS
last_frame_ms = ticks_ms() - FRAME_INTERVAL_MS
last_button_ms = ticks_ms() - BUTTON_DEBOUNCE_MS
last_back_value = button_back.value()
last_next_value = button_next.value()
feedback_started_ms = ticks_ms()
feedback_active = False
# =============================================================================
# 8) FUNCIONES DE SENSORES
# =============================================================================
def read_dht():
try:
sensor_dht.measure()
return sensor_dht.temperature(), sensor_dht.humidity(), None
except OSError as error:
return None, None, error
def read_ldr():
try:
total = 0
for _ in range(LDR_SAMPLES):
total += ldr.read()
sleep_ms(8)
raw = total // LDR_SAMPLES
pct = round((raw / ADC_MAX_VALUE) * 100)
if LDR_INVERTED:
pct = 100 - pct
pct = max(0, min(100, pct))
return raw, pct, None
except Exception as error:
return None, None, error
def light_state(pct):
if pct < 30:
return "BAJA"
if pct < 70:
return "MEDIA"
return "ALTA"
def visual_lux_from_pct(pct):
"""
Escala visual para alimentar el HUD inicial.
NO es una medicion fotometrica calibrada en lux.
"""
return int((max(0, min(100, pct)) / 100) * HUD_LUX_VISUAL_MAX)
# =============================================================================
# 9) HELPERS GRAFICOS
# =============================================================================
def clamp(value, low, high):
if value < low:
return low
if value > high:
return high
return value
def centered_text(fb, text, y, color=1):
text = str(text)
x = (OLED_W - len(text) * 8) // 2
if x < 0:
x = 0
fb.text(text, x, y, color)
def filled_circle(fb, cx, cy, r, color=1):
y = -r
while y <= r:
span = int((r * r - y * y) ** 0.5)
fb.hline(cx - span, cy + y, span * 2 + 1, color)
y += 1
def draw_degree(fb, x, y, color=1):
fb.pixel(x, y, color)
fb.pixel(x + 1, y, color)
fb.pixel(x, y + 1, color)
fb.pixel(x + 1, y + 1, color)
def draw_sun(fb, cx, cy, radius, now_ms):
filled_circle(fb, cx, cy, radius, 1)
pulse = (now_ms // 220) % 2
ray = radius + 3 + pulse
fb.hline(cx - ray - 3, cy, 3, 1)
fb.hline(cx + ray + 1, cy, 3, 1)
fb.vline(cx, cy - ray - 3, 3, 1)
fb.vline(cx, cy + ray + 1, 3, 1)
fb.pixel(cx - ray, cy - ray, 1)
fb.pixel(cx + ray, cy - ray, 1)
fb.pixel(cx - ray, cy + ray, 1)
fb.pixel(cx + ray, cy + ray, 1)
def draw_moon(fb, cx, cy, radius):
filled_circle(fb, cx, cy, radius, 1)
filled_circle(fb, cx + max(2, radius // 2), cy - 1, radius, 0)
def draw_cloud(fb, x, y):
filled_circle(fb, x + 7, y + 7, 5, 1)
filled_circle(fb, x + 16, y + 5, 7, 1)
filled_circle(fb, x + 27, y + 7, 5, 1)
fb.fill_rect(x + 6, y + 7, 23, 8, 1)
def draw_drop_outline(fb, cx, cy, size=9):
# Punta de la gota
fb.line(cx, cy - size, cx - size // 2, cy, 1)
fb.line(cx, cy - size, cx + size // 2, cy, 1)
# Parte redondeada inferior
filled_circle(fb, cx, cy + 3, size // 2, 1)
filled_circle(fb, cx, cy + 2, max(1, size // 2 - 2), 0)
def draw_screen_indicator(fb, current):
# Cinco puntos en la esquina inferior derecha.
x0 = 95
y = 61
for i in range(TOTAL_SCREENS):
x = x0 + i * 7
if i == current:
fb.fill_rect(x, y - 2, 4, 3, 1)
else:
fb.pixel(x + 1, y - 1, 1)
# =============================================================================
# 10) PANTALLA 2 - TEMPERATURA
# =============================================================================
def render_temperature(oled, temp, error, now_ms):
fb = oled.framebuf
fb.fill(0)
fb.text("2/5 TEMP", 0, 0, 1)
if temp is None:
centered_text(fb, "DHT ERROR", 25)
fb.text("GPIO15", 36, 41, 1)
draw_screen_indicator(fb, 1)
oled.show()
return
temp_f = float(temp)
# Termometro
tx = 19
top = 14
height = 33
fb.rect(tx, top, 10, height, 1)
filled_circle(fb, tx + 5, 50, 7, 1)
filled_circle(fb, tx + 5, 50, 4, 0)
# Escala visual 10..40 C
p = clamp((temp_f - 10.0) / 30.0, 0.0, 1.0)
mercury_h = int(28 * p)
mercury_y = top + 29 - mercury_h
fb.fill_rect(tx + 3, mercury_y, 4, mercury_h + 3, 1)
filled_circle(fb, tx + 5, 50, 3, 1)
# Marcas del termometro
for y in range(top + 4, top + 29, 6):
fb.hline(tx + 10, y, 4, 1)
# Sol animado
radius = 7 + ((now_ms // 260) % 2)
draw_sun(fb, 92, 27, radius, now_ms)
# Ondas de calor que ascienden
for i in range(3):
x = 54 + i * 13
y = 18 + ((now_ms // 130 + i * 4) % 18)
fb.pixel(x, y, 1)
fb.pixel(x + 1, y - 1, 1)
fb.pixel(x + 2, y, 1)
# Valor principal
value = "{:.1f}".format(temp_f)
fb.text(value, 55, 45, 1)
draw_degree(fb, 55 + len(value) * 8 + 1, 46, 1)
fb.text("C", 55 + len(value) * 8 + 5, 45, 1)
draw_screen_indicator(fb, 1)
oled.show()
# =============================================================================
# 11) PANTALLA 3 - HUMEDAD
# =============================================================================
def render_humidity(oled, hum, error, now_ms):
fb = oled.framebuf
fb.fill(0)
fb.text("3/5 HUMEDAD", 0, 0, 1)
if hum is None:
centered_text(fb, "DHT ERROR", 25)
fb.text("GPIO15", 36, 41, 1)
draw_screen_indicator(fb, 2)
oled.show()
return
hum_f = clamp(float(hum), 0.0, 100.0)
# Nube con movimiento horizontal de ida y vuelta
phase = (now_ms // 120) % 48
if phase <= 24:
cloud_x = 7 + phase
else:
cloud_x = 7 + (48 - phase)
cloud_y = 13 + ((now_ms // 350) % 2)
draw_cloud(fb, cloud_x, cloud_y)
# Lluvia: mas humedad => mas gotas visibles
drop_count = 1 + int(hum_f / 25)
if drop_count > 4:
drop_count = 4
fall = (now_ms // 90) % 19
for i in range(drop_count):
dx = cloud_x + 8 + i * 7
dy = cloud_y + 15 + ((fall + i * 5) % 20)
if dy < 53:
fb.pixel(dx, dy, 1)
if dy + 1 < 53:
fb.pixel(dx, dy + 1, 1)
# Gota grande a la derecha, pulsante
drop_y = 28 + ((now_ms // 300) % 2)
draw_drop_outline(fb, 100, drop_y, 11)
# Porcentaje principal
value = "{}%".format(int(hum_f))
fb.text(value, 78, 47, 1)
# Barra inferior de humedad
fb.rect(5, 55, 65, 7, 1)
fill_w = int(63 * hum_f / 100.0)
if fill_w > 0:
fb.fill_rect(6, 56, fill_w, 5, 1)
draw_screen_indicator(fb, 2)
oled.show()
# =============================================================================
# 12) PANTALLA 4 - LUZ / LUMINANCIA RELATIVA
# =============================================================================
def render_light(oled, pct, raw, error, now_ms):
fb = oled.framebuf
fb.fill(0)
fb.text("4/5 LUZ", 0, 0, 1)
if error is not None:
centered_text(fb, "LDR ERROR", 25)
fb.text("GPIO34", 36, 41, 1)
draw_screen_indicator(fb, 3)
oled.show()
return
pct = int(clamp(pct, 0, 100))
# Horizonte
fb.hline(0, 43, 128, 1)
if pct >= 55:
# A mayor iluminacion, el sol crece.
radius = 4 + int((pct - 55) * 5 / 45)
radius += (now_ms // 320) % 2
draw_sun(fb, 86, 25, radius, now_ms)
# Rayos ascendentes / energia
for i in range(4):
px = 13 + i * 12
py = 33 - ((now_ms // 110 + i * 3) % 12)
fb.pixel(px, py, 1)
fb.text("SOL", 4, 17, 1)
elif pct >= 35:
# Transicion: disco pequeno en el horizonte.
radius = 3 + ((now_ms // 350) % 2)
filled_circle(fb, 86, 38, radius, 1)
fb.text("TRANSICION", 4, 17, 1)
else:
# Poca luz: luna creciente + estrellas parpadeantes.
draw_moon(fb, 88, 25, 8)
twinkle = (now_ms // 260) % 2
stars = ((18, 17), (36, 28), (55, 15), (112, 20), (104, 34))
for i, pos in enumerate(stars):
if (i % 2) == twinkle:
fb.pixel(pos[0], pos[1], 1)
fb.text("LUNA", 4, 17, 1)
fb.text("LUZ:{}%".format(pct), 4, 47, 1)
fb.text(light_state(pct), 68, 47, 1)
# ADC en linea inferior; no se llama lux porque no hay calibracion fotometrica.
fb.text("ADC:{}".format(raw), 4, 56, 1)
draw_screen_indicator(fb, 3)
oled.show()
# =============================================================================
# 13) PANTALLA 5 - AMDG EN LOS CUATRO PUNTOS DE UNA +
# =============================================================================
def render_amdg(oled, now_ms):
fb = oled.framebuf
fb.fill(0)
cx = 64
cy = 32
# Letras alrededor de la intersección
left_x = 51
right_x = 69
top_y = 20
bottom_y = 36
# Cruz compacta
h_x0 = 47
h_x1 = 81
v_y0 = 16
v_y1 = 48
# Línea horizontal
fb.fill_rect(
h_x0,
cy - 1,
h_x1 - h_x0 + 1,
2,
1
)
# Línea vertical
fb.fill_rect(
cx - 1,
v_y0,
2,
v_y1 - v_y0 + 1,
1
)
# Intersección
fb.fill_rect(
cx - 2,
cy - 2,
4,
4,
1
)
# Letras en los cuatro cuadrantes
fb.text("A", left_x, top_y, 1)
fb.text("M", right_x, top_y, 1)
fb.text("D", left_x, bottom_y, 1)
fb.text("G", right_x, bottom_y, 1)
# Indicador de pantalla, fuera del símbolo
fb.text("5/5", 101, 54, 1)
# Animación discreta que NO modifica AMDG
if (now_ms // 450) % 2 == 0:
fb.pixel(96, 57, 1)
fb.pixel(97, 57, 1)
oled.show()
# =============================================================================
# 14) NAVEGACION / FEEDBACK
# =============================================================================
def short_transition(oled):
# Flash muy corto al cambiar de pantalla.
try:
oled.invert(1)
sleep_ms(28)
oled.invert(0)
except Exception:
pass
def start_feedback(red=False, green=False):
led_red.value(1 if red else 0)
led_green.value(1 if green else 0)
def stop_feedback():
led_red.value(0)
led_green.value(0)
# =============================================================================
# 15) INICIALIZACION OLED
# =============================================================================
print("=== EcoPulse 9 ===")
print("MODE:", MODE)
print("DHT GPIO:", DHT_GPIO)
print("LDR GPIO:", LDR_ADC_GPIO)
print("OLED SCL/SDA:", SCL_PIN_GPIO, SDA_PIN_GPIO)
print("Boton rojo/anterior GPIO:", BUTTON_BACK_GPIO)
print("Boton verde/siguiente GPIO:", BUTTON_NEXT_GPIO)
print("LED rojo GPIO:", RED_LED_GPIO)
print("LED verde GPIO:", GREEN_LED_GPIO)
print("Escaneando I2C...")
devices = i2c.scan()
print("I2C:", [hex(device) for device in devices])
if not devices:
print("ERROR: no se detecta la OLED por I2C.")
print("Revisar VCC, GND, SCL=18, SDA=19.")
while True:
led_red.value(1)
sleep_ms(250)
led_red.value(0)
sleep_ms(250)
addr = 0x3C if 0x3C in devices else devices[0]
oled = SSD1306_I2C(OLED_W, OLED_H, i2c, addr=addr)
campus_hud = campus_ui.OledCampusHUD(oled)
# Splash breve.
oled.fill(0)
centered_text(oled.framebuf, "EcoPulse 9", 10)
centered_text(oled.framebuf, "OLED CAMPUS", 27)
centered_text(oled.framebuf, "ROJO <- -> VERDE", 45)
oled.show()
sleep_ms(1200)
# =============================================================================
# 16) CICLO PRINCIPAL
# =============================================================================
while True:
now_ms = ticks_ms()
# -------------------------------------------------------------------------
# DHT: lectura cada 2 s
# -------------------------------------------------------------------------
if ticks_diff(now_ms, last_dht_ms) >= DHT_READ_INTERVAL_MS:
last_dht_ms = now_ms
new_temp, new_hum, new_error = read_dht()
dht_error = new_error
if new_error is None:
temperatura = new_temp
humedad = new_hum
print("Temp:", temperatura, "C | Hum:", humedad, "%")
else:
print("Error DHT:", new_error)
# -------------------------------------------------------------------------
# LDR: lectura cada 1.5 s
# -------------------------------------------------------------------------
if ticks_diff(now_ms, last_ldr_ms) >= LDR_READ_INTERVAL_MS:
last_ldr_ms = now_ms
new_raw, new_pct, new_error = read_ldr()
ldr_error = new_error
if new_error is None:
ldr_raw = new_raw
ldr_pct = new_pct
print(
"LDR:", ldr_raw,
"| Luz:", ldr_pct, "%",
"| Nivel:", light_state(ldr_pct)
)
else:
print("Error LDR:", new_error)
# -------------------------------------------------------------------------
# Botones con deteccion de flanco + debounce
# -------------------------------------------------------------------------
back_value = button_back.value()
next_value = button_next.value()
back_pressed = (
back_value == BUTTON_BACK_ACTIVE
and last_back_value != BUTTON_BACK_ACTIVE
)
next_pressed = (
next_value == BUTTON_NEXT_ACTIVE
and last_next_value != BUTTON_NEXT_ACTIVE
)
can_navigate = ticks_diff(now_ms, last_button_ms) >= BUTTON_DEBOUNCE_MS
if can_navigate and back_pressed:
last_button_ms = now_ms
screen = (screen - 1) % TOTAL_SCREENS
start_feedback(red=True)
feedback_started_ms = now_ms
feedback_active = True
short_transition(oled)
print("Pantalla:", screen + 1)
elif can_navigate and next_pressed:
last_button_ms = now_ms
screen = (screen + 1) % TOTAL_SCREENS
start_feedback(green=True)
feedback_started_ms = now_ms
feedback_active = True
short_transition(oled)
print("Pantalla:", screen + 1)
last_back_value = back_value
last_next_value = next_value
# Apaga LED de feedback sin detener las animaciones.
if feedback_active and ticks_diff(now_ms, feedback_started_ms) >= LED_FEEDBACK_MS:
stop_feedback()
feedback_active = False
# -------------------------------------------------------------------------
# Render de animaciones
# -------------------------------------------------------------------------
if ticks_diff(now_ms, last_frame_ms) >= FRAME_INTERVAL_MS:
last_frame_ms = now_ms
if screen == 0:
# Pantalla 1: exactamente basada en oled_campus_hud.py.
temp_for_ui = temperatura if temperatura is not None else 0
hum_for_ui = humedad if humedad is not None else 0
lux_visual = visual_lux_from_pct(ldr_pct)
campus_hud.render(
STATION_ID,
temp_for_ui,
hum_for_ui,
lux_visual
)
elif screen == 1:
render_temperature(
oled,
temperatura,
dht_error,
now_ms
)
elif screen == 2:
render_humidity(
oled,
humedad,
dht_error,
now_ms
)
elif screen == 3:
render_light(
oled,
ldr_pct,
ldr_raw,
ldr_error,
now_ms
)
else:
render_amdg(oled, now_ms)
sleep_ms(20)