Muy buenas a todos y todas a este mundo de los 8bits!!!
Desde que compre esta pequeña joya de los 80's, uno de los mayores problemas que presenta el ZX Spectrum es la carga de programas o juegos desde su soporte nativo: las cintas de casete.
Y es que en este tipo de máquinas, como el Amstrad CPC 464, utilizan como soporte magnético las obsoletas cintas de casete.
Si bien hay un buen mercado de segunda mano para este tipo de soporte, no siempre están en las mejores condiciones al ser susceptible a la degradación tanto física como química, deformaciones y atascos e incluso la aparición de moho si se almaceno en un sótano o trastero húmedo.
Por suerte las nuevas tecnologías siempre nos echan una mano para para solucionar este tipo de adversidades y no dejar como un gran pisapapeles a nuestro querido ZX Spectrum.
El concepto era almacenar los 1s y 0s de los bits en tonos de audio, para después hacer la operación contraría y transformar esos tonos de audio en información que el ordenador pueda entender.
Realmente era algo más parecido a un módem que a un disquete de la época.
Como resultado tenemos un soporte que esta basado en audio.
Igual que en sus orígenes, el audio siempre se puede copiar y aquí es donde entran las nuevas tecnologías: El formato de audio WAV y el formato MP3.
Como ya explique en la restauración del ZX Spectrum una de las soluciones es saltarse el casete e introducir la señal de audio desde la salida de audio de un teléfono o un ordenador.
Esta opción esta muy bien, pero seguimos con la posibilidad de que el cable esté en mal estado o el conector sucio además de tener otro cable más por encima del escritorio.
Podemos hacer la misma operación pero de manera inalámbrica con un módulo de audio Bluetooth.
Fáciles de encontrar, y baratos, nos ofrecen una solución muy interesante para implementar este pequeño dispositivo en nuestro ZX Spectrum.
Igual que hicimos con el amplificador y el cable, vamos a necesitar los mismos 4 puntos para conectar el módulo bluetooth, toma de alimentación +5V y la salida de audio del casete.
NOTA: Recuerda revisar la placa de la casetera ya que hay diferentes modelos, revisa el diagrama antes de conectar!!!
Una de sus mejores características es que ocupa muy poco espacio lo he situado en la parte lateral derecha de la máquina y no molesta para poder cerrar y abrir la carcasa sin problemas.
¿Cómo funciona?
Para hacer funcionar el módulo primero necesitamos vincular el módulo bluetooth a nuestro teléfono de igual manera que lo hacemos con unos audífonos o un altavoz.
Una vez vinculado el módulo lo tenemos que "calibrar" para que el sonido no este saturado o la señal sea insuficiente. Esto lo regulamos con la intensidad del sonido, en este caso el subiendo o bajando el volumen del dispositivo.
También aplicaciones como PlayZX tiene un apartado de configuración con apartados como:
Wave payback volume: Regulamos el volumen, en mi caso lo tengo al 50%.
Stereo: Envía la señal por los canales (L y R), también lo tengo activado.
Both channels in sync: Esta opción la tengo desactivada.
Natural wave: También desactivada.
Una vez configurado, buscamos el juego que vamos a cargar, seleccionamos la cinta y pulsamos Play...
Si te interesa el micro ordenador de 8 bits ZX Spectrum puedes encontrar más información en los siguientes enlaces:
Y como veía mi cluster Pi un poco "triste" decidí animarlo un poco con una pantalla OLED de tipo I2c y algunas animaciones programadas en Python que la hacen más "friendly".
Expresiones y dirección de la mirada
Podemos alternar entre las diferentes expresiones:
Normal
Contento
Triste
Sorpresa
Dormido
También podemos dirigir la mirada en las siguientes direcciones:
Izquierda
Centro
Derecha
Pero también podemos llamar de manera independiente a cada una de las expresiones con la función "crear_ojos" y "animar_parpadeo"
def crear_ojos(expresion="normal", direccion_mirada="centro", estado_parpadeo=1.0):
"""
Ojos con sistema de parpadeo realista
estado_parpadeo: 1.0 = totalmente abierto, 0.0 = totalmente cerrado
"""
Para crear la animación de parpadeo:
def animar_parpadeo(expresion="normal", direccion_mirada="centro"):
"""Animación completa de parpadeo"""
Programa completo en python:
# Simulación de ojos con expresiones en OLED 128x64
import time
import random
import board
import adafruit_ssd1306
from PIL import Image, ImageDraw, ImageFont
# Use for I2C.
i2c = board.I2C() # uses board.SCL and board.SDA
try:
disp = adafruit_ssd1306.SSD1306_I2C(128, 64, i2c, addr=0x3C)
print("\nAdafruit SSD1306 ok")
except Exception as errors:
print("\nError Adafruit SSD1306:",str(errors), "\nPlease check Raspi-config\n")
exit()
def crear_ojos(expresion="normal", direccion_mirada="centro", estado_parpadeo=1.0):
"""
Ojos con sistema de parpadeo realista
estado_parpadeo: 1.0 = totalmente abierto, 0.0 = totalmente cerrado
"""
# Crear imagen evitando la franja amarilla superior
img = Image.new('1', (128, 64), 0)
draw = ImageDraw.Draw(img)
# Posiciones fijas
centro_y = 40
ojo_izq_x = 45
ojo_der_x = 83
# Tamaño máximo reducido a 40
max_tam = 40
# Radio de redondeo
radio = 15
# Determinar tamaños según dirección de mirada
if direccion_mirada == "izquierda":
ancho_izq, alto_izq = max_tam, max_tam
ancho_der, alto_der = 30, 30
elif direccion_mirada == "derecha":
ancho_izq, alto_izq = 30, 30
ancho_der, alto_der = max_tam, max_tam
else: # centro
ancho_izq, alto_izq = 35, 40
ancho_der, alto_der = 35, 40
def dibujar_ojo(x, y, ancho, alto, radio, apertura=1.0):
"""Dibuja un ojo con sistema de parpadeo"""
if apertura >= 1.0:
# Ojo totalmente abierto
draw.rounded_rectangle([
(x - ancho//2, y - alto//2),
(x + ancho//2, y + alto//2)
], radius=radio, outline=1, fill=1)
elif apertura <= 0.0:
# Ojo totalmente cerrado (línea)
draw.line([
(x - ancho//2, y),
(x + ancho//2, y)
], fill=1, width=3)
else:
# Ojo parcialmente cerrado - calcular altura visible
alto_visible = int(alto * apertura)
if alto_visible < 2:
alto_visible = 2
# Dibujar el ojo recortado según la apertura
draw.rounded_rectangle([
(x - ancho//2, y - alto_visible//2),
(x + ancho//2, y + alto_visible//2)
], radius=min(radio, alto_visible//2), outline=1, fill=1)
# Aplicar expresiones (modifican la apertura base)
apertura_base = estado_parpadeo
if expresion == "normal":
apertura = apertura_base
elif expresion == "contento":
apertura = apertura_base * 0.7 # Ojos más cerrados
elif expresion == "triste":
apertura = apertura_base * 0.9 # Ojos casi normales pero caídos
elif expresion == "sorpresa":
apertura = min(apertura_base * 1.2, 1.0) # Ojos más abiertos
elif expresion == "dormido":
apertura = apertura_base * 0.3 # Ojos casi cerrados
# Dibujar ojos con la apertura calculada
dibujar_ojo(ojo_izq_x, centro_y, ancho_izq, alto_izq, radio, apertura)
dibujar_ojo(ojo_der_x, centro_y, ancho_der, alto_der, radio, apertura)
return img
def animar_parpadeo(expresion="normal", direccion_mirada="centro"):
"""Animación completa de parpadeo"""
frames = []
# Fases del parpadeo (apertura de 1.0 a 0.0 y vuelta)
fases = [1.0, 0.8, 0.5, 0.2, 0.0, 0.2, 0.5, 0.8, 1.0]
for apertura in fases:
frame = crear_ojos(expresion, direccion_mirada, apertura)
frames.append(frame)
return frames
# Bucle principal
if __name__ == "__main__":
expresiones = ["normal", "contento", "triste", "sorpresa", "dormido"]
direcciones = ["izquierda", "centro", "derecha"]
print("Mostrando expresiones sencillas...")
print("Presiona Ctrl+C para detener")
try:
while True:
for expresion in expresiones:
for direccion in direcciones:
print(f"Expresión: {expresion} dirección: {direccion}")
img = crear_ojos(expresion, direccion)
disp.image(img)
disp.show()
time.sleep(5)
# 30% de probabilidad de guiño entre animaciones
if random.random() < 0.2:
#lado_guiño = random.choice(["izquierdo", "derecho"])
print(f"¡papadeo {expresion}!")
frames_parpadeo = animar_parpadeo(expresion, direccion)
for frame in frames_parpadeo:
disp.image(frame)
disp.show()
except KeyboardInterrupt:
print("\nBucle detenido")
Es un programa que podemos ampliar con otras expresiones o incluso hacer llamadas desde otros programas por ejemplo conectandolo a una Raspi cam para simular que la mirada nos sigue.
Otro idea que se me ocurre es que las llamadas sean según diferentes eventos, como que despierte con el acceso por ssh o utilizar el sensor de temperatura para que nos "mire mal" si nuestra Raspberry Pi se pone lujuriosa.
Añadir programa al iniciar Raspberry PI
Lo ideal es que este programa se inicie al encender la Raspberry pi. Es un buen indicador para saber que la Raspberry Pi arranco sin problemas.
esto lo podemos hacer editando el crontab con el comando "crontab -e" (no hace falta sudo).
Recuerda poner "@reboot sleep 30" para darle un tiempo prudente a la Raspberry para inicializar el puerto I2c.
Y siempre puedes ver si todo esta bien o hay algún fallo consultando el archivo que se genera en el inicio llamado "eyes.log".
Conclusión:
Es un programa sencillo que le dará a nuestra maquina un poco más de carisma (por si le faltaba un poco) y que deje de ser un puñado de lucecitas encima del escritorio.
Antes de montar el cluster con las 3 Raspberry Pi, la que máquina que usaba para estas cosas empezó a dar problemas con la tarjeta y lo pude detectar ya que no me miraba igual que siempre...
¿Le puedo poner nombre?
Aquí tienes otros enlaces de este blog relacionados con Raspberry Pi:
Si estas atento al canal de Youtube, sabrás de mis últimas adquisiciones en ordenadores de los 90's.
En esta ocasión es un ordenador 286, una generación que estuvo activa desde 1982 hasta principios de los 90's empezando su declive en 1985 con la introducción de los 386 y sus poderosos 32bits.
Pero que esto no os confunda, sus auténticos rivales eran sus predecesores, los 8086/8080 a los cuales machacaba sin piedad.
La revolución del 286
Las mejoras del 286 son una autentica revolución a nivel hardware y se dividen en dos modos de operación principales:
Modo Real (Real Mode):
Completamente compatible con el 8086/8088. Los programas escritos para el PC original funcionaban sin modificaciones.
Sin embargo, en este modo, el 286 era mucho más rápido (a la misma frecuencia de reloj, unas 3-6 veces más rápido) gracias a mejoras en la microarquitectura, como un pipeline de 4 etapas y una unidad de ejecución más eficiente.
Modo Protegido (Protected Mode):
Esta fue la gran innovación. Introdujo conceptos que definieron la informática moderna:
Espacio de Direccionamiento Ampliado:
Acceso a hasta 16 MB de memoria física (frente a 1 MB del 8086).
Memoria Virtual:
Podía direccionar hasta 1 GB de memoria virtual por tarea, utilizando un sistema de disco como extensión de la RAM.
Protección de Memoria:
Aislamiento entre procesos y el sistema operativo mediante anillos de privilegio (0 para el SO, 3 para aplicaciones), evitando que una aplicación "colgada" colapsara toda la máquina.
Multitarea por Hardware:
Soporte a nivel de hardware para cambiar rápidamente entre múltiples tareas.
Los componentes:
En este caso estamos frente a un PC totalmente clónico, es decir cada parte es de como decimos en España "de su padre y de su madre", son compatibles pero de marcas tamaños y formas diferentes. Aunque creo que eso son cosas que no cambian...
Sin duda lo que más me sorprendió de todo el conjunto, y no precisamente por el procesador.
Esta placa con un socket de 40 pines admite procesadores con velocidades de 12, 16 y 20MHz y Co-procesadores 80287-8, 12 y 16.
Admitía un máximo de 4MB en módulos SIPP. Y esto es lo más raro de todo!!!. Nunca había ni visto ni escuchado hablar de este tipo de módulos, tienen 30 pines igual que las memorias SIMM de 30 contactos pero tienen soldadas unas "patitas" y el zócalo en el que las introducimos son sockets como los que se utilizan para poner los circuitos integrados, ¡curioso cuanto menos!
Cuenta con 6 puertos de expansión ISA 16bits y 1 puerto de expansión ISA de 8bits y puerto para teclados de tipo AT, El "gordo"
Esta bestia de 20MHz y pese a no contar con unidad de coma flotante, era capaz de manejar hasta 16MB de memoria RAM física y 1GB de memoria virtual, que no creo que en esa época aun usuario normal lo utilizase, pero poder, podía.
Ofrecía velocidades de 20MHz en su modo turbo y 12MHz para su modo "tortuga" haciendo compatibles software que no estaban optimizados para estas velocidades.
El Harris CS80C286-20 era particularmente valorado en aplicaciones embebidas, industriales y portátiles por su bajo consumo y rango de temperatura extendido, manteniendo compatibilidad total con el software x86 de la época.
Harris CS80C286-20 - Especificaciones Técnicas
Fabricante
Harris Semiconductor (posteriormente Intersil)
Part Number
CS80C286-20
Frecuencia
20 MHz
Tecnología
CHMOS IV (1.2 µm = 1200 nm)
Arquitectura
16-bit x86 compatible
Bus direcciones
24 bits (16 MB físico)
Bus datos
16 bits
Transistores
~120,000-130,000
Voltaje
+5V ±10%
Consumo típico
150 mA @ 20 MHz (750 mW)
Temperatura operativa
-40°C a +85°C (Industrial)
Empaquetado
PLCC-68, LCC-68, PGA-68
Memoria RAM: El formato SIPP
Como ya he comentado son memorias de tipo SIPP(Single In-line Pin Package) con una capacidad de 256KB, sumando los 4 módulos tenemos una cantidad de 1MB de RAM si, habéis leído bien, 1MB de RAM.
Módulo SIPP 30 contactos
No descarto hacerme con unos módulos de 1MB de 30 contactos y probar si son compatibles, según el manual de la placa, sería posible...
Esta "escasa" cantidad de RAM sera suficiente para cualquier sistema MS-DOS como veremos más adelante.
Controladora de discos y puertos ISA 16bits
Controladora UMC UM82C86F
Aquí me lleve una pequeña alegría ya que funciono sin ningún tipo de inconveniente.
Con capacidad para 2 puertos COM y 2 puertos LPT es en sus puertos para disquetera de 34 pines en el que irá conectada nuestra querida disquetera Gotek y otro puerto IDE de 40 pines en el que he conectado un adaptador de tarjetas CF a IDE y una tarjeta con capacidad para 512MB (488MB reales).
Uno de los componentes que mejoraba la experiencia en cuanto a juegos se refiere es la tarjeta de sonido y en este caso viene de la mano de Creative y su gama Sound Blaster.
La Sound Blaster Vibra de 16bits.
Esta tarjeta, como todas las anteriores viene en formato ISA de 16bits y aunque es un estándar a nivel de sonido le da a esta máquina vida propia.
Unidades de almacenamiento:
Disco duro
Una de las partes más importantes de cualquier ordenador, además del poder de computación, es donde almacenar información. Por desgracia este ordenador no tenía disco duro.
Esto me hizo recurrir al uso de un adaptador de CF a IDE junto con una tarjeta de 512MB.
Adaptador CF a IDE
Puede parecer poca memoria, pero hay que tener en cuenta que, de manera nativa, solo podemos disponer de 504MB, así que podemos decir que vamos sobrados de memoria.
Una de las cosas que más me gusta de este sistema es la ausencia de ruido. Estos equipos son especialmente ruidosos, así que al quitar el disco duro mecánico nos ahorramos unos cuantos dB's. No voy a negar que escuchar a un disco de esta época arrancar es todo un gustazo para los oídos, pero después de media hora ya empieza a cansar y más su tienes la caja abierta como suele ser mi caso.
Parámetros de Disco Duro BIOS (CHS)
Parámetro
Valor
Descripción
Cylinders (Cilindros)
933
Número de pistas por cara
Heads (Cabezas)
16
Número de superficies de lectura/escritura
Sectors (Sectores)
63
Sectores por pista (estándar IDE)
Precomp (Precompensación)
65535
Valor de precompensación (opcional)
LZone (Landing Zone)
992
Cilindro donde se estacionan las cabezas (Opcional)
Tamaño Total
488 MB
(≈ 512,000,000 bytes)
Unidad de Disco
Gotek con FlashFloppy
La unidad de disquetes de 1.44MB estaba prácticamente inservible. Sin pensarlo 2 veces me hice con una unidad Gotek.
Ya hemos hablado de la unidad Gotek en modo IBMpc en este blog, y es la navaja suiza para cualquier unidad de disco, sea de Alta o Baja densidad, con 720KB o 1.44MB respectivamente, tanto para unidades de 3 1/2 como unidades de 5 1/4.
Desde la Gotek cargaremos el sistema operativo e instalaremos programas y por supuesto, juegos.
El software
El sistema operativo
Aquí mi primera opción fue usar MS-DOS 6.22 (1994) pero la experiencia no fué muy buena, tener un 1MB no le gustaba mucho y aunque me consta que se puede ajustar, cambié a MS-DOS 5.0 (1991), un viejo conocido.
MS-DOS 5.0 fué mi primer contacto con un sistema operativo en el año 1992 en mi añorado 386SX.
Como tenía la misma cantidad de RAM sabía que no me iba a dar problemas, y así fue. Una vez se instaló arranco y apareció nuestro querido prompt: C:\.
Algunos de los primeros programas que tenía este sistema era el MS-DOS Shell (dosshell.com) un proto sistema operativo que además era bastante personalizable para su tiempo.
MS-DOS ya incluía el mítico editor de textos "edit.com" o "doskey.com", un potente administrador de la línea de comandos que añadía historial de comandos, edición de línea (con flechas) y la capacidad de crear macros. Estos programas cambiarían completamente la experiencia del usuario en la consola.
También hubo mejoras en el instalador "setup.com" que hacia que la instalación del sistema fuera mucho más intuitivo.
Versiones de Windows 1.0 y 2.0
Ya que no había problemas de espacio en la unidad CF quise probar las versiones más tempranas de Windows.
Windows 1.0 y Windows 2.0:
Una de las aplicaciones que inicio una revolución en cuanto a sistemas gráficos y que perdura hasta el día de hoy.
Iniciando Windows 1.0 su andadura comercial en 1985 y terminando a finales de 1987 para dar paso a una versión superior Windows 2.0 en sus versiones 286 y 386.
Al no utilizar las herramientas que traen, no me llamaron mucho la atención.
Sinceramente Dosshell se ve mucho mejor y es más rápido para probar programas y software de antaño.
Entretenimiento:
Al tener esta "locura" de almacenamiento, hay que tener en cuenta que el estándar era tener entre 40 y 105MB de almacenamiento, nos permite tener los suficientes juegos como para estar entretenidos una buenas sesiones.
Para tiempos de ocio y al contar con tarjeta de sonido, entretenerse con este ordenador es fácil, Juegos como Volfied, Duke Nukem2D o incluso Wolfenstein 3D pueden mantenerte durante horas pegado a la pantalla... veamos que juegos han funcionado y cuales no:
Funcionando:
Curse of Enchantia
Duke Nukem 2D
Indiana Jones y la última cruzada
Lemmings
Monkey Island 1
Monkey Island 2
Prince of Persia
Volfied
Wolfenstein 3D
Zool
No funcionan o necesitan equipo superior:
Alone in the Dark
Doom
Quake
Warcraft I y II
Todo el hardware y software esta disponible en los siguiente enlaces
En la era de las pantallas táctiles y los comandos por voz, hay algo profundamente satisfactorio en la interacción física: el clic tangible de un botón, la rotación precisa de un encoder, el feedback táctil que confirma una acción.
Hoy exploramos cómo combinar el poder del ESP32-C3 con elementos de control clásicos para crear interfaces que no solo funcionan, sino que se sienten bien al usar.
ESP32-C3 Super Mini: Todo el poder de un ESP32 en su mínima expresión
ESP32-C3 Super Mini pinout
Esta es la primera toma de contacto de este poderoso microcontrolador en una placa con unas dimensiones realmente reducidas.
Y salvo por algunas consideraciones a la hora de programarlo con el Arduino IDE 2, es igual de sencillo que cualquiera de sus hermanos mayores
Características más destacadas:
Procesador RISC-V de 160MHz con 1 núcleo - Pese a tener un solo núcleo tiene potencia suficiente para interfaces complejas.
GPIOs configurables - Podemos adaptar cada pin a nuestras necesidades.
Tamaño minimalista - Ideal para proyectos de reducidas dimensiones.
Bajo consumo - Su consumo optimizado para IoT y transiciones de sleep más rápidas le da una relación potencia/consumo perfecto para proyectos portátiles
Visualización con OLED SH1106 1.3"
128x64 píxeles - Espacio suficiente para mostrar menús e información de manera minimalista.
Un buen contraste - Legible en casi cualquier condición de luz.
Interfaz I2C - solo 2 cables necesarios
Encoder + 3 Botones: Control Total
Encoder rotativo - navegación precisa e infinita
Botón del encoder - confirmación rápida
Botón Confirm/Back - navegación intuitiva tipo "OK/Cancel"
Código Ejemplar: Sistema de Menús con Control Físico Completo
Aquí tienes un sistema completo que demuestra cómo integrar todos los componentes en una interfaz cohesiva:
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
// ================= CONFIGURACIÓN DE PINES =================
// Según tu configuración específica
#define PIN_CONFIRM 3 // Botón verde/confirmación
#define PIN_BACK 4 // Botón rojo/retroceso
#define PIN_ROTARY_BTN 2 // Botón del encoder
#define PIN_ROTARY_A 20 // Encoder fase A
#define PIN_ROTARY_B 21 // Encoder fase B
#define OLED_SDA 6 // Datos I2C
#define OLED_SCL 7 // Reloj I2C
// ================= CONFIGURACIÓN OLED =================
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
Adafruit_SH1106G display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// ================= VARIABLES GLOBALES =================
// Control del encoder
volatile int encoderPos = 0;
int lastEncoderPos = 0;
int rotaryAState;
int rotaryALastState;
// Estados de botones
bool btnConfirmPressed = false;
bool btnBackPressed = false;
bool btnRotaryPressed = false;
bool lastConfirmState = HIGH;
bool lastBackState = HIGH;
bool lastRotaryState = HIGH;
// Sistema de menús
int menuIndex = 0;
const int menuItems = 4;
String menuOptions[menuItems] = {
"Ajustar Brillo",
"Ver Contador",
"Config. Sistema",
"Info del Hardware"
};
// Variables de aplicación
int screenBrightness = 128; // 0-255
int counter = 0;
unsigned long lastActivity = 0;
// ================= INTERRUPCIÓN ENCODER =================
void IRAM_ATTR rotaryEncoderISR() {
delayMicroseconds(500);
rotaryAState = digitalRead(PIN_ROTARY_A);
if (rotaryAState != rotaryALastState) {
// Determinar dirección basándose en el estado de la fase B
if (digitalRead(PIN_ROTARY_B) != rotaryAState) {
encoderPos++; // Rotación horaria
} else {
encoderPos--; // Rotación antihoraria
}
}
rotaryALastState = rotaryAState;
}
// ================= FUNCIÓN DE TEMPERATURA =================
float readChipTemperature() {
// Simulación de temperatura basada en tiempo de actividad
// Para medición real, conectar sensor DS18B20 o similar
float baseTemp = 25.0;
float heatingFactor = millis() / 600000.0;
float tempC = baseTemp + fmin(heatingFactor, 10.0);
// Pequeña variación aleatoria para simular lectura real
static float lastTemp = baseTemp;
float variation = (random(-10, 11) / 100.0); // ±0.1°C
tempC = lastTemp + variation;
tempC = constrain(tempC, 24.0, 36.0);
lastTemp = tempC;
return tempC;
}
// ================= CONFIGURACIÓN INICIAL =================
void setup() {
Serial.begin(115200);
Serial.println("Iniciando Sistema de Control Físico");
// 1. Configurar I2C con pines personalizados
Wire.begin(OLED_SDA, OLED_SCL);
// 2. Inicializar pantalla OLED
if(!display.begin(0x3C, true)) {
Serial.println("Error al inicializar pantalla OLED");
while(true); // Detener si la pantalla falla
}
Serial.println("Pantalla OLED SH1106 inicializada");
// 3. Configurar pines de entrada
pinMode(PIN_CONFIRM, INPUT_PULLUP);
pinMode(PIN_BACK, INPUT_PULLUP);
pinMode(PIN_ROTARY_BTN, INPUT_PULLUP);
pinMode(PIN_ROTARY_A, INPUT_PULLUP);
pinMode(PIN_ROTARY_B, INPUT_PULLUP);
// 4. Configurar interrupción para encoder
rotaryALastState = digitalRead(PIN_ROTARY_A);
attachInterrupt(digitalPinToInterrupt(PIN_ROTARY_A),
rotaryEncoderISR, CHANGE);
// 5. Pantalla de bienvenida animada
showWelcomeAnimation();
Serial.println("Sistema listo para usar!");
}
// ================= BUCLE PRINCIPAL =================
void loop() {
// 1. Leer y procesar botones
readButtons();
// 2. Actualizar menú según encoder
updateMenuFromEncoder();
// 3. Dibujar interfaz actual
drawMainInterface();
// 4. Manejar inactividad
checkInactivity();
delay(50); // Control de tasa de refresco
}
// ================= FUNCIONES DE CONTROL =================
void readButtons() {
// Leer estados actuales
bool confirmState = digitalRead(PIN_CONFIRM);
bool backState = digitalRead(PIN_BACK);
bool rotaryState = digitalRead(PIN_ROTARY_BTN);
// Detectar flancos descendentes (pulsaciones)
if (confirmState == LOW && lastConfirmState == HIGH) {
btnConfirmPressed = true;
lastActivity = millis();
Serial.println("Botón CONFIRM pulsado");
}
if (backState == LOW && lastBackState == HIGH) {
btnBackPressed = true;
lastActivity = millis();
Serial.println("Botón BACK pulsado");
}
if (rotaryState == LOW && lastRotaryState == HIGH) {
btnRotaryPressed = true;
lastActivity = millis();
Serial.println("Botón ROTARY pulsado");
}
// Actualizar estados anteriores
lastConfirmState = confirmState;
lastBackState = backState;
lastRotaryState = rotaryState;
// Manejar acciones de botones
handleButtonActions();
}
void updateMenuFromEncoder() {
if (encoderPos != lastEncoderPos) {
lastActivity = millis(); // Registrar actividad
// Determinar dirección del cambio
if (encoderPos > lastEncoderPos) {
// Rotación hacia adelante
menuIndex = (menuIndex + 1) % menuItems;
Serial.print("Menú hacia adelante: ");
} else {
// Rotación hacia atrás
menuIndex = (menuIndex - 1 + menuItems) % menuItems;
Serial.print("Menú hacia atrás: ");
}
Serial.print(menuIndex);
Serial.print(" - ");
Serial.println(menuOptions[menuIndex]);
lastEncoderPos = encoderPos;
}
}
void handleButtonActions() {
if (btnConfirmPressed || btnRotaryPressed) {
// CONFIRM o ROTARY seleccionan la opción actual
executeMenuAction(menuIndex);
btnConfirmPressed = false;
btnRotaryPressed = false;
}
if (btnBackPressed) {
// BACK siempre vuelve al menú principal
Serial.println("Volviendo al menú principal");
btnBackPressed = false;
}
}
// ================= INTERFAZ GRÁFICA =================
void drawMainInterface() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
// 1. Encabezado
display.setCursor(35, 0);
display.println("MENU PRINCIPAL");
display.drawLine(0, 10, 128, 10, SH110X_WHITE);
// 2. Opciones del menú
for (int i = 0; i < menuItems; i++) {
display.setCursor(10, 12 + i * 12);
if (i == menuIndex) {
// Opción seleccionada (invertida)
display.setTextColor(SH110X_BLACK, SH110X_WHITE);
display.print("> ");
} else {
display.setTextColor(SH110X_WHITE);
display.print(" ");
}
display.println(menuOptions[i]);
}
// 3. Pie de página informativo
display.setTextColor(SH110X_WHITE);
display.setCursor(0, 56);
display.print("Pos: ");
display.print(encoderPos);
display.print(" | Sel: ");
display.print(menuIndex + 1);
display.print("/");
display.print(menuItems);
display.display();
}
// ================= ACCIONES DEL MENÚ =================
void executeMenuAction(int index) {
Serial.print("Ejecutando acción: ");
Serial.println(menuOptions[index]);
switch(index) {
case 0: // Ajustar Brillo
adjustBrightness();
break;
case 1: // Ver Contador
showCounter();
break;
case 2: // Config. Sistema
showSystemConfig();
break;
case 3: // Info Hardware
showHardwareInfo();
break;
}
// Redibujar menú principal después de la acción
display.clearDisplay();
}
// ================= MÓDULOS DE APLICACIÓN =================
void adjustBrightness() {
Serial.println("Ajustando brillo...");
int originalBrightness = screenBrightness;
int lastBrightnessPos = encoderPos;
bool exitAdjust = false;
while(!exitAdjust) {
// Actualizar brillo con encoder
if (encoderPos != lastBrightnessPos) {
int diff = encoderPos - lastBrightnessPos;
screenBrightness += diff * 5;
screenBrightness = constrain(screenBrightness, 0, 255);
lastBrightnessPos = encoderPos;
lastActivity = millis();
}
// Dibujar interfaz completa
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
// Encabezado
display.setCursor(20, 0);
display.println("AJUSTAR BRILLO");
display.drawLine(0, 10, 128, 10, SH110X_WHITE);
// Valor numérico
display.setCursor(40, 15);
display.print("Valor: ");
display.print(screenBrightness);
// Barra de progreso visual
display.drawRect(10, 30, 108, 12, SH110X_WHITE);
int barWidth = (screenBrightness * 104) / 255;
display.fillRect(12, 32, barWidth, 8, SH110X_WHITE);
// Indicadores de extremos
display.setCursor(10, 45);
display.print("Min");
display.setCursor(110, 45);
display.print("Max");
// Instrucciones
display.setCursor(5, 55);
display.print("Enc: +/- BACK:Salir");
display.display();
// Verificar si presionan BACK para salir
if (digitalRead(PIN_BACK) == LOW) {
delay(200);
exitAdjust = true;
Serial.print("Brillo final: ");
Serial.println(screenBrightness);
}
delay(50);
}
}
void showCounter() {
Serial.println("Mostrando contador...");
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.setCursor(30, 0);
display.println("CONTADOR");
display.drawLine(0, 10, 128, 10, SH110X_WHITE);
bool exitCounter = false;
int lastCounterPos = encoderPos;
while(!exitCounter) {
// Actualizar contador con encoder
if (encoderPos != lastCounterPos) {
if (encoderPos > lastCounterPos) {
counter++;
} else {
counter--;
}
lastCounterPos = encoderPos;
lastActivity = millis();
}
// Mostrar contador grande
display.setTextSize(3);
display.setCursor(40, 18);
display.println(counter);
// Instrucciones
display.setTextSize(1);
display.setCursor(5, 45);
display.print("Encoder: +/-");
display.setCursor(5, 55);
display.print("CONF:Reset BACK:Salir");
display.display();
display.clearDisplay();
// Verificar botones
if (digitalRead(PIN_BACK) == LOW) {
delay(200);
exitCounter = true;
}
if (digitalRead(PIN_CONFIRM) == LOW) {
delay(200);
counter = 0;
Serial.println("Contador reseteado");
}
delay(50);
}
}
void showSystemConfig() {
Serial.println("Mostrando configuración...");
bool exitConfig = false;
int configOption = 0;
String configOptions[] = {"Opcion A", "Opcion B", "Opcion C"};
bool configStates[] = {false, false, false};
while(!exitConfig) {
// Leer encoder para navegar
if (encoderPos != lastEncoderPos) {
if (encoderPos > lastEncoderPos) {
configOption = (configOption + 1) % 3;
} else {
configOption = (configOption - 1 + 3) % 3;
}
lastEncoderPos = encoderPos;
lastActivity = millis();
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.setCursor(20, 0);
display.println("CONFIGURACION");
display.drawLine(0, 10, 128, 10, SH110X_WHITE);
// Mostrar opciones
for (int i = 0; i < 3; i++) {
display.setCursor(10, 12 + i * 15);
if (i == configOption) {
display.setTextColor(SH110X_BLACK, SH110X_WHITE);
display.print("> ");
} else {
display.setTextColor(SH110X_WHITE);
display.print(" ");
}
display.print(configOptions[i]);
display.print(": ");
display.println(configStates[i] ? "ON" : "OFF");
}
// Instrucciones
display.setTextColor(SH110X_WHITE);
display.setCursor(0, 55);
display.print("CONF:Tog BACK:Salir");
display.display();
// Verificar botones
if (digitalRead(PIN_BACK) == LOW) {
delay(200);
exitConfig = true;
}
if (digitalRead(PIN_CONFIRM) == LOW) {
delay(200);
configStates[configOption] = !configStates[configOption];
Serial.print(configOptions[configOption]);
Serial.print(" cambiado a: ");
Serial.println(configStates[configOption] ? "ON" : "OFF");
}
delay(50);
}
}
void showHardwareInfo() {
Serial.println("Mostrando info hardware...");
bool exitInfo = false;
int infoPage = 0;
while(!exitInfo) {
if (encoderPos != lastEncoderPos) {
if (encoderPos > lastEncoderPos) {
infoPage = (infoPage + 1) % 2;
} else {
infoPage = (infoPage - 1 + 2) % 2;
}
lastEncoderPos = encoderPos;
lastActivity = millis();
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SH110X_WHITE);
display.setCursor(30, 0);
display.println("INFORMACION");
display.drawLine(0, 10, 128, 10, SH110X_WHITE);
if (infoPage == 0) {
// Página 1: Info General
display.setCursor(0, 15);
display.println("Hardware: ESP32-C3");
display.setCursor(0, 25);
display.print("RAM Libre: ");
display.print(ESP.getFreeHeap() / 1024);
display.println(" KB");
display.setCursor(0, 35);
display.print("Flash Total: ");
display.print(ESP.getFlashChipSize() / 1024 / 1024);
display.println(" MB");
display.setCursor(0, 45);
display.print("Uptime: ");
display.print(millis() / 1000);
display.println(" seg");
display.setCursor(0, 55);
display.println("Enco:Temp BACK:salir");
} else {
// Página 2: Temperatura (NUEVO)
float tempC = readChipTemperature();
display.setCursor(10, 15);
display.println("== TEMPERATURA ==");
display.setCursor(25, 28);
display.setTextSize(2);
display.print(tempC, 1);
display.print(" C");
display.setTextSize(1);
// Barra de temperatura
display.setCursor(5, 46);
display.print("[");
int tempBar = map(constrain(tempC, 24, 36), 24, 36, 0, 20);
for (int i = 0; i < 18; i++) {
display.print(i < tempBar ? "#" : ".");
}
display.print("]");
display.setCursor(10, 56);
display.print("24C");
display.setCursor(105, 56);
display.print("36C");
}
display.display();
if (digitalRead(PIN_BACK) == LOW) {
delay(200);
exitInfo = true;
}
delay(50);
}
}
// ================= FUNCIONES AUXILIARES =================
void showWelcomeAnimation() {
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(SH110X_WHITE);
// Animación de entrada
for(int i = 0; i < 3; i++) {
display.clearDisplay();
display.setCursor(20, 20);
display.println("SISTEMA");
display.setCursor(25, 40);
display.println("LISTO");
display.display();
delay(300);
display.clearDisplay();
delay(300);
}
// Dibujar marco final
display.clearDisplay();
display.drawRect(0, 0, 128, 64, SH110X_WHITE);
display.setCursor(35, 28);
display.println("READY");
display.display();
delay(1000);
}
void checkInactivity() {
unsigned long inactivityTime = millis() - lastActivity;
// Apagar pantalla después de 30 segundos de inactividad
if (inactivityTime > 30000) {
display.clearDisplay();
display.setCursor(30, 28);
display.println("Modo Sleep");
display.display();
// Esperar actividad
while(millis() - lastActivity > 30000) {
if (digitalRead(PIN_CONFIRM) == LOW ||
digitalRead(PIN_BACK) == LOW ||
digitalRead(PIN_ROTARY_BTN) == LOW ||
encoderPos != lastEncoderPos) {
lastActivity = millis();
break;
}
delay(100);
}
}
}
Explicación Detallada del Código
Estructura Modular
El código está organizado en diferectes secciones:
Configuración de pines: Define cada conexión física entre el microcontrolador y el módulo.
Variables globales: Estado del sistema centralizado.
Interrupciones: Para una respuesta inmediata del encoder.
Funciones principal: Las clásicas funciones setup() y loop().
Módulos específicos: Funciones especificas para la pantalla y el menú.
2. Sistema de Menús Inteligente
Navegación circular:
El encoder rota de manera infinita por las opciones.
Selección visual: La opción actual se muestra invertida
Acciones contextuales: Cada menú tiene comportamiento único
3. Control de Botones con Debounce
// Detección de flancos (no estado sostenido)
if (confirmState == LOW && lastConfirmState == HIGH) {
// Solo se activa al presionar, no al mantener
}
Con este código evitamos múltiples activaciones con una sola pulsación.
4. Módulos Interactivos Demostrativos
Ajuste de brillo: Control analógico con barra visual.
Contador: Incremento/decremento con encoder
Configuración: Menú de opciones ON/OFF
Infornación del sistema: Datos del hardware en tiempo real.
Conclusión: Una combinación casi perfecta
Este proyecto demuestra que incluso en la era digital, la interacción física sigue siendo relevante.
Con la combinación del microcontrolador ESP32-C3 + OLED + Encoder + Botones nos da:
Retroalimentación tangible: Sabes que has realizado una acción
Precisión: El encoder permite ajustes finos imposibles en pantallas táctiles
Confiabilidad: Botones físicos funcionan en cualquier condición
Experiencia de usuario: Hay placer en usar controles bien diseñados
Despedida: Tu Turno para Crear
Este código es solo el punto de partida. Imagina lo que puedes construir:
Un controlador MIDI para música electrónica
Una interfaz para una consola retro
Un panel de control para tu taller
Un sistema de menús para un robot
La belleza del ESP32-C3 Super Mini con OLED SH1106 está en su versatilidad. Es lo suficientemente potente para proyectos complejos, pero lo suficientemente accesible para principiantes.
¡Copia el código, conecta los componentes y siente la satisfacción de controlar algo físico! Comparte tus creaciones y variaciones - la comunidad maker crece cuando compartimos.
Puedes visitar los siguiente enlaces relacionados con pantallas y Arduino:
Seguimos investigando nuevas funciones para el módulo GPS GY-NEO6MV2, un módulo sencillo y económico que podemos implementar en cualquier microcontrolador, desde un Arduino UNO a un ESP32.
Pero como no siempre podemos tener un dispositivo conectado para leer los datos del GPS una solución sencilla es almacenarlos.
En este caso vamos el ESP8266 que será capaz de almacenar los datos relacionados con la posición, velocidad o altitud en su memoria interna, alrededor de 4MB. (Este valor puede variar según el modelo).
Los datos se almacenan en un archivo con formato JSON al ser uno de los formatos de intercambio de datos sencillo de escribir, ligero y eficiente. Ideal para microcontroladores.
Este archivo con los datos en formato JSON los descargaremos con un programa escrito en Python3 para poder procesarlos posteriormente a diferentes formatos como KML o GPX, muy utilizados por dispositivos deportivos, aplicaciones móviles como GAIA GPS o programas como Google Earth.
Componentes para GPS tracker con Wemos D1 Mini
Para este proyecto solo vamos a necesitar:
ESP8266 Wemos D1 Mini (clon)
Módulo GPS GY-NEO6MV2
Batería
La conexión de los dispositivos GY-NEO6MV2 y Wemos D1 Mini
Librerías necesarias para Wemos D1 Mini
Para programar el Wemos D1 usaremos el IDE de Arduino y las siguiente librerías necesarias para la instalación del sketch:
ESP8266Wifi
Gestiona la conexión WiFi en modo AP o cliente del ESP8266. Para este proyecto usaremos el modo AP.
LittleFS
Sistema de archivos para almacenar datos en la memoria flash (similar a SPIFFS).
TinyGPSPlus
Decodifica datos NMEA del módulo GPS y proporciona métricas (lat, lng, alt, etc.).
ESP8266WebServer
Crea un servidor HTTP para manejar peticiones REST (GET/DELETE).
SoftwareSerial
Permite comunicación serial con hardware no nativo (GPS en este caso).
ArduinoJSON
Serializa/deserializa datos JSON para la API REST.
Veamos el flujo de trabajo para el ESP8266 Wemos D1 Mini
🖥️ Procesamiento: Wemos D1 Mini
Para que todo el proyecto funcione correctamente necesitaremos algunas librerías externas:
Decodificación de datos GPS: Convierte el formato NMEA a valores útiles como latitud, longitud, altitud o el número de satélites usando la librería TinyGPSPlus.
Formato de datos JSON: Serialización y estructura de datos en formato JSON usando la librería ArduinoJson
DynamicJsonDocument doc(200); // Crea JSON de 200 bytes
doc["sat"] = String(gps.satellites.value()); // Satélites visibles
doc["lat"] = gps.location.lat(); // Latitud
doc["lng"] = gps.location.lng(); // Longitud
doc["tim"] = gps.time.value(); // Hora (formato hhmmsscc)
doc["alt"] = gps.altitude.meters(); // Altitud en metros
doc["spe"] = gps.speed.kmph(); // Velocidad en km/h
String response;
serializeJson(doc, response); // Convierte JSON a String
Almacenamiento con LittleFS:
Guarda datos o elimina el archivo en formato JSON en la memoria interna haciendo uso de la librería LittleFS.
LittleFS.open("/gps_data.json", "a"); // Abre el archivo "gps_data.json" en el directrorio raíz "/".
LittleFS.remove("/gps_data.json"); // Elimina el archivo "gps_data.json" en el directrorio raíz "/".
Servicio Web con 3 endpoints:
Crearemos un servidor web con la librería ESP8266WebServer que se instala cuando instalamos la placa ESP8266.
ESP8266WebServer server(80); // Creamos el servidor en el puerto 80.
server.on("/gps", HTTP_GET, handleGPSData); // GET /gps → Datos actuales
server.on("/download", HTTP_GET, handleDownload); // GET /download → Archivo JSON
server.on("/clear", HTTP_DELETE, handleClear); // DELETE /clear → Borra datos
Los Endpoints son rutas URL con un metodo HTTP asignado a una función:
"/gps", HTTP_GET, handleGPSData:
Con esta petición obtenemos los datos en tiempo real desde la función: handleGPSData.
"/download", HTTP_GET, handleDownload: Descarga el historial completo a un archivo con extensión JSON desde la función:
handleDownload.
"/clear", HTTP_DELETE, handleClear: Borra el el archivo con el historial con la función asignada handleClear.
Para comprobar que todo esta correcto podemos abrir el monitor serial y tiene que aparecer una salida similar a la que se muestra a continuación:
Obtención de datos GY-NEO6MV2 desde el navegador
Esta opción es la más sencilla de todas si solo necesitas el archivo JSON. Solo tienes que conectarte con la IP generada por ESP8266, normalmente la 192.168.4.1.
Podemos usar cualquiera de los 3 Endpoints definidos:
http://192.168.4.1/gps
http://192.168.4.1/download
http://192.168.4.1/remove con este comando borraremos la memoria del Wemos Mini D1
Obtención de datos desde Python
Otra de las opciones para descargar los datos es el programa que tenemos a continuación:
import requests
import json
# Configuración
IP_WEMOS = "192.168.1.41" # Reemplaza con la IP de tu Wemos
# 1. Obtener último dato (endpoint /gps)
response = requests.get(f"http://{IP_WEMOS}/gps")
if response.status_code == 200:
data = response.json()
print("Última posición:", data["lat"], data["lng"])
# 2. Descargar archivo completo (endpoint /download)
response = requests.get(f"http://{IP_WEMOS}/download")
if response.status_code == 200:
with open("gps_data.json", "wb") as f:
f.write(response.content)
print("Archivo descargado!")
Sencillo pero efectivo, utiliza dos de los EndPoints, /gps y /download que ya hemos visto más arriba y nos proporcionara un archivo llamado gps_data.json.py.
Obtención y procesamiento de datos desde Python
Pero con los datos del JSON "en crudo" es difícil aplicarlos a otras aplicaciones ya que no es un estándar. Para tener una mayor compatibilidad con otros dispositivos existen otros formatos de intercambio de datos como KML y GPX
El formato KML (Keyhole Markup Language)
Es el formato nativo de Google Earth/Google Maps y es ideal para visualización en mapas interactivos y también incluye estilos, colores, iconos personalizables.
Es el estándar universal para dispositivos GPS, creado específicamente para GPS (no adaptado como KML) y el formato nativo de la mayoría de dispositivos GPS
Metadata completa: elevación, tiempo, satélites, etc.
Esta conversión de formato JSON a KML o GPX la podemos realizar de la mano Python y un programa que reúne todo lo que se ha visto hasta ahora. Conexión, descarga, visualización y conversión.
Esta diseñado para su uso en terminal y con el que obtendremos el archivo .json y lo convertirá a uno de los dos formatos, KML y GPX de manera independiente, o a los dos formatos al mismo tiempo.
Estas son las opciones del menú:
1. Descargar datos del WEMOS (JSON)
2. Cargar datos desde archivo JSON
3. Convertir a KML (Google Earth)
4. Convertir a GPX (Dispositivos GPS)
5. Convertir a AMBOS formatos
6. Ver información del WEMOS
0. Salir
Una vez terminado ya tenemos los archivos con las extensiones KML y GPX.
Vamos a utilizar el archivo generado en formato KML para mostrar la ruta en Google Maps:
Abrimos el navegador en la ruta earth.google.com y en la barra lateral izquierda nos aparece un sigo +.
Se abrirá un desplegable y seleccionamos "Importar KML/KMZ"
Y este es el resultado final de la importacion
Conclusión del proyecto:
La comunicación entre el ESP8266 WEMOS D1 Mini y el módulo GPS GY-NEO6MV2 ha demostrado ser una solución funcional y eficiente para la captura de datos de geolocalización.
El sistema constituye una base sólida para proyectos que requieran tracking GPS, pudiendo extenderse hacia aplicaciones de monitorización de flotas, seguimiento deportivo o recopilación de datos para análisis geográfico.
Es un ejemplo que podemos modular el diseño facilitando la incorporación de sensores adicionales como el ADXL345 o el MPU6050.