Hardware components | ||||||
![]() |
| × | 1 | |||
![]() |
| × | 1 | |||
| × | 1 | ||||
Software apps and online services | ||||||
![]() |
| |||||
A while ago, I came across an old BetaBrite LED sign tucked away in storage, forgotten and collecting dust. I remembered seeing these bright scrolling signs in many shops and thought it would be awesome to bring it back to life for fun or simple notifications at home. The only problem: I had absolutely no idea how to program or control it.
At first, the project felt a bit intimidating. While I’m comfortable tinkering with microcontrollers, the BetaBrite used an old-school serial protocol and I couldn’t find simple, ready-to-go tutorials. After some research, I discovered a few articles and open source libraries that offered leads but no all-in-one solution for modern, wireless use.
Determined to make it work without needing to recompile or reflash code for each change, I decided to create my own system: a way to control every detail of the sign—messages, effects, even WiFi credentials—directly from a friendly web interface. Relying on an ESP8266 to handle WiFi and web server duties, I implemented a solution where I could tweak everything from my phone or PC, save new settings, and have them persist even after power-cycling.
Working through this project taught me a ton—about protocol hacking, web interfaces, and making vintage hardware fit into the IoT era. Now, my old BetaBrite is anything but obsolete: it’s a live, configurable display that’s ready for any use I imagine, whether as a status board or a creative notifier
ESP8266 TX D1----> T1IN (MAX232)
ESP8266 RX D2 <---- R1OUT (MAX232)
GND (ESP8266) --- GND (MAX232) --- GND (BetaBrite, DB9 pin 5)
TX OUT (MAX232) --> BetaBrite RX (DB9 pin 3) GREEN CABLE RJ12
RX IN (MAX232) <-- BetaBrite TX (DB9 pin 2) YELLOW CABLE RJ12
ONLY USE VCC 5V FROM POWER SUPPLY ESP8266
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
#include <SoftwareSerial.h>
#include <FS.h>
#include <WiFiUdp.h>
#include <NTPClient.h>
#include <EEPROM.h>
#include <time.h>
// --- EEPROM parameters ---
#define EEPROM_SIZE 192
#define STA_SSID_ADDR 0
#define STA_PASS_ADDR 48
#define AP_SSID_ADDR 96
#define AP_PASS_ADDR 144
#define WIFI_CRED_MAXLEN 47
// --- Efectos y colores ---
struct Effect { String name; String code; };
Effect effects[] = {
{"ROTACION", "a"}, {"FIJO", "b"}, {"PARPADEO", "c"},
{"ARRIBA", "e"}, {"ABAJO", "f"}, {"IZQUIERDA", "g"}, {"DERECHA", "h"},
{"BARRIDO ARRIBA", "i"}, {"BARRIDO ABAJO", "j"}, {"BARRIDO IZQ", "k"}, {"BARRIDO DER", "l"},
{"DESPLAZAMIENTO", "m"}, {"AUTO", "o"}, {"CENTRO IN", "p"}, {"CENTRO OUT", "q"},
{"EXPLOSION", "u"}, {"ROT. CORTO", "t"}, {"ESTRELLAS", "n7"}, {"BRILLO", "n1"},
{"NIEVE", "n2"}, {"BIENVENIDA", "n8"}, {"NOTICIAS", "A"}
};
struct ColorOption { String name; String code; };
ColorOption colors[] = { {"Automatico", "0"}, {"Rojo", "1"}, {"Verde", "2"}, {"Ambar", "3"} };
SoftwareSerial alphaSign(D2, D1);
const int numMessages = 10;
bool messageEnabled[numMessages] = {true, true, true, true, true, true, true, true, true, true};
String messages[numMessages] = { "Mensaje 1 por defecto", "Mensaje 2 por defecto", "Mensaje 3 por defecto",
"Mensaje 4 por defecto", "Mensaje 5 por defecto", "Mensaje 6 por defecto",
"Mensaje 7 por defecto", "Mensaje 8 por defecto", "Mensaje 9 por defecto", "Mensaje 10 por defecto" };
String selectedEffects[numMessages] = {"a", "b", "c", "e", "f", "g", "h", "i", "j", "k"};
String selectedColors[numMessages] = {"0", "0", "0", "0", "0", "0", "0", "0", "0", "0"};
int intervalSeconds = 5;
int displaySpeed = 3;
String wifiSsid = "your_wifi";
String wifiPass = "yourpass";
String apSsid = "BetaBrite_AP";
String apPass = "12345678";
String relojOpcion = "ambos"; // "reloj", "fecha", "ambos", "ninguno"
ESP8266WebServer server(80);
WiFiUDP ntpUDP;
NTPClient* timeClient = nullptr;
volatile bool configurando = false;
bool mostrarIP = false;
unsigned long tiempoInicioIP = 0;
#define TIEMPO_MOSTRAR_IP 10000UL // 1 min
bool apFallback = false;
// ------- Prototipos ----------
void saveConfig();
void loadConfig();
void saveWiFiEEPROM();
void loadWiFiEEPROM();
String quitarAcentos(String texto);
void enviarTexto(String texto, String efecto, String color);
void enviarFechaHoraBetaBrite();
void handleRoot();
void handleSave();
void mostrarIpEnBetabrite(const char* ip);
// --- Funciones EEPROM para strings ---
// Guarda un String en EEPROM desde la dirección y hasta WIFI_CRED_MAXLEN caracteres
void saveStringToEEPROM(int addr, const String& val) {
byte len = min((int)val.length(), WIFI_CRED_MAXLEN);
EEPROM.write(addr, len); // longitud
for (int i=0; i<len; i++) EEPROM.write(addr + 1 + i, val[i]);
EEPROM.write(addr + 1 + len, 0); // null terminator extra, para seguridad
}
// Lee un String desde EEPROM
String readStringFromEEPROM(int addr) {
int len = EEPROM.read(addr);
if (len > WIFI_CRED_MAXLEN) len = WIFI_CRED_MAXLEN; // protección básica
char data[WIFI_CRED_MAXLEN + 1];
for (int i=0; i<len; i++) data[i] = EEPROM.read(addr + 1 + i);
data[len] = 0;
return String(data);
}
// Guarda credenciales de WiFi y AP en EEPROM
void saveWiFiEEPROM() {
EEPROM.begin(EEPROM_SIZE);
saveStringToEEPROM(STA_SSID_ADDR, wifiSsid);
saveStringToEEPROM(STA_PASS_ADDR, wifiPass);
saveStringToEEPROM(AP_SSID_ADDR, apSsid);
saveStringToEEPROM(AP_PASS_ADDR, apPass);
EEPROM.commit();
EEPROM.end();
}
// Recupera credenciales de WiFi y AP desde EEPROM sólo si las cadenas actuales están vacías
void loadWiFiEEPROM() {
EEPROM.begin(EEPROM_SIZE);
if (wifiSsid.length() == 0) wifiSsid = readStringFromEEPROM(STA_SSID_ADDR);
if (wifiPass.length() == 0) wifiPass = readStringFromEEPROM(STA_PASS_ADDR);
if (apSsid.length() == 0) apSsid = readStringFromEEPROM(AP_SSID_ADDR);
if (apPass.length() == 0) apPass = readStringFromEEPROM(AP_PASS_ADDR);
EEPROM.end();
}
void setup() {
SPIFFS.begin();
loadConfig(); // SPIFFS
loadWiFiEEPROM(); // Si SPIFFS 'olvida', recupera de EEPROM
alphaSign.begin(9600);
Serial.begin(115200);
bool intentarSTA = wifiSsid.length() > 0 && wifiPass.length() >= 8;
if (intentarSTA) {
WiFi.mode(WIFI_STA);
WiFi.begin(wifiSsid.c_str(), wifiPass.c_str());
Serial.print("Intentando conectar a WiFi STA: ");
Serial.println(wifiSsid);
int wifiTimer = 0;
while (WiFi.status() != WL_CONNECTED && wifiTimer < 20000) {
delay(500);
Serial.print(".");
wifiTimer += 500;
}
Serial.println();
}
if (WiFi.status() == WL_CONNECTED) {
Serial.print("WiFi conectada. IP: ");
Serial.println(WiFi.localIP());
mostrarIP = true;
tiempoInicioIP = millis();
timeClient = new NTPClient(ntpUDP, "pool.ntp.org", -6 * 3600, 60000);
timeClient->begin();
apFallback = false;
} else {
Serial.println("No se pudo conectar como STA. Lanzando AP de configuracion.");
WiFi.disconnect(true);
delay(200);
WiFi.mode(WIFI_AP);
delay(500);
IPAddress local_ip(192, 168, 4, 1), gateway(192, 168, 4, 1), subnet(255, 255, 255, 0);
WiFi.softAPConfig(local_ip, gateway, subnet);
WiFi.softAP(apSsid.c_str(), apPass.c_str());
apFallback = true;
}
server.on("/", handleRoot);
server.on("/save", handleSave);
// --- Handler de RESET seguro ---
server.on("/reboot", []() {
server.send(200, "text/html", "<h1>Reiniciando...</h1>");
delay(1000);
ESP.restart();
});
server.begin();
Serial.println("Servidor web listo.");
}
void loop() {
server.handleClient();
if (mostrarIP && !apFallback && WiFi.status() == WL_CONNECTED) {
if (millis() - tiempoInicioIP < TIEMPO_MOSTRAR_IP) {
mostrarIpEnBetabrite(WiFi.localIP().toString().c_str());
delay(3000);
return;
} else {
mostrarIP = false;
}
}
if (!configurando) {
if (!apFallback && WiFi.status() == WL_CONNECTED && timeClient != nullptr &&
relojOpcion != "ninguno") {
enviarFechaHoraBetaBrite();
delay(intervalSeconds * 1000);
server.handleClient();
yield();
}
for (int i = 0; i < numMessages; i++) {
if (messageEnabled[i]) {
String mensajeSinAcentos = quitarAcentos(messages[i]);
enviarTexto(mensajeSinAcentos, selectedEffects[i], selectedColors[i]);
delay(intervalSeconds * 1000);
server.handleClient();
yield();
}
}
}
}
void mostrarIpEnBetabrite(const char* ip) {
String ipmsg = ip;
enviarTexto(ipmsg, "t", "0");
}
void enviarFechaHoraBetaBrite() {
if (!timeClient) return;
timeClient->update();
time_t now = timeClient->getEpochTime();
struct tm *tmstruct = localtime(&now);
int hour = tmstruct->tm_hour;
int minute = tmstruct->tm_min;
int month = tmstruct->tm_mon + 1;
int day = tmstruct->tm_mday;
int year = tmstruct->tm_year % 100;
char fechaText[11], horaText[6];
sprintf(fechaText, "%02d/%02d/%02d", day, month, year);
sprintf(horaText, "%02d:%02d", hour, minute);
if (relojOpcion == "fecha") {
enviarTexto(fechaText, "b", "0");
} else if (relojOpcion == "reloj") {
enviarTexto(horaText, "b", "0");
} else if (relojOpcion == "ambos") {
String todo = String(horaText) + " - " + String(fechaText);
enviarTexto(todo, "t", "0");
}
}
String quitarAcentos(String texto) {
String limpio = "";
for (unsigned int i = 0; i < texto.length(); i++) {
char c = texto.charAt(i);
switch (c) {
case 'á': case 'Á': c = 'a'; break;
case 'é': case 'É': c = 'e'; break;
case 'í': case 'Í': c = 'i'; break;
case 'ó': case 'Ó': c = 'o'; break;
case 'ú': case 'Ú': c = 'u'; break;
case 'ü': case 'Ü': c = 'u'; break;
case 'ñ': case 'Ñ': c = 'n'; break;
case 'ç': case 'Ç': c = 'c'; break;
}
if ((unsigned char)c > 127) c = '?';
limpio += c;
}
return limpio;
}
void enviarTexto(String texto, String efecto, String color) {
const byte velocidades[5] = {0x15, 0x16, 0x17, 0x18, 0x19};
uint8_t speedIndex = (displaySpeed >= 1 && displaySpeed <= 5) ? displaySpeed - 1 : 2;
for (int i = 0; i < 5; i++) alphaSign.write(0x00);
alphaSign.write(0x01);
alphaSign.write('Z');
alphaSign.write('0');
alphaSign.write('0');
alphaSign.write(0x02);
alphaSign.write('A');
alphaSign.write('A');
alphaSign.write(0x1B);
alphaSign.write(0x20);
alphaSign.print(efecto);
alphaSign.write(velocidades[speedIndex]);
if (color != "0") {
alphaSign.write(0x1C);
alphaSign.write(color.c_str()[0]);
}
alphaSign.print(texto);
alphaSign.write(0x04);
}
void handleRoot() {
String html = "<!DOCTYPE html><html>";
html += "<head><style>";
html += "body { font-family: Arial, sans-serif; background: linear-gradient(to bottom, #74ABE2, #5563DE); color: white; margin: 0; padding: 20px; }";
html += "h1 { text-align: center; font-size: 36px; margin-bottom: 20px; }";
html += "form { max-width: 600px; margin: auto; background: #2F3C87; padding: 20px; border-radius: 8px; }";
html += "h3 { color: #F4D03F; font-size: 22px; }";
html += "label { display: block; margin-top: 10px; }";
html += "input[type='text'], input[type='number'], select { width: calc(100% - 20px); padding: 8px; margin: 10px 0; }";
html += "input[type='checkbox'] { margin-right: 10px; }";
html += "input[type='submit'], button { background: #F4D03F; border: none; padding: 10px 20px; color: black; font-size: 16px; cursor: pointer; border-radius: 4px; margin-top:8px; }";
html += "input[type='submit']:hover, button:hover { background: #FFC300; }";
html += "</style></head><body>";
html += "<h1>Configuracion BetaBrite 213c-1</h1>";
html += "<form action='/save' method='POST'>";
for (int i = 0; i < numMessages; i++) {
html += "<h3>Mensaje " + String(i + 1) + "</h3>";
html += "<label><input type='checkbox' name='enable" + String(i) + "' " + (messageEnabled[i] ? "checked" : "") + "> Habilitar</label>";
html += "<label for='message" + String(i) + "'>Texto:</label>";
html += "<input type='text' name='message" + String(i) + "' value='" + quitarAcentos(messages[i]) + "'>";
html += "<label for='effect" + String(i) + "'>Efecto:</label>";
html += "<select name='effect" + String(i) + "'>";
for (unsigned int e = 0; e < sizeof(effects)/sizeof(effects[0]); e++) {
html += "<option value='" + effects[e].code + "' " + (selectedEffects[i] == effects[e].code ? "selected" : "") + ">" + effects[e].name + "</option>";
}
html += "</select>";
html += "<label for='color" + String(i) + "'>Color:</label>";
html += "<select name='color" + String(i) + "'>";
for (unsigned int c = 0; c < sizeof(colors)/sizeof(colors[0]); c++) {
html += "<option value='" + colors[c].code + "' " + (selectedColors[i] == colors[c].code ? "selected" : "") + ">" + colors[c].name + "</option>";
}
html += "</select>";
}
html += "<h3>Intervalo entre mensajes (segundos):</h3>";
html += "<input type='number' name='interval' value='" + String(intervalSeconds) + "' min='1'><br><br>";
html += "<h3>Velocidad del mensaje (1=lento, 5=rapido):</h3>";
html += "<input type='number' name='speed' min='1' max='5' value='" + String(displaySpeed) + "'><br><br>";
html += "<h3>Configuracion de reloj/fecha</h3>";
html += "<label for='relojOpcion'>Mostrar en pantalla:</label>";
html += "<select name='relojOpcion'>";
html += "<option value='reloj' " + (relojOpcion == "reloj" ? String("selected") : String("")) + ">Solo reloj</option>";
html += "<option value='fecha' " + (relojOpcion == "fecha" ? String("selected") : String("")) + ">Solo fecha</option>";
html += "<option value='ambos' " + (relojOpcion == "ambos" ? String("selected") : String("")) + ">Reloj y Fecha</option>";
html += "<option value='ninguno' " + (relojOpcion == "ninguno" ? String("selected") : String("")) + ">Ninguno</option>";
html += "</select><br><br>";
html += "<h3>Wifi a conectar (STA)</h3>";
html += "<label for='wifiSsid'>SSID:</label>";
html += "<input type='text' name='wifiSsid' value='" + wifiSsid + "'><br>";
html += "<label for='wifiPass'>Password:</label>";
html += "<input type='password' name='wifiPass' value='" + wifiPass + "'><br>";
html += "<h3>Wifi de respaldo (AP propio)</h3>";
html += "<label for='apSsid'>SSID AP:</label>";
html += "<input type='text' name='apSsid' value='" + apSsid + "'><br>";
html += "<label for='apPass'>Password AP:</label>";
html += "<input type='password' name='apPass' value='" + apPass + "'><br>";
html += "<input type='submit' value='Guardar'>";
html += "<button type='button' style='background:#D32F2F; color:white; font-weight:bold; margin-left:10px;' onclick=\"if(confirm('¿Seguro que quieres reiniciar el dispositivo?')){fetch('/reboot').then(()=>window.location.reload())}\">Reiniciar dispositivo</button>";
html += "</form></body></html>";
server.send(200, "text/html", html);
}
void handleSave() {
configurando = true;
for (int i = 0; i < numMessages; i++) {
String txt = "";
if (server.hasArg("message" + String(i))) {
txt = server.arg("message" + String(i));
txt.trim();
txt = quitarAcentos(txt);
if (txt.length()) messages[i] = txt;
}
if (server.hasArg("effect" + String(i))) {
String tmp = server.arg("effect" + String(i));
tmp.trim();
selectedEffects[i] = tmp;
}
if (server.hasArg("color" + String(i))) {
String tmp = server.arg("color" + String(i));
tmp.trim();
selectedColors[i] = tmp;
}
messageEnabled[i] = server.hasArg("enable" + String(i));
}
if (server.hasArg("interval")) intervalSeconds = server.arg("interval").toInt();
if (server.hasArg("speed")) displaySpeed = constrain(server.arg("speed").toInt(), 1, 5);
if (server.hasArg("relojOpcion")) {
String tmp = server.arg("relojOpcion");
tmp.trim();
relojOpcion = tmp;
}
if (server.hasArg("wifiSsid")) {
String tmp = server.arg("wifiSsid");
tmp.trim();
wifiSsid = tmp;
}
if (server.hasArg("wifiPass")) {
String tmp = server.arg("wifiPass");
tmp.trim();
wifiPass = tmp;
}
if (server.hasArg("apSsid")) {
String tmp = server.arg("apSsid");
tmp.trim();
apSsid = tmp;
}
if (server.hasArg("apPass")) {
String tmp = server.arg("apPass");
tmp.trim();
apPass = tmp;
}
saveConfig(); // SPIFFS
saveWiFiEEPROM(); // EEPROM
server.send(200, "text/html", "<h1>Configuracion guardada!</h1><a href='/'>Regresar</a>");
configurando = false;
}
void saveConfig() {
File f = SPIFFS.open("/config.txt", "w");
if (!f) { Serial.println("Error al abrir config.txt para escritura!"); return; }
for (int i = 0; i < numMessages; i++) {
f.printf("%s|%d|%s|%s\n", messages[i].c_str(), messageEnabled[i], selectedEffects[i].c_str(), selectedColors[i].c_str()); yield();
}
f.printf("INT:%d\n", intervalSeconds);
f.printf("SPD:%d\n", displaySpeed);
f.printf("SSID_STA:%s\n", wifiSsid.c_str());
f.printf("PW_STA:%s\n", wifiPass.c_str());
f.printf("SSID_AP:%s\n", apSsid.c_str());
f.printf("PW_AP:%s\n", apPass.c_str());
f.printf("RELOJ:%s\n", relojOpcion.c_str());
f.flush();
f.close();
Serial.println("Configuracion guardada en SPIFFS."); yield();
}
void loadConfig() {
File f = SPIFFS.open("/config.txt", "r");
if (!f) return;
int idx = 0;
wifiSsid = "";
wifiPass = "";
apSsid = "";
apPass = "";
while (f.available() && idx < numMessages) {
String line = f.readStringUntil('\n');
if (line.startsWith("INT:")) { intervalSeconds = line.substring(4).toInt(); continue; }
if (line.startsWith("SPD:")) { displaySpeed = line.substring(4).toInt(); continue; }
if (line.startsWith("SSID_STA:")) { String tmp = line.substring(9); tmp.trim(); wifiSsid = tmp; continue; }
if (line.startsWith("PW_STA:")) { String tmp = line.substring(7); tmp.trim(); wifiPass = tmp; continue; }
if (line.startsWith("SSID_AP:")) { String tmp = line.substring(8); tmp.trim(); apSsid = tmp; continue; }
if (line.startsWith("PW_AP:")) { String tmp = line.substring(6); tmp.trim(); apPass = tmp; continue; }
if (line.startsWith("RELOJ:")) { String tmp = line.substring(6); tmp.trim(); relojOpcion = tmp; continue; }
int sep1 = line.indexOf('|'); int sep2 = line.indexOf('|', sep1+1); int sep3 = line.indexOf('|', sep2+1);
if (sep1 > 0 && sep2 > sep1 && sep3 > sep2) {
String msg = line.substring(0, sep1); msg.trim(); msg = quitarAcentos(msg);
if (msg.length()) messages[idx] = msg;
messageEnabled[idx] = line.substring(sep1+1, sep2).toInt() ? true : false;
String e = line.substring(sep2+1, sep3); e.trim();
selectedEffects[idx] = e;
String c = line.substring(sep3+1); c.trim();
selectedColors[idx] = c;
idx++;
}
}
f.close();
}













Comments