Proyecto: Tagboard
El proyecto Tagboard implementa un tablero de mensajes ligero ejecutándose en ESP32 con AtomVM. La solución combina un backend en Elixir que expone una API HTTP y sirve archivos estáticos, con un frontend basado en Web Components.
Arquitectura General
El sistema sigue una arquitectura cliente-servidor minimalista:
-
Backend: Servidor HTTP integrado en AtomVM que proporciona
-
Una API REST para crear y recuperar tags
-
Servicio de archivos estáticos para el frontend
-
Almacenamiento persistente en NVS (Non-Volatile Storage) del ESP32
-
Frontend: Aplicación web basada en Web Components que se comunica con la API
Servidor httpd y API
El módulo principal TagBoard configura el servidor HTTP y define las rutas de la API.
defmodule TagBoard do
@behaviour :httpd_api_handler
@port 8080
def start() do
setup_network(:sta)
httpd_config = [
# handler para ruta /api
{[<<"tags">>],
%{
handler: :httpd_api_handler,
handler_config: %{
module: __MODULE__
}
}},
# handler de archivos para ruta /, expone archivos en directorio /priv
{[],
%{
handler: :httpd_file_handler,
handler_config: %{
app: :tag_board
}
}}
]
{:ok, _httpd_pid} = :httpd.start_link(@port, httpd_config)
:io.format('Servidor web iniciado.~n')
:timer.sleep(5000)
:timer.sleep(:infinity)
end
def handle_api_request(:post, [<<"create">>], http_request, _args) do
{:ok, body_map} = :mjson.decode(http_request.body)
author = body_map[<<"author">>]
content = body_map[<<"content">>]
timestamp = body_map[<<"timestamp">>]
post_content(author, content, timestamp)
{:close, %{status: "ok"}}
end
def handle_api_request(:get, [<<"get">>], http_request, _args) do
{:ok, tags} = TagBoard.Store.get_tags()
{:close, %{tags: tags}}
end
defp post_content(author, content, timestamp) do
TagBoard.Store.add_tag(author, content, timestamp)
{:ok, tags} = TagBoard.Store.get_tags()
end
defp setup_network(:ap) do
ap_config = [
ssid: "robot test",
psk: "passpass",
ap_started: fn -> :io.format('Punto de acceso iniciado en 192.168.4.1~n') end
]
{:ok, _network_pid} = :network.start(ap: ap_config)
Process.sleep(4000)
end
defp setup_network(:sta) do
config =
[
{:ssid, <<"ssid">>},
{:psk, <<"password">>},
{:got_ip, fn ip -> :io.format('Got IP: ~p~n', [ip]) end},
{:dhcp_hostname, <<"myesp32">>}
]
{:ok, _network_pid} = :network.start(sta: config)
Process.sleep(4000)
end
end
Almacenamiento Persistentente
El módulo TagBoard.Store gestiona el almacenamiento de tags en la memoria no volátil (NVS) del ESP32, asegurando que los datos sobrevivan a reinicios.
defmodule TagBoard.Store do
@nvs_namespace :tagboard_data
@nvs_key :tags_list
@max_tags 25
def add_tag(author, content, timestamp) do
case read_tags_from_nvs() do
{:ok, current_tags} ->
new_tag = %{id: timestamp, author: author, content: content, timestamp: timestamp}
updated_tags = [new_tag | current_tags]
limited_tags = :lists.sublist(updated_tags, @max_tags)
case write_tags_to_nvs(limited_tags) do
:ok ->
:ok
{:error, reason} ->
:io.format('Failed to write tags to NVS after adding: ~p~n', [reason])
{:error, reason}
end
{:error, reason} ->
:io.format('Failed to add tag due to NVS read error: ~p~n', [reason])
{:error, reason}
end
end
def get_tags, do: read_tags_from_nvs()
def clear_all_tags do
case :esp.nvs_erase_key(@nvs_namespace, @nvs_key) do
:ok ->
:io.format('All tags cleared from NVS.~n')
:ok
error ->
:io.format('Failed to clear tags from NVS: ~p~n', [error])
{:error, error}
end
end
defp read_tags_from_nvs do
binary_tags = :esp.nvs_get_binary(@nvs_namespace, @nvs_key, <<>>)
if binary_tags == <<"">> do
{:ok, []}
else
try do
tags = :erlang.binary_to_term(binary_tags)
# Basic validation: ensure it's a list. If not, treat as empty.
if is_list(tags) do
{:ok, tags}
else
{:ok, []}
end
rescue
e ->
{:error, {:deserialization_error, e}}
end
end
end
defp write_tags_to_nvs(tags) do
binary_tags = :erlang.term_to_binary(tags)
case :esp.nvs_put_binary(@nvs_namespace, @nvs_key, binary_tags) do
:ok ->
:ok
error ->
{:error, error}
end
end
end
Frontend
El frontend consiste en un index.html ligero que importa e utiliza un Web Component llamado <tag-board />. Este componente se encarga de llamar a la API periódicamente para motrar tags actualizados, además de crear tags nuevos.
<!DOCTYPE html>
<html>
<head>
<title>Tagboard</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #F1E6C9;
--btn: #2a2f55;
--btn-hover: #3a4090;
--btn-active: #4c53c7;
--text: #222222;
}
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, sans-serif;
color: var(--text);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>
<script type="module" src="/bundle.js"></script>
</head>
<body>
<h1>Tagboard</h1>
<tag-board />
</body>
</html>
Problema de Tipos MIME
httpd tiene una configuración mínima que sirve todos los archivos con el tipo MIME application/octet-stream. Este tipo presenta una limitación: los navegadores modernos no permiten importar módulos JavaScript con este MIME type por razones de seguridad, aunque sí permiten cargarlos mediante etiquetas <script>.
Para solucionar esto, el proyecto usa esbuild para crear un bundle único que incluye todos los componentes Web Components y dependencias.
Configuración de Build
El script de flash en devenv.nix automatiza el proceso:
scripts.flash.exec = ''
esbuild ./js/board.js --bundle --outfile=./priv/bundle.js --format=iife
mix atomvm.packbeam && sudo esptool --chip auto --port /dev/ttyUSB0 --baud 921600 \
write-flash 0x250000 tag_board.avm
'';
Estructura del Frontend
La implementación del frontend (fuera del alcance principal de este documento) utiliza:
-
Web Components para encapsulación, implementados utilizando Lit
-
fetch() API para comunicación con el backend. Se hacen llamadas cada 3 segundos para mantener el estado sincronizado
Para detalles completos del frontend, consulte el repositorio: https://github.com/ElixirCL/elixir-robotics/tree/main/modules/atomvm/examples/tagboard/js