# SAFECORE - Caja fuerte didactica con Raspberry Pi Pico W. # Identifica el proyecto y la placa para la que se preparo este archivo.
import time # Importa temporizadores y funciones de reloj de MicroPython.
from machine import Pin, I2C, PWM # Importa los objetos para GPIO, bus I2C y salidas PWM.
import ssd1306 # Importa el controlador SSD1306 incluido como segundo archivo del proyecto.
# ==================== AJUSTES DEL USUARIO ==================== # Seccion de parametros que se pueden cambiar sin modificar la logica.
CLAVE_CORRECTA = "1234" # Clave de demostracion; cambiala antes de usar el prototipo.
CLAVE_PANICO = "9999" # Clave discreta que activa la apertura simulada y el aviso de coaccion.
MAX_INTENTOS = 3 # Cantidad maxima de claves equivocadas antes del bloqueo temporal.
TIEMPO_BLOQUEO_MS = 60000 # Duracion del bloqueo expresada en milisegundos: 60000 ms equivalen a 60 segundos.
ANGULO_CERRADO = 0 # Angulo inicial del servo; calibralo segun la posicion real del pestillo.
ANGULO_ABIERTO = 90 # Angulo de apertura; ajustalo si la mecanica de tu caja necesita otro recorrido.
SERVO_MIN_US = 600 # Pulso minimo inicial de prueba para el SG90; evita forzar el servo contra un tope.
SERVO_MAX_US = 2400 # Pulso maximo inicial de prueba para el SG90; reduce el valor si zumba o se traba.
TIPO_BUZZER = "PASIVO" # Usa PASIVO para el buzzer piezo de Wokwi o ACTIVO para un modulo activo con driver.
FRECUENCIA_PITIDO_ACEPTADO = 1900 # Define un tono agudo para confirmar que la clave correcta fue aceptada.
DURACION_PITIDO_ACEPTADO_MS = 150 # Define la duracion breve del unico pitido de aceptacion.
FRECUENCIA_PITIDO_ERROR = 850 # Define un tono mas grave para distinguir el aviso de tres intentos fallidos.
DURACION_PITIDO_ERROR_MS = 550 # Hace que cada uno de los tres pitidos de error sea largo y claramente audible.
PAUSA_ENTRE_PITIDOS_MS = 250 # Separa los tres pitidos largos con una pausa de silencio.
MODO_SENSOR = "WOKWI_BOTON" # Deja WOKWI_BOTON para el pulsador de la simulacion o cambia a SW420 para el modulo fisico.
SW420_ACTIVO_EN = 1 # Nivel logico que indica vibracion en muchos modulos genericos: 1=HIGH; cambia a 0 si tu modulo es inverso.
USAR_WIFI = False # Mantiene WiFi apagado en la primera prueba; activalo cuando ingreses las credenciales reales.
WIFI_SSID = "TU_RED_WIFI" # Sustituye este texto por el nombre exacto de tu red WiFi.
WIFI_PASSWORD = "TU_CLAVE_WIFI" # Sustituye este texto por la clave de tu red WiFi.
ENVIAR_TELEGRAM = False # Activa True solo despues de configurar WiFi, Token, Chat ID e instalar requests.
TELEGRAM_TOKEN = "PEGA_AQUI_EL_TOKEN_DEL_BOT" # Token privado entregado por BotFather; no lo compartas ni lo publiques.
TELEGRAM_CHAT_ID = "PEGA_AQUI_TU_CHAT_ID" # Identificador numerico del chat o grupo autorizado para recibir avisos.
UTC_PERU_HORAS = -5 # Diferencia horaria de Peru respecto a UTC para escribir la hora local en el registro.
# ==================== ASIGNACION DE PINES ==================== # Los GPIO coinciden con el circuito corregido adjunto.
PIN_OLED_SDA = 0 # GP0 transporta los datos SDA de la pantalla OLED.
PIN_OLED_SCL = 1 # GP1 transporta el reloj SCL de la pantalla OLED.
PIN_SERVO = 15 # GP15 genera PWM a 50 Hz para posicionar el servomotor.
PIN_BUZZER = 18 # GP18 controla el buzzer; se separa de GP15 para no compartir su generador PWM.
PIN_LED = 13 # GP13 controla el LED rojo a traves de una resistencia de 330 ohmios.
PIN_SENSOR = 16 # GP16 lee el pulsador de Wokwi o la salida digital DO del SW-420 real.
PINES_FILAS = (2, 3, 4, 5) # GP2 a GP5 activan una fila cada vez del teclado matricial.
PINES_COLUMNAS = (6, 7, 8, 9) # GP6 a GP9 leen las cuatro columnas del teclado matricial.
MAPA_TECLAS = (("1", "2", "3", "A"), ("4", "5", "6", "B"), ("7", "8", "9", "C"), ("*", "0", "#", "D")) # Traduce cada cruce fila-columna a una tecla.
# ==================== ESTADOS DEL PROGRAMA ==================== # Cada estado define que acciones acepta el sistema.
ESTADO_MENU = 0 # La caja esta cerrada y espera el primer digito de la clave.
ESTADO_INGRESO = 1 # La caja acumula hasta cuatro digitos y espera # para confirmar.
ESTADO_ERROR = 2 # Muestra temporalmente el mensaje de clave incorrecta sin detener el bucle.
ESTADO_BLOQUEADO = 3 # Ignora el teclado durante el bloqueo de 60 segundos.
ESTADO_ABIERTO = 4 # Mantiene el pestillo en posicion abierta hasta pulsar *.
ESTADO_ALARMA = 5 # Ejecuta la alarma de manipulacion con temporizadores no bloqueantes.
ESTADO_PANICO = 6 # Simula apertura bajo coaccion y vuelve a cerrar sin activar LED ni buzzer.
# ==================== INICIALIZACION DE HARDWARE ==================== # Crea los objetos que comunican el programa con los componentes.
i2c = I2C(0, scl=Pin(PIN_OLED_SCL), sda=Pin(PIN_OLED_SDA), freq=100000) # Configura el bus I2C 0 a 100 kHz para mantener una comunicacion conservadora con la OLED.
direcciones_i2c = i2c.scan() # Busca los dispositivos que responden en el bus I2C antes de iniciar la pantalla.
print("Dispositivos I2C detectados:", [hex(direccion) for direccion in direcciones_i2c]) # Muestra en la consola las direcciones encontradas para facilitar el diagnostico.
if 0x3C in direcciones_i2c: # Comprueba si la OLED responde en su direccion I2C habitual.
DIRECCION_OLED = 0x3C # Selecciona la direccion habitual 0x3C.
elif 0x3D in direcciones_i2c: # Comprueba si el modulo utiliza la direccion alternativa 0x3D.
DIRECCION_OLED = 0x3D # Selecciona la direccion alternativa 0x3D.
else: # Entra aqui si no se detecta ninguna de las direcciones habituales de una OLED SSD1306.
raise RuntimeError("No se detecta la OLED: revisa VCC, GND, SDA=GP0 y SCL=GP1.") # Detiene el programa con un mensaje claro en lugar de mostrar un ETIMEDOUT poco descriptivo.
oled = ssd1306.SSD1306_I2C(128, 64, i2c, addr=DIRECCION_OLED) # Inicializa la pantalla con la direccion que realmente respondio al escaneo.
filas = [Pin(pin, Pin.OUT, value=0) for pin in PINES_FILAS] # Configura las cuatro filas como salidas inicialmente apagadas.
columnas = [Pin(pin, Pin.IN, Pin.PULL_DOWN) for pin in PINES_COLUMNAS] # Configura las columnas como entradas con resistencia interna a tierra.
servo = PWM(Pin(PIN_SERVO)) # Crea la salida PWM que controla el cable de senal del servo.
servo.freq(50) # Configura 50 ciclos por segundo, frecuencia habitual de servomotores RC.
led_rojo = Pin(PIN_LED, Pin.OUT, value=0) # Configura el LED como salida y lo deja apagado al arrancar.
if MODO_SENSOR == "WOKWI_BOTON": # Comprueba si se esta usando el pulsador incluido en el diagrama de Wokwi.
sensor_impacto = Pin(PIN_SENSOR, Pin.IN, Pin.PULL_UP) # El pulsador de Wokwi conecta GP16 a GND y por eso se usa PULL_UP.
NIVEL_ACTIVO_SENSOR = 0 # En la simulacion, pulsar el boton produce un nivel LOW.
else: # Entra aqui cuando se instala el modulo SW-420 real.
if SW420_ACTIVO_EN == 1: # Comprueba si el modulo fisico indica vibracion con nivel HIGH.
sensor_impacto = Pin(PIN_SENSOR, Pin.IN, Pin.PULL_DOWN) # Mantiene la entrada LOW cuando no hay señal activa.
NIVEL_ACTIVO_SENSOR = 1 # Interpreta HIGH como golpe detectado.
else: # Atiende modulos SW-420 de salida invertida.
sensor_impacto = Pin(PIN_SENSOR, Pin.IN, Pin.PULL_UP) # Mantiene la entrada HIGH mientras el modulo no la lleva a LOW.
NIVEL_ACTIVO_SENSOR = 0 # Interpreta LOW como golpe detectado.
if TIPO_BUZZER == "PASIVO": # Selecciona el modo para el piezo pasivo utilizado en el simulador.
buzzer = PWM(Pin(PIN_BUZZER)) # Crea PWM independiente en GP18 para producir tonos audibles.
buzzer.freq(1200) # Fija una frecuencia inicial que se cambiara durante los patrones de alarma.
buzzer.duty_u16(0) # Deja el buzzer apagado al iniciar el equipo.
else: # Selecciona el modo para un buzzer activo fisico con transistor o modulo driver.
buzzer = Pin(PIN_BUZZER, Pin.OUT, value=0) # En un buzzer activo solo se conmuta la entrada del driver entre apagado y encendido.
# ==================== VARIABLES DE TRABAJO ==================== # Guarda el estado temporal sin bloquear la ejecucion.
estado_actual = ESTADO_MENU # Define el menu como estado inicial.
clave_actual = "" # Acumula los digitos ingresados sin mostrarlos en claro.
intentos_fallidos = 0 # Cuenta las claves incorrectas desde el ultimo desbloqueo.
tecla_anterior = None # Evita repetir una tecla mientras el usuario la mantiene presionada.
pantalla_anterior = None # Guarda el contenido anterior para no redibujar la OLED en cada vuelta.
inicio_sistema = time.ticks_ms() # Registra el inicio para ignorar vibraciones durante la estabilizacion inicial.
sensor_anterior_activo = False # Memoriza el estado anterior del sensor para detectar solo el flanco de activacion.
tiempo_ultimo_golpe = inicio_sistema # Guarda el instante del ultimo golpe aceptado para filtrar rebotes.
tiempo_inicio_bloqueo = 0 # Almacena el instante en que empieza el bloqueo de 60 segundos.
tiempo_inicio_error = 0 # Almacena el instante de inicio del mensaje de clave incorrecta.
tiempo_inicio_alarma = 0 # Almacena el instante de inicio de la alarma de manipulacion.
tiempo_inicio_panico = 0 # Almacena el instante de inicio de la apertura simulada por coaccion.
secuencia_pitidos = None # Guarda las fases de sonido y silencio que deben reproducirse sin detener el programa.
indice_fase_pitido = 0 # Indica que fase del patron sonoro se esta ejecutando.
inicio_fase_pitido = 0 # Guarda el instante en que comenzo la fase actual del patron sonoro.
ultimo_segundo_mostrado = -1 # Evita refrescar la cuenta regresiva si el segundo no cambio.
hora_sincronizada = False # Indica si se obtuvo fecha y hora valida desde un servidor NTP.
red_wifi = None # Guarda el objeto WiFi cuando la conectividad se habilita.
# ==================== FUNCIONES DE PANTALLA ==================== # Agrupa la escritura de mensajes en la pantalla OLED.
def mostrar(linea1, linea2="", linea3="", linea4=""): # Recibe hasta cuatro renglones de texto para la pantalla de 128x64.
global pantalla_anterior # Permite actualizar la cache de pantalla definida fuera de la funcion.
lineas = (linea1[:16], linea2[:16], linea3[:16], linea4[:16]) # Limita cada renglon a 16 caracteres para evitar recortes laterales.
if lineas == pantalla_anterior: # Comprueba si el mensaje ya esta visible.
return # Evita un refresco innecesario que causaria parpadeo.
oled.fill(0) # Borra el buffer grafico completo.
for indice, texto in enumerate(lineas): # Recorre los cuatro renglones disponibles.
oled.text(texto, 0, indice * 16) # Dibuja cada renglon separado 16 pixeles verticales.
oled.show() # Transfiere el buffer a la pantalla mediante I2C.
pantalla_anterior = lineas # Recuerda el mensaje que acaba de mostrarse.
# ==================== FUNCIONES DEL TECLADO ==================== # Escanea una matriz 4x4 y genera un evento por pulsacion.
def leer_tecla_cruda(): # Devuelve la tecla que esta presionada en este instante o None si no hay ninguna.
for indice_fila, pin_fila in enumerate(filas): # Activa una fila cada vez para identificar el cruce con la columna.
pin_fila.value(1) # Coloca la fila actual en HIGH para poder detectar una tecla pulsada.
for indice_columna, pin_columna in enumerate(columnas): # Recorre las cuatro entradas de columna.
if pin_columna.value() == 1: # Detecta si la fila activa quedo conectada a una columna.
pin_fila.value(0) # Desactiva la fila antes de salir de la funcion.
return MAPA_TECLAS[indice_fila][indice_columna] # Devuelve el caracter asociado a ese cruce.
pin_fila.value(0) # Desactiva la fila si no se encontro ninguna tecla en ella.
return None # Informa que no hay una tecla presionada.
def leer_tecla_evento(): # Devuelve una sola vez cada pulsacion para que una tecla sostenida no se repita.
global tecla_anterior # Permite recordar entre iteraciones la tecla que ya fue procesada.
tecla = leer_tecla_cruda() # Lee el estado instantaneo del teclado matricial.
if tecla != tecla_anterior: # Comprueba si hubo un cambio respecto de la lectura anterior.
time.sleep_ms(20) # Espera 20 ms para reducir falsos cambios por rebote mecanico.
confirmacion = leer_tecla_cruda() # Vuelve a leer para confirmar que la pulsacion es estable.
if confirmacion == tecla: # Acepta el evento solo si las dos lecturas coinciden.
tecla_anterior = tecla # Memoriza la nueva situacion, incluida la liberacion None.
if tecla is not None: # Solo devuelve caracteres cuando existe una pulsacion real.
return tecla # Entrega la tecla al programa principal.
else: # Atiende un cambio producido durante el periodo de estabilizacion.
tecla_anterior = confirmacion # Actualiza la memoria con el valor confirmado mas reciente.
return None # No genera un evento nuevo cuando la tecla no cambio o no se confirmo.
# ==================== FUNCIONES DE SERVO Y BUZZER ==================== # Controla los actuadores con pulsos compatibles con el hardware.
def mover_servo(angulo): # Recibe un angulo de 0 a 180 grados para mover el mecanismo del pestillo.
angulo = max(0, min(180, angulo)) # Limita el valor para evitar ordenes fuera del rango admitido.
pulso_us = SERVO_MIN_US + ((SERVO_MAX_US - SERVO_MIN_US) * angulo // 180) # Convierte el angulo en un pulso de 600 a 2400 microsegundos.
duty = pulso_us * 65535 // 20000 # Convierte el pulso a ciclo de trabajo para PWM con periodo de 20 ms.
servo.duty_u16(duty) # Envia el pulso calculado al pin GP15.
def buzzer_encender(frecuencia=1200): # Enciende el zumbador con el tono solicitado cuando es pasivo.
if TIPO_BUZZER == "PASIVO": # Comprueba si el componente necesita una onda PWM para emitir sonido.
buzzer.freq(frecuencia) # Ajusta la frecuencia del tono que se desea escuchar.
buzzer.duty_u16(32768) # Aplica aproximadamente un 50 por ciento de ciclo de trabajo.
else: # Atiende al buzzer activo que incorpora su propio oscilador interno.
buzzer.value(1) # Activa el transistor o modulo driver mediante una senal logica HIGH.
def buzzer_apagar(): # Apaga cualquiera de los dos tipos de zumbador.
if TIPO_BUZZER == "PASIVO": # Selecciona el metodo de apagado del piezo pasivo.
buzzer.duty_u16(0) # Lleva el ciclo PWM a cero para silenciar el piezo.
else: # Selecciona el metodo de apagado del zumbador activo.
buzzer.value(0) # Desactiva la entrada del transistor o del modulo driver.
def iniciar_patron_pitidos(secuencia): # Programa una serie de pitidos sin usar pausas que congelen el sistema.
global secuencia_pitidos, indice_fase_pitido, inicio_fase_pitido # Declara los datos globales que controlan la reproduccion del patron.
if not secuencia: # Comprueba que se haya recibido al menos una fase de sonido o silencio.
buzzer_apagar() # Mantiene el zumbador apagado si la secuencia esta vacia.
secuencia_pitidos = None # Desactiva cualquier patron porque no hay fases que reproducir.
return # Sale de la funcion sin iniciar una secuencia vacia.
secuencia_pitidos = secuencia # Conserva la lista de pares frecuencia/duracion que se ejecutara.
indice_fase_pitido = 0 # Empieza siempre por la primera fase de la secuencia.
inicio_fase_pitido = time.ticks_ms() # Marca el momento exacto en que comienza la primera fase.
frecuencia, duracion = secuencia_pitidos[indice_fase_pitido] # Lee la frecuencia y duracion de la primera fase.
if frecuencia > 0: # Interpreta una frecuencia positiva como una fase en la que debe sonar el buzzer.
buzzer_encender(frecuencia) # Inicia inmediatamente el primer pitido con la frecuencia configurada.
else: # Interpreta frecuencia cero como una pausa silenciosa.
buzzer_apagar() # Mantiene el buzzer apagado durante la primera fase si es una pausa.
def detener_patron_pitidos(): # Cancela el patron actual y silencia inmediatamente el zumbador.
global secuencia_pitidos # Permite desactivar la secuencia global en curso.
secuencia_pitidos = None # Indica que ya no quedan fases por reproducir.
buzzer_apagar() # Garantiza que el buzzer quede en silencio.
def actualizar_patron_pitidos(ahora): # Avanza las fases del patron usando ticks_ms sin detener otras tareas.
global secuencia_pitidos, indice_fase_pitido, inicio_fase_pitido # Declara los datos globales que se actualizan.
if secuencia_pitidos is None: # Comprueba si hay un patron sonoro activo.
return # Sale inmediatamente cuando no hay pitidos programados.
frecuencia, duracion = secuencia_pitidos[indice_fase_pitido] # Obtiene la frecuencia y duracion de la fase actual.
if time.ticks_diff(ahora, inicio_fase_pitido) < duracion: # Comprueba si la fase actual todavia debe mantenerse.
return # Conserva el sonido o silencio actual hasta completar su duracion.
indice_fase_pitido += 1 # Avanza a la siguiente fase cuando termina la duracion de la fase actual.
if indice_fase_pitido >= len(secuencia_pitidos): # Comprueba si ya se reprodujeron todas las fases del patron.
detener_patron_pitidos() # Silencia el buzzer y libera la secuencia al terminar el ultimo pitido.
return # Sale porque no quedan mas fases.
inicio_fase_pitido = ahora # Marca el comienzo de la nueva fase para medir su duracion.
frecuencia, duracion = secuencia_pitidos[indice_fase_pitido] # Lee los parametros de la nueva fase.
if frecuencia > 0: # Comprueba si la nueva fase corresponde a un sonido.
buzzer_encender(frecuencia) # Reproduce el pitido con la frecuencia de esta fase.
else: # Comprueba si la nueva fase corresponde a una pausa.
buzzer_apagar() # Silencia el zumbador durante la pausa entre pitidos.
# ==================== REGISTRO DE EVENTOS ==================== # Guarda sucesos sin almacenar la clave secreta que ingreso el usuario.
def guardar_log(evento): # Recibe una descripcion breve del evento que debe conservarse en flash.
if hora_sincronizada: # Comprueba si el reloj de la Pico obtuvo hora valida por NTP.
instante = time.localtime(time.time() + UTC_PERU_HORAS * 3600) # Convierte la hora UTC del reloj a hora local de Peru.
fecha = "[{}/{}/{} {:02d}:{:02d}:{:02d}]".format(instante[2], instante[1], instante[0], instante[3], instante[4], instante[5]) # Forma una marca de tiempo legible.
else: # Atiende el funcionamiento sin internet o sin sincronizacion horaria.
fecha = "[SIN_HORA]" # Evita inventar una fecha cuando el reloj no fue sincronizado.
try: # Protege el programa ante un error de escritura en la memoria flash.
with open("historial.txt", "a") as archivo: # Abre el historial en modo agregar para conservar los eventos anteriores.
archivo.write("{} {}\n".format(fecha, evento)) # Escribe fecha y evento en una linea nueva.
print("REGISTRO:", evento) # Muestra el evento en la consola REPL de Thonny.
except Exception as error: # Captura errores por falta de espacio o problemas del sistema de archivos.
print("ERROR AL GUARDAR HISTORIAL:", error) # Informa el fallo sin detener la caja fuerte.
# ==================== WIFI, NTP Y TELEGRAM OPCIONALES ==================== # Estas funciones solo actuan si se habilita la conectividad.
def preparar_wifi(): # Conecta la Pico W y sincroniza el reloj una sola vez durante el arranque.
global red_wifi, hora_sincronizada # Permite guardar el objeto de red y el resultado de sincronizacion.
if not USAR_WIFI: # Comprueba si el usuario decidio mantener la version local sin internet.
print("WIFI DESACTIVADO: el proyecto funcionara sin notificaciones remotas.") # Aclara en Thonny que Telegram no esta activo.
return # Sale sin intentar conectarse a una red.
try: # Captura errores de credenciales, cobertura, DNS o del modulo de red.
import network # Importa el controlador WiFi disponible en la Raspberry Pi Pico W.
red_wifi = network.WLAN(network.STA_IF) # Crea la interfaz WiFi en modo estacion.
red_wifi.active(True) # Enciende la radio WiFi.
if not red_wifi.isconnected(): # Evita repetir la conexion si ya existe una sesion activa.
red_wifi.connect(WIFI_SSID, WIFI_PASSWORD) # Inicia la conexion con el nombre y la clave configurados.
inicio = time.ticks_ms() # Marca el inicio del periodo de espera de conexion.
while not red_wifi.isconnected() and time.ticks_diff(time.ticks_ms(), inicio) < 15000: # Espera como maximo 15 segundos durante el arranque.
time.sleep_ms(250) # Cede tiempo al controlador de red entre comprobaciones.
if red_wifi.isconnected(): # Comprueba si la Pico obtuvo conexion.
print("WIFI CONECTADO:", red_wifi.ifconfig()) # Muestra direccion IP y parametros de red en Thonny.
try: # La hora NTP es independiente de la disponibilidad de Telegram.
import ntptime # Importa el cliente NTP incluido en muchas versiones de MicroPython.
ntptime.settime() # Ajusta el reloj interno de la placa usando UTC desde internet.
hora_sincronizada = True # Habilita marcas de tiempo locales en el archivo de historial.
print("HORA NTP SINCRONIZADA EN UTC.") # Confirma la sincronizacion en la consola.
except Exception as error_hora: # Atiende fallos NTP sin perder la conexion WiFi.
print("NO SE PUDO SINCRONIZAR NTP:", error_hora) # Informa que el registro usara [SIN_HORA].
else: # Atiende el caso en que la conexion no se completo dentro del plazo.
print("NO SE CONECTO AL WIFI; se continua en modo local.") # Mantiene el sistema operativo sin internet.
except Exception as error_wifi: # Captura cualquier error de inicializacion del controlador WiFi.
print("ERROR WIFI; se continua en modo local:", error_wifi) # Informa el problema sin abortar el sistema.
def enviar_telegram(mensaje): # Envia una alerta HTTP a Telegram solo cuando el usuario habilita todos los datos.
if not ENVIAR_TELEGRAM: # Comprueba si el envio remoto esta desactivado por defecto.
print("AVISO LOCAL (Telegram desactivado):", mensaje) # Deja evidencia en la consola para las pruebas del simulador.
return False # Informa que no se envio ningun mensaje remoto.
if red_wifi is None or not red_wifi.isconnected(): # Evita solicitudes web cuando no hay una red activa.
print("TELEGRAM NO ENVIADO: no hay WiFi conectado.") # Explica por que no se pudo transmitir la alerta.
return False # Sale sin detener la logica de seguridad.
if "PEGA_AQUI" in TELEGRAM_TOKEN or "PEGA_AQUI" in TELEGRAM_CHAT_ID: # Detecta si siguen puestos los textos de ejemplo.
print("TELEGRAM NO ENVIADO: falta Token o Chat ID.") # Evita enviar una solicitud con credenciales incompletas.
return False # Indica que no hubo envio.
respuesta = None # Reserva la variable para poder cerrar la respuesta aunque ocurra un error.
try: # Agrupa la importacion y la solicitud web para que cualquier fallo sea controlado.
import ujson # Importa el conversor JSON de MicroPython.
try: # Prueba primero el nombre de modulo actual de micropython-lib.
import requests as cliente_http # Importa el cliente HTTP recomendado en versiones recientes de micropython-lib.
except ImportError: # Atiende instalaciones antiguas que todavia usan el nombre urequests.
import urequests as cliente_http # Importa el alias compatible de la biblioteca anterior.
url = "https://api.telegram.org/bot{}/sendMessage".format(TELEGRAM_TOKEN) # Forma la URL de la API de Telegram.
datos = ujson.dumps({"chat_id": TELEGRAM_CHAT_ID, "text": mensaje}) # Convierte el chat y el aviso a JSON.
cabeceras = {"Content-Type": "application/json"} # Informa a Telegram que el cuerpo de la solicitud esta en JSON.
respuesta = cliente_http.post(url, data=datos, headers=cabeceras) # Envia el mensaje mediante HTTPS.
correcto = respuesta.status_code == 200 # Comprueba el codigo HTTP de respuesta satisfactoria.
print("TELEGRAM ENVIADO." if correcto else "TELEGRAM RESPONDIO:", respuesta.status_code) # Informa el resultado en la consola.
return correcto # Devuelve True solo si Telegram devolvio HTTP 200.
except Exception as error_http: # Captura falta de libreria, error TLS, DNS o rechazo de la API.
print("ERROR AL ENVIAR TELEGRAM:", error_http) # Registra el error para poder diagnosticarlo desde Thonny.
return False # Permite que el funcionamiento local continue.
finally: # Se ejecuta tanto si la solicitud funciona como si falla.
if respuesta is not None: # Comprueba si se recibio un objeto de respuesta HTTP.
try: # Protege el cierre del socket de red.
respuesta.close() # Libera memoria y conexion usada por la solicitud.
except Exception: # Ignora un posible error producido al cerrar una respuesta incompleta.
pass # No realiza ninguna accion adicional porque la alerta ya fue procesada.
# ==================== TRANSICIONES DE ESTADO ==================== # Cada funcion agrupa acciones que ocurren al cambiar de modo.
def iniciar_menu(): # Prepara el estado de espera de clave con el pestillo cerrado.
global estado_actual, clave_actual, pantalla_anterior # Declara las variables globales que modifica la transicion.
mover_servo(ANGULO_CERRADO) # Lleva el servo a la posicion definida como cerrada.
clave_actual = "" # Borra cualquier digito que hubiera quedado pendiente.
estado_actual = ESTADO_MENU # Regresa al estado de espera.
pantalla_anterior = None # Obliga a dibujar el mensaje inicial en la OLED.
mostrar("SAFECORE", "CAJA CERRADA", "Ingrese su clave", "# para confirmar") # Informa al usuario que la caja esta lista.
def iniciar_apertura(): # Abre el pestillo tras autenticar la clave maestra.
global estado_actual, clave_actual # Declara variables globales modificadas por la funcion.
guardar_log("ACCESO CONCEDIDO") # Registra el acceso sin escribir la clave utilizada.
mover_servo(ANGULO_ABIERTO) # Coloca el servo en el angulo de apertura antes de intentar enviar datos por WiFi.
iniciar_patron_pitidos(((FRECUENCIA_PITIDO_ACEPTADO, DURACION_PITIDO_ACEPTADO_MS),)) # Emite un unico pitido corto para confirmar la apertura autorizada.
clave_actual = "" # Elimina la clave de la memoria de trabajo.
estado_actual = ESTADO_ABIERTO # Cambia al estado de caja abierta.
mostrar("ACCESO CONCEDIDO", "CAJA ABIERTA", "Pulse * para", "volver a cerrar") # Muestra la instruccion de cierre.
enviar_telegram("SafeCore: acceso autorizado a la caja fuerte.") # Envia el aviso despues de completar la apertura local.
def iniciar_panico(): # Ejecuta la clave de coaccion con apariencia de apertura normal.
global estado_actual, clave_actual, tiempo_inicio_panico # Declara las variables globales que deben actualizarse.
guardar_log("ALERTA DE COACCION ACTIVADA") # Registra el evento sin guardar el codigo secreto.
mover_servo(ANGULO_ABIERTO) # Mueve el pestillo para que la caja parezca abrirse ante la persona que coacciona.
clave_actual = "" # Borra los digitos introducidos.
estado_actual = ESTADO_PANICO # Cambia al estado especial de coaccion.
mostrar("ACCESO CONCEDIDO", "CAJA ABIERTA", "Puede continuar", "") # Presenta un mensaje de apertura normal como pantalla de engano.
enviar_telegram("ALERTA SAFECORE: posible apertura bajo coaccion. Verificar inmediatamente.") # Transmite la alerta silenciosa despues de efectuar la apertura simulada.
tiempo_inicio_panico = time.ticks_ms() # Inicia los 3.5 segundos de apertura visible despues del intento de aviso remoto.
def iniciar_bloqueo(motivo, activar_sirena=True): # Inicia el bloqueo temporal y permite decidir si debe sonar una alarma.
global estado_actual, tiempo_inicio_bloqueo, ultimo_segundo_mostrado # Declara los temporizadores modificados.
guardar_log("BLOQUEO: {}".format(motivo)) # Registra la causa del bloqueo.
tiempo_inicio_bloqueo = time.ticks_ms() # Guarda el instante inicial para calcular los 60 segundos.
if activar_sirena: # Comprueba si el motivo requiere el aviso sonoro de tres intentos fallidos.
pausa = (0, PAUSA_ENTRE_PITIDOS_MS) # Define una fase de silencio entre dos pitidos consecutivos.
pitido = (FRECUENCIA_PITIDO_ERROR, DURACION_PITIDO_ERROR_MS) # Define una fase de pitido largo para cada error que completo el limite.
iniciar_patron_pitidos((pitido, pausa, pitido, pausa, pitido)) # Programa exactamente tres pitidos largos separados por dos pausas.
else: # Atiende bloqueos originados por otras alarmas que ya tienen su propio patron sonoro.
detener_patron_pitidos() # Evita que se reproduzca un patron de tres errores para una manipulacion fisica.
ultimo_segundo_mostrado = -1 # Fuerza la primera actualizacion del contador en pantalla.
estado_actual = ESTADO_BLOQUEADO # Cambia al estado que ignora las teclas durante el tiempo de bloqueo.
mostrar("SISTEMA BLOQUEADO", "No intente abrir", "Tiempo: 60 s", "Espere por favor") # Informa el bloqueo al usuario.
def iniciar_alarma_manipulacion(): # Atiende un golpe o vibracion detectado mientras la caja esta cerrada.
global estado_actual, tiempo_inicio_alarma, clave_actual # Declara las variables globales actualizadas al disparar la alarma.
guardar_log("MANIPULACION FISICA DETECTADA") # Guarda evidencia del evento en el historial.
tiempo_inicio_alarma = time.ticks_ms() # Marca el comienzo del patron de alarma local.
clave_actual = "" # Descarta cualquier clave que se estuviera ingresando.
estado_actual = ESTADO_ALARMA # Cambia al estado de alarma antes de usar WiFi.
mostrar("ALERTA DETECTADA", "MANIPULACION", "Alarma activa", "Caja bloqueada") # Muestra el motivo en pantalla.
led_rojo.value(1) # Enciende primero el LED para que la alerta local no dependa de la red.
buzzer_encender(1400) # Activa el buzzer antes de intentar transmitir la notificacion.
enviar_telegram("ALERTA SAFECORE: se detecto vibracion o golpe en la caja fuerte.") # Envia el aviso despues de iniciar la alarma local.
# ==================== LECTURA DEL SENSOR ==================== # Detecta transiciones para no disparar muchas alarmas por un mismo golpe.
def hubo_golpe(ahora): # Devuelve True una vez cuando aparece un flanco de activacion del sensor.
global sensor_anterior_activo, tiempo_ultimo_golpe # Permite recordar el flanco y el tiempo del evento anterior.
valor = sensor_impacto.value() # Lee el nivel digital actual de GP16.
activo = valor == NIVEL_ACTIVO_SENSOR # Convierte HIGH o LOW a un valor logico uniforme.
nuevo_flanco = activo and not sensor_anterior_activo # Detecta el paso desde reposo hacia vibracion.
sensor_anterior_activo = activo # Guarda el estado actual para comparar en la siguiente vuelta.
if time.ticks_diff(ahora, inicio_sistema) < 2000: # Da dos segundos al sensor para estabilizarse despues del encendido.
return False # Ignora vibraciones o cambios iniciales durante el arranque.
if nuevo_flanco and time.ticks_diff(ahora, tiempo_ultimo_golpe) >= 2500: # Aplica un periodo de rearme para evitar eventos duplicados.
tiempo_ultimo_golpe = ahora # Guarda la hora del golpe aceptado.
return True # Informa que se debe iniciar la alarma de manipulacion.
return False # No se detecto un nuevo evento valido.
# ==================== PROCESAMIENTO DE TECLAS ==================== # Aplica las reglas de autenticacion de la caja.
def procesar_tecla(tecla): # Recibe una tecla ya depurada por la funcion leer_tecla_evento.
global estado_actual, clave_actual, intentos_fallidos, tiempo_inicio_error # Declara los datos de autenticacion que puede modificar.
if estado_actual == ESTADO_MENU: # Atiende el primer digito cuando la caja esta cerrada.
if tecla.isdigit(): # Solo acepta numeros en la clave de cuatro digitos.
clave_actual = tecla # Guarda el primer digito ingresado.
estado_actual = ESTADO_INGRESO # Cambia a la pantalla de ingreso de clave.
mostrar("INGRESE CLAVE", "*" * len(clave_actual), "# = OK * = BORRA", "4 digitos") # Oculta el digito con un asterisco.
elif estado_actual == ESTADO_INGRESO: # Atiende nuevas teclas mientras se forma la clave.
if tecla.isdigit() and len(clave_actual) < 4: # Acepta solo numeros y limita la clave a cuatro caracteres.
clave_actual += tecla # Anade el digito al final de la cadena temporal.
mostrar("INGRESE CLAVE", "*" * len(clave_actual), "# = OK * = BORRA", "4 digitos") # Actualiza los asteriscos visibles.
elif tecla == "*": # Usa asterisco como funcion de borrado o cancelacion.
clave_actual = "" # Elimina todos los digitos que se habian ingresado.
iniciar_menu() # Regresa a la pantalla inicial sin contar un intento fallido.
elif tecla == "#": # Usa numeral como boton de confirmacion de la clave.
if len(clave_actual) != 4: # Comprueba que se hayan ingresado exactamente cuatro digitos.
mostrar("CLAVE INCOMPLETA", "Ingrese 4 digitos", "* para cancelar", "") # Explica por que no puede validar todavia.
clave_actual = "" # Limpia el campo para iniciar una nueva entrada.
tiempo_inicio_error = time.ticks_ms() # Inicia una espera corta para que el usuario lea el mensaje.
estado_actual = ESTADO_ERROR # Usa el estado temporal de error para volver al menu sin pausa larga.
elif clave_actual == CLAVE_PANICO: # Comprueba primero la clave especial de coaccion.
iniciar_panico() # Abre de manera simulada y dispara la alerta silenciosa.
elif clave_actual == CLAVE_CORRECTA: # Comprueba la clave maestra normal.
intentos_fallidos = 0 # Limpia el contador de fallos tras una autenticacion correcta.
iniciar_apertura() # Desbloquea el pestillo y registra el acceso.
else: # Atiende cualquier clave de cuatro digitos que no coincida con las dos configuradas.
intentos_fallidos += 1 # Suma un intento incorrecto.
guardar_log("INTENTO DE CLAVE INCORRECTA") # Guarda el fallo sin revelar la clave ingresada.
clave_actual = "" # Borra inmediatamente la clave temporal por seguridad.
if intentos_fallidos >= MAX_INTENTOS: # Comprueba si se alcanzaron los tres fallos permitidos.
enviar_telegram("ALERTA SAFECORE: se alcanzaron tres intentos de clave incorrecta.") # Avisa al responsable si Telegram esta habilitado.
iniciar_bloqueo("tres intentos fallidos", True) # Impone bloqueo de 60 segundos y una senal sonora corta.
else: # Atiende el primer o segundo error sin bloquear el equipo.
tiempo_inicio_error = time.ticks_ms() # Marca el inicio del mensaje temporal de error.
estado_actual = ESTADO_ERROR # Cambia a un estado de espera no bloqueante.
mostrar("CLAVE INCORRECTA", "Intento {}/{}".format(intentos_fallidos, MAX_INTENTOS), "Vuelva a probar", "") # Muestra el numero de intentos consumidos.
elif estado_actual == ESTADO_ABIERTO: # Atiende las teclas cuando el pestillo esta desbloqueado.
if tecla == "*": # Usa asterisco para cerrar manualmente la caja.
guardar_log("CAJA CERRADA POR USUARIO") # Registra la accion de cierre.
iniciar_menu() # Mueve el servo a cerrado y vuelve a la espera de clave.
# ==================== ARRANQUE DEL SISTEMA ==================== # Prepara la red, el servo y la pantalla antes del bucle principal.
preparar_wifi() # Conecta a WiFi y NTP solo si la opcion correspondiente esta activada.
mover_servo(ANGULO_CERRADO) # Ordena al servo colocarse en la posicion de pestillo cerrado.
buzzer_apagar() # Asegura que el buzzer no quede sonando al encender.
led_rojo.value(0) # Asegura que el LED de alarma empiece apagado.
guardar_log("SISTEMA INICIADO") # Registra cada arranque para el historial del prototipo.
iniciar_menu() # Presenta el menu inicial de la caja.
print("--- SAFECORE LISTO ---") # Muestra una confirmacion de arranque en la consola REPL.
# ==================== BUCLE PRINCIPAL ==================== # Ejecuta lecturas frecuentes sin pausas largas que congelen el programa.
while True: # Mantiene el control del sistema mientras la Pico W tenga alimentacion.
ahora = time.ticks_ms() # Lee el contador monotono en milisegundos para todos los temporizadores.
if estado_actual not in (ESTADO_ABIERTO, ESTADO_PANICO, ESTADO_ALARMA): # Supervisa el sensor mientras la caja no esta abierta ni en alarma.
if hubo_golpe(ahora): # Comprueba si aparecio una nueva vibracion valida.
iniciar_alarma_manipulacion() # Activa la alarma visual/sonora y el aviso remoto configurado.
tecla = leer_tecla_evento() # Lee una tecla nueva sin repetirla mientras permanece presionada.
if tecla is not None: # Comprueba si el teclado produjo un evento.
procesar_tecla(tecla) # Aplica la tecla segun el estado actual del sistema.
if estado_actual == ESTADO_ERROR: # Atiende el mensaje de error sin detener la lectura del sensor ni el reloj.
if time.ticks_diff(ahora, tiempo_inicio_error) >= 1400: # Comprueba si ya transcurrio el tiempo de lectura del mensaje.
iniciar_menu() # Vuelve al menu para permitir otro intento.
elif estado_actual == ESTADO_ALARMA: # Ejecuta el patron de alarma por manipulacion fisica.
transcurrido = time.ticks_diff(ahora, tiempo_inicio_alarma) # Calcula cuanto tiempo lleva activa la alarma.
if transcurrido >= 4500: # Limita la sirena a cuatro segundos y medio.
buzzer_apagar() # Detiene el sonido al finalizar el patron.
led_rojo.value(0) # Apaga el LED de alarma.
iniciar_bloqueo("manipulacion detectada", False) # Mantiene cerrada la caja durante 60 segundos sin repetir la sirena.
else: # Mantiene la alarma activa durante su ventana de funcionamiento.
fase_encendida = (transcurrido // 300) % 2 == 0 # Alterna encendido y apagado cada 300 milisegundos.
led_rojo.value(1 if fase_encendida else 0) # Hace parpadear el LED siguiendo el patron.
if fase_encendida: # Comprueba si corresponde emitir sonido en esta fase.
buzzer_encender(1400) # Emite el tono de alarma mientras la fase esta encendida.
else: # Atiende la fase silenciosa del patron.
buzzer_apagar() # Silencia el buzzer sin pausar el bucle principal.
elif estado_actual == ESTADO_BLOQUEADO: # Actualiza la cuenta regresiva del bloqueo temporal.
transcurrido = time.ticks_diff(ahora, tiempo_inicio_bloqueo) # Calcula el tiempo que ha pasado desde el bloqueo.
if transcurrido >= TIEMPO_BLOQUEO_MS: # Comprueba si ya se cumplieron los 60 segundos.
intentos_fallidos = 0 # Restablece el contador para permitir nuevos intentos.
buzzer_apagar() # Asegura que el buzzer quede apagado al desbloquear.
led_rojo.value(0) # Asegura que el LED quede apagado al desbloquear.
guardar_log("BLOQUEO TEMPORAL FINALIZADO") # Registra el fin del periodo de espera.
iniciar_menu() # Regresa a la pantalla de caja cerrada.
else: # Mantiene el bloqueo mientras el contador siga siendo positivo.
restantes = (TIEMPO_BLOQUEO_MS - transcurrido + 999) // 1000 # Redondea hacia arriba los segundos que faltan.
if restantes != ultimo_segundo_mostrado: # Refresca la OLED solo cuando cambia el segundo visible.
mostrar("SISTEMA BLOQUEADO", "No intente abrir", "Quedan: {} s".format(restantes), "Teclado inactivo") # Muestra el tiempo restante.
ultimo_segundo_mostrado = restantes # Memoriza el valor que ya se dibujo.
led_rojo.value(0) # Mantiene apagado el LED durante el bloqueo normal despues del aviso inicial.
elif estado_actual == ESTADO_ABIERTO: # Mantiene la caja abierta hasta que el usuario solicite el cierre.
pass # El evento de la tecla * se procesa en procesar_tecla sin bloquear el bucle.
elif estado_actual == ESTADO_PANICO: # Mantiene el comportamiento de engano durante un tiempo definido.
if time.ticks_diff(ahora, tiempo_inicio_panico) >= 3500: # Espera 3.5 segundos para simular que la persona pudo abrir la caja.
mover_servo(ANGULO_CERRADO) # Vuelve a cerrar el pestillo una vez transcurrido el intervalo de coaccion.
guardar_log("CIERRE AUTOMATICO TRAS MODO PANICO") # Registra el cierre posterior al evento de coaccion.
iniciar_menu() # Retorna al estado cerrado sin activar una alarma visible o sonora.
actualizar_patron_pitidos(ahora) # Avanza el pitido de aceptacion o los tres pitidos de bloqueo sin pausar el programa.
time.sleep_ms(10) # Cede 10 ms al sistema para mantener un bucle estable y de respuesta rapida.
Loading
ssd1306
ssd1306