descargas: firmware bring-up v5 0.1.1 + procedimiento actualizado

This commit is contained in:
Leopoldo Rodríguez 2026-09-16 15:13:30 -06:00
parent 95e28ed3b9
commit f637d7813e
13 changed files with 711 additions and 84 deletions

View File

@ -1,33 +1,72 @@
#!/usr/bin/env python3
"""Actas HBOX — servicio mínimo para recibir formularios firmados de los técnicos.
"""Actas HBOX — servicio para recibir y consultar formularios firmados de los técnicos.
Guarda cada envío como un JSON por número de serie, y mantiene un índice CSV por tipo
de acta. Sin base de datos: un archivo por envío, fácil de respaldar y de leer.
Reglas:
- Un acta real (serie que NO contiene PRUEBA) se puede enviar UNA sola vez por serie.
Un segundo envío se rechaza (409) hasta que un administrador la borre.
- Al seleccionar una serie ya enviada, el formulario la vuelve a cargar (llena, bloqueada).
- Solo el usuario 'leo' (administrador) puede borrar/reabrir un acta.
- Las series PRUEBA se guardan aparte (_pruebas), no cuentan y se pueden reenviar.
El usuario autenticado llega en la cabecera X-Remote-User (la fija nginx desde $remote_user;
el cliente no puede falsificarla porque nginx la sobrescribe).
Rutas:
POST /api/actas -> recibe un acta (JSON), la guarda, devuelve {ok, archivo}
GET /api/actas -> lista los envíos (para ingeniería)
GET /api/actas/<tipo>/<archivo> -> devuelve un acta concreta
GET /api/salud -> healthcheck
Variables de entorno:
ACTAS_DIR carpeta donde se guardan las actas (default /srv/projects/drogheda/actas)
GET /api/salud
GET /api/whoami -> {user, admin}
GET /api/acta?tipo=&serie= -> {exists, acta?}
POST /api/actas -> crea (409 si ya existe una real)
DELETE /api/acta?tipo=&serie= -> borra (solo admin)
GET /api/actas -> listado (ingeniería)
"""
import os, re, json, csv, datetime
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
BASE = os.environ.get("ACTAS_DIR", "/srv/projects/drogheda/actas")
ADMINS = {u.strip().lower() for u in os.environ.get("ACTAS_ADMINS", "leo").split(",") if u.strip()}
SAFE = re.compile(r"[^A-Za-z0-9_\-]")
def slug(s, n=40): return SAFE.sub("", str(s or "").strip())[:n]
def slug(s, n=40):
return SAFE.sub("", str(s or "").strip())[:n]
def current_user():
return (request.headers.get("X-Remote-User") or "").strip()
def is_admin():
return current_user().lower() in ADMINS
def find_latest(tipo, serie):
"""Ruta del acta real más reciente para (tipo, serie), o None."""
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
return None
pref = serie + "_"
cand = sorted(fn for fn in os.listdir(carpeta)
if fn.startswith(pref) and fn.endswith(".json"))
return os.path.join(carpeta, cand[-1]) if cand else None
@app.get("/api/salud")
def salud():
return jsonify(ok=True, servicio="actas-hbox")
@app.get("/api/whoami")
def whoami():
u = current_user()
return jsonify(ok=True, user=u, admin=(u.lower() in ADMINS))
@app.get("/api/acta")
def get_acta():
tipo = slug(request.args.get("tipo")); serie = slug(request.args.get("serie"), 20)
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
ruta = find_latest(tipo, serie)
if not ruta:
return jsonify(ok=True, exists=False)
with open(ruta, encoding="utf-8") as f:
d = json.load(f)
d["_archivo"] = os.path.basename(ruta)
return jsonify(ok=True, exists=True, acta=d)
@app.post("/api/actas")
def crear():
try:
@ -37,8 +76,7 @@ def crear():
if not isinstance(d, dict):
return jsonify(ok=False, error="Se esperaba un objeto"), 400
tipo = slug(d.get("tipo")) # p.ej. prueba-v5, prueba-v4, ensamble
serie = slug(d.get("serie"), 20) # p.ej. V5-07
tipo = slug(d.get("tipo")); serie = slug(d.get("serie"), 20)
tecnico = str(d.get("tecnico", "")).strip()
if not tipo or not serie:
return jsonify(ok=False, error="Faltan 'tipo' o 'serie'"), 400
@ -47,19 +85,27 @@ def crear():
if not d.get("conforme"):
return jsonify(ok=False, error="Falta la conformidad del técnico"), 400
es_prueba = "PRUEBA" in serie.upper()
# un acta real solo se puede enviar una vez
if not es_prueba and find_latest(tipo, serie):
return jsonify(ok=False, existe=True,
error=f"Ya existe un acta para {serie}. Pide a Leo que la borre para rehacerla."), 409
ts = datetime.datetime.now().replace(microsecond=0)
iso = ts.isoformat()
d["_recibido"] = iso
d["_usuario"] = current_user()
d["_ip"] = request.headers.get("X-Forwarded-For", request.remote_addr)
d["_prueba"] = es_prueba
carpeta = os.path.join(BASE, tipo)
carpeta = os.path.join(BASE, tipo, "_pruebas") if es_prueba else os.path.join(BASE, tipo)
os.makedirs(carpeta, exist_ok=True)
archivo = f"{serie}_{iso.replace(':', '-')}.json"
ruta = os.path.join(carpeta, archivo)
with open(ruta, "w", encoding="utf-8") as f:
with open(os.path.join(carpeta, archivo), "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
# índice CSV acumulado por tipo (fácil de abrir en Excel)
if not es_prueba:
idx = os.path.join(carpeta, "_indice.csv")
nuevo = not os.path.exists(idx)
with open(idx, "a", newline="", encoding="utf-8") as f:
@ -68,7 +114,56 @@ def crear():
w.writerow(["recibido", "serie", "tecnico", "resultado", "archivo"])
w.writerow([iso, serie, tecnico, d.get("resultado", ""), archivo])
return jsonify(ok=True, archivo=f"{tipo}/{archivo}")
sub = f"{tipo}/_pruebas/{archivo}" if es_prueba else f"{tipo}/{archivo}"
return jsonify(ok=True, archivo=sub, prueba=es_prueba)
@app.delete("/api/acta")
def borrar_acta():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador puede borrar actas."), 403
tipo = slug(request.args.get("tipo")); serie = slug(request.args.get("serie"), 20)
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
carpeta = os.path.join(BASE, tipo)
borrados = []
if os.path.isdir(carpeta):
pref = serie + "_"
for fn in list(os.listdir(carpeta)):
if fn.startswith(pref) and fn.endswith(".json"):
os.remove(os.path.join(carpeta, fn)); borrados.append(fn)
idx = os.path.join(carpeta, "_indice.csv")
if os.path.exists(idx):
rows = list(csv.reader(open(idx, encoding="utf-8")))
keep = ([rows[0]] if rows else []) + [r for r in rows[1:] if not (len(r) > 1 and r[1] == serie)]
with open(idx, "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerows(keep)
return jsonify(ok=True, borrados=borrados)
@app.delete("/api/actas")
def borrar_todas():
"""Reset admin: borra TODAS las actas reales de un tipo (opcional: también las de prueba)."""
if not is_admin():
return jsonify(ok=False, error="Solo un administrador puede reiniciar actas."), 403
tipo = slug(request.args.get("tipo"))
incl_pruebas = request.args.get("pruebas") == "1"
if not tipo:
return jsonify(ok=False, error="falta tipo"), 400
carpeta = os.path.join(BASE, tipo)
n = 0
if os.path.isdir(carpeta):
for fn in list(os.listdir(carpeta)):
fp = os.path.join(carpeta, fn)
if fn.endswith(".json"):
os.remove(fp); n += 1
elif fn == "_indice.csv":
os.remove(fp)
if incl_pruebas:
pdir = os.path.join(carpeta, "_pruebas")
if os.path.isdir(pdir):
for fn in list(os.listdir(pdir)):
if fn.endswith(".json"):
os.remove(os.path.join(pdir, fn)); n += 1
return jsonify(ok=True, borradas=n)
@app.get("/api/actas")
def listar():
@ -85,24 +180,11 @@ def listar():
d = json.load(f)
except Exception:
continue
filas.append({
"tipo": tipo, "archivo": f"{tipo}/{fn}",
filas.append({"tipo": tipo, "archivo": f"{tipo}/{fn}",
"serie": d.get("serie"), "tecnico": d.get("tecnico"),
"resultado": d.get("resultado"), "recibido": d.get("_recibido"),
})
"resultado": d.get("resultado"), "recibido": d.get("_recibido")})
filas.sort(key=lambda r: (r.get("recibido") or ""), reverse=True)
return jsonify(ok=True, total=len(filas), actas=filas)
@app.get("/api/actas/<tipo>/<archivo>")
def leer(tipo, archivo):
tipo = slug(tipo); archivo = slug(archivo, 120)
if not archivo.endswith("json"):
archivo += ".json" if not archivo.endswith(".json") else ""
ruta = os.path.join(BASE, tipo, archivo)
if not os.path.isfile(ruta):
abort(404)
with open(ruta, encoding="utf-8") as f:
return app.response_class(f.read(), mimetype="application/json")
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081")))

86
docs/admin.html Normal file
View File

@ -0,0 +1,86 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Administración — HBOX</title>
<link rel="stylesheet" href="formularios/acta.css">
<style>
table{border-collapse:collapse;width:100%;font-size:14px;margin-top:6px}
th{text-align:left;color:var(--tinta2);font-size:11.5px;text-transform:uppercase;letter-spacing:.05em;padding:6px 8px 6px 0;border-bottom:1px solid var(--tinta2)}
td{padding:7px 8px 7px 0;border-bottom:1px solid var(--linea)}
.del{background:#fff;color:var(--falla);border:1px solid var(--falla);border-radius:8px;padding:5px 10px;font-size:13px;cursor:pointer}
.bulk{background:var(--falla);color:#fff;border:0;border-radius:9px;padding:9px 14px;font-size:13.5px;cursor:pointer;margin:4px 8px 4px 0}
.ver{color:var(--plane);text-decoration:none;font-weight:600}
.aviso{background:var(--crit-soft);border-left:3px solid var(--crit);padding:10px 14px;border-radius:0 8px 8px 0;margin:10px 0}
</style>
</head>
<body>
<div class="wrap">
<header class="top"><h1>Administración de actas</h1><p id="sub">Solo para el administrador.</p></header>
<div style="margin:2px 0 10px"><a class="volver" href="./tablero.html">← Tablero</a></div>
<div id="cont"><p>Comprobando permisos…</p></div>
</div>
<script>
const cont = document.getElementById('cont');
const TIPOS = [
{ tipo:'prueba-v5', label:'Prueba V5' },
{ tipo:'prueba-v4', label:'Prueba V4' },
{ tipo:'ensamble', label:'Ensamble' },
];
function esc(s){ const d=document.createElement('div'); d.textContent=s==null?'':String(s); return d.innerHTML; }
function fmt(iso){ return iso? iso.replace('T',' ').slice(0,16):''; }
async function delOne(tipo, serie){
if(!confirm('¿Borrar el acta '+serie+'? Podrá rehacerse.')) return;
const r = await fetch('/api/acta?tipo='+encodeURIComponent(tipo)+'&serie='+encodeURIComponent(serie),{method:'DELETE'});
const j = await r.json(); if(!j.ok){ alert(j.error||'error'); return; } load();
}
async function delAll(tipo, label){
if(!confirm('¿BORRAR TODAS las actas de '+label+'? Esta acción no se puede deshacer.')) return;
if(!confirm('Confirma otra vez: se borrarán TODAS las de '+label+'.')) return;
const r = await fetch('/api/actas?tipo='+encodeURIComponent(tipo),{method:'DELETE'});
const j = await r.json(); if(!j.ok){ alert(j.error||'error'); return; }
alert('Borradas: '+j.borradas); load();
}
async function delPruebas(){
if(!confirm('¿Borrar todos los registros de PRUEBA de los tres formularios?')) return;
for(const t of TIPOS){ await fetch('/api/actas?tipo='+encodeURIComponent(t.tipo)+'&pruebas=1',{method:'DELETE'}); }
alert('Registros de prueba borrados.'); load();
}
async function load(){
let who; try{ who = await (await fetch('/api/whoami')).json(); }catch(e){}
if(!who || !who.admin){
cont.innerHTML = '<div class="aviso"><b>Acceso restringido.</b> Esta página solo está disponible para el administrador (Leo). '
+ 'Tú entraste como <b>'+esc(who&&who.user||'—')+'</b>.</div>';
return;
}
document.getElementById('sub').textContent = 'Administrador: '+esc(who.user);
let j; try{ j = await (await fetch('/api/actas')).json(); }catch(e){ cont.innerHTML='Error al cargar.'; return; }
const actas = j.actas || [];
let h = '<div class="paso"><h2>Reiniciar en bloque</h2>'
+ '<p class="sub">Borra todas las actas guardadas de un formulario, para volver a empezar el lote.</p>';
TIPOS.forEach(t=>{ h += '<button class="bulk" onclick="delAll(\''+t.tipo+'\',\''+t.label+'\')">Borrar TODAS — '+t.label+'</button>'; });
h += '<button class="bulk" style="background:var(--tinta2)" onclick="delPruebas()">Limpiar registros de PRUEBA</button></div>';
h += '<div class="paso"><h2>Actas guardadas ('+actas.length+')</h2>';
if(!actas.length){ h += '<p class="sub">No hay actas guardadas.</p>'; }
else {
h += '<div class="tblwrap"><table><tr><th>Serie</th><th>Tipo</th><th>Técnico</th><th>Resultado</th><th>Fecha</th><th></th><th></th></tr>';
actas.forEach(a=>{
h += '<tr><td><b>'+esc(a.serie)+'</b></td><td>'+esc(a.tipo)+'</td><td>'+esc(a.tecnico||'')+'</td>'
+ '<td>'+esc(a.resultado||'')+'</td><td>'+esc(fmt(a.recibido))+'</td>'
+ '<td><a class="ver" href="formularios/ver.html?tipo='+encodeURIComponent(a.tipo)+'&serie='+encodeURIComponent(a.serie)+'">Ver</a></td>'
+ '<td><button class="del" onclick="delOne(\''+a.tipo+'\',\''+esc(a.serie)+'\')">Borrar</button></td></tr>';
});
h += '</table></div>';
}
h += '</div>';
cont.innerHTML = h;
}
window.delOne=delOne; window.delAll=delAll; window.delPruebas=delPruebas;
load();
</script>
</body>
</html>

View File

@ -0,0 +1 @@
91ca6f87a86686be33ce013110f214a7e94ae7c8909c9eea1dbe846d9b5247f3 hbox-v5-bringup_0.1.1.bin

View File

@ -0,0 +1,43 @@
# hbox_v5_bringup — EinarBoard v5 first-flash bring-up firmware
Bench-proves a newly assembled v5 board over USB-C **J11** only. Never powers the RTK stack.
Checks: HSE 25 MHz + USB CDC · LSE Y4 · I2C: PCF8574 0x20 / SHT40 0x44 / TMP102 0x48 /
SC16IS752 0x4D · gauge + RGB LED walk · ADC · digital inputs. Prints PASS/FAIL, then a
`STAT` line every 2 s. Console commands: `r` rerun · `g` gauge walk · `i` I2C scan · `h` help.
Procedure for technicians (ES): `06-Testing-Validation/2026-09-15_Drogheda_EinarBoard-v5_Procedimiento-Prueba-y-Flasheo_ES_v1.0.md`
## Build
STM32duino core 3.x (`STMicroelectronics:stm32`). Target is the **generic F411RE**, not the Blackpill.
```bash
arduino-cli compile \
--fqbn "STMicroelectronics:stm32:GenF4:pnum=GENERIC_F411RETX,usb=CDCgen,xserial=generic,rtlib=nanofp" \
--build-property "build.extra_flags=-DHSE_VALUE=25000000UL" \
--output-dir out hbox_v5_bringup
```
`rtlib=nanofp` is required — the default newlib-nano build prints nothing for `%f`, so every voltage
and temperature line would be blank. `SystemClock_Config()` is overridden in the sketch (HSE 25 MHz → 96 MHz, USB 48 MHz) because the
generic variant defaults to HSI, which is not accurate enough for USB.
## Flash (DFU, J11)
Hold **SW2** (BOOT0) → tap **SW1** (RESET) → release SW2 → device `0483:DF11` appears.
```bash
dfu-util -a 0 -s 0x08000000:leave -D release/hbox-v5-bringup_0.1.1.bin
```
or STM32CubeProgrammer → USB → Connect → Download `release/hbox-v5-bringup_0.1.1.bin` @ `0x08000000`.
## Release
| File | Version | SHA-256 (first 16) | Size |
|---|---|---|---|
| **`release/hbox-v5-bringup_0.1.1.bin`** | **0.1.1 (2026-09-16) — USE THIS** | `91ca6f87a86686be` | 55 104 B |
| `release/hbox-v5-bringup_0.1.0.bin` | 0.1.0 — SUPERSEDED (no float printf, ETH_CS driven) | `1a9a77ae479c8f86` | 41 040 B |
## Changelog
- **0.1.1** (2026-09-16, audit): build with `rtlib=nanofp` (floats printed blank in 0.1.0); `ETH_CS` now
`INPUT_PULLUP` instead of driving HIGH into the unpowered W5500; reset cause reports `PIN` only when
`PINRSTF` is set (else `UNKNOWN`).
- **0.1.0** (2026-09-15): first build.
## ⚠ Do not flash `blackpillrtkcode_v3` (v3.0.3) on v5
Its `PIN_BATLED1..4` = PB12–PB15 are the W5500 SPI2 bus on this board. `board_v5.h` is the
verified v5 pin map — the production v5 firmware should include it rather than redefine pins.

Binary file not shown.

31
docs/descargas/index.md Normal file
View File

@ -0,0 +1,31 @@
# Descargas
Archivos que el técnico necesita en el banco. Verificar el SHA-256 antes de flashear
(`shasum -a 256 archivo` en macOS, `sha256sum archivo` en Linux, `certutil -hashfile archivo SHA256` en Windows).
## Firmware de bring-up — EinarBoard v5
| Archivo | Versión | SHA-256 | Uso |
|---|---|---|---|
| [**hbox-v5-bringup_0.1.1.bin**](hbox-v5-bringup_0.1.1.bin) | 0.1.1 (2026-09-16) | `91ca6f87a86686be…` ([SHA256SUMS](SHA256SUMS.txt)) | Sección 5 del [procedimiento de prueba y flasheo v5](../procedimientos/v5-prueba-y-flasheo.md) |
Flasheo por DFU en **J11** (mantener **SW2**, pulsar y soltar **SW1**, soltar SW2):
```bash
dfu-util -a 0 -s 0x08000000:leave -D hbox-v5-bringup_0.1.1.bin
```
o con **STM32CubeProgrammer** → USB → Connect → Download del `.bin` en `0x08000000`.
!!! warning "Solo para la tarjeta v5"
Este firmware **no** es el de producción: solo prueba la tarjeta (reloj, USB, cristales, I²C,
LEDs, ADC) sin encender la etapa RTK ni el cargador. **Nunca flashear `blackpillrtkcode_v3`
(v3.0.3) en una v5.**
Notas de compilación y cambios: [README del firmware de bring-up](hbox-v5-bringup-README.md).
## Herramientas
- **STM32CubeProgrammer** (Windows/macOS/Linux): https://www.st.com/en/development-tools/stm32cubeprog.html
- **dfu-util** (macOS: `brew install dfu-util`; Linux: `apt install dfu-util`)
- Terminal serie: CoolTerm, `screen /dev/tty.usbmodem* 115200`, o el monitor serie de Arduino IDE

View File

@ -55,3 +55,6 @@ button.enviar:disabled{opacity:.5;cursor:not-allowed}
.msg.err{display:block;background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla)}
.enviada{opacity:.6;pointer-events:none}
a.volver{color:var(--naranja);font-size:14px;text-decoration:none}
button.limpiar{background:#fff;color:var(--tinta2);border:1px solid var(--linea);
border-radius:10px;padding:13px 16px;font-size:15px;cursor:pointer}
button.limpiar:hover{border-color:var(--naranja);color:var(--naranja)}

View File

@ -1,28 +1,34 @@
/* Motor de actas HBOX — renderiza el formulario desde una config y lo envía al servidor.
Uso: <script src="acta.js"></script> luego Acta.render(CONFIG)
Tipos de ítem: check | medida | texto | radio | nota
Autoguardado en localStorage por (tipo + serie). Envío: POST /api/actas */
/* Motor de actas HBOX — renderiza el formulario, sincroniza con el servidor y firma.
- Un acta real se envía UNA vez: al elegir una serie ya enviada, se carga llena y bloqueada.
- Solo el administrador (leo) ve el botón "Borrar acta (reabrir)".
- La serie PRUEBA no se bloquea (para practicar).
Tipos de ítem: check | medida | texto | radio | nota · Envío: POST /api/actas */
(function () {
const $ = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a){ if(p==='html')e.innerHTML=a[p]; else if(p==='text')e.textContent=a[p]; else e.setAttribute(p,a[p]); } k.forEach(c=>e.append(c)); return e; };
function render(cfg) {
const root = document.getElementById('acta');
const key = () => 'acta:' + cfg.tipo + ':' + (serieSel.value || '');
let admin = false;
const wrap = $('div', { class: 'wrap' });
const head = $('header', { class: 'top' });
head.append($('h1', { text: cfg.titulo }));
head.append($('p', { text: cfg.subtitulo || 'Helgen Technologies · llena mientras pruebas la tarjeta' }));
wrap.append(head);
wrap.append($('a', { class: 'volver', href: '../actas/' , text: '← Volver a Actas' }));
const nav = $('div', { style: 'display:flex;justify-content:space-between;margin:2px 0 8px' });
nav.append($('a', { class: 'volver', href: '../actas/', text: '← Volver a Actas' }));
nav.append($('a', { class: 'volver', href: '../salir.html', text: 'Cerrar sesión' }));
wrap.append(nav);
if (cfg.borrador)
wrap.append($('div', { class: 'borrador', html: '⚠ <b>BORRADOR</b> — este formulario aún debe revisarse con ingeniería antes de usarlo como registro oficial.' }));
// datos de cabecera
const datos = $('div', { class: 'datos' });
const serieSel = $('select', { id: 'f_serie' });
serieSel.append($('option', { value: '', text: '— elegir —' }));
serieSel.append($('option', { value: cfg.prefijo + '-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
for (let i = 1; i <= (cfg.series || 15); i++) {
const s = cfg.prefijo + '-' + String(i).padStart(2, '0');
serieSel.append($('option', { value: s, text: s }));
@ -34,7 +40,9 @@
const cab = $('section', { class: 'paso' }); cab.append(datos);
wrap.append(cab);
// secciones del procedimiento
const banner = $('div', { class: 'msg', id: 'f_banner' });
wrap.append(banner);
cfg.secciones.forEach((sec, si) => {
const s = $('section', { class: 'paso' });
s.append($('h2', { text: sec.titulo }));
@ -65,7 +73,6 @@
wrap.append(s);
});
// firma / conformidad
const firma = $('section', { class: 'paso firma' });
firma.append($('h2', { text: 'Cierre y firma' }));
firma.append($('p', { class: 'sub', text: 'Al enviar, quedas registrado como responsable de esta prueba.' }));
@ -84,50 +91,121 @@
wrap.append(msg);
root.append(wrap);
// barra fija de envío
const barra = $('div', { class: 'barra' });
const estado = $('span', { class: 'estado', id: 'f_estado', text: 'Sin guardar' });
const limpiar = $('button', { class: 'limpiar', id: 'f_limpiar', text: 'Limpiar' });
const borrar = $('button', { class: 'limpiar', id: 'f_borrar', text: 'Borrar acta (reabrir)', style: 'display:none;border-color:var(--falla);color:var(--falla)' });
const btn = $('button', { class: 'enviar', id: 'f_enviar', text: 'Enviar acta firmada' });
barra.append(estado, btn);
const grp = $('div', { style: 'display:flex;gap:10px' }); grp.append(limpiar, borrar, btn);
barra.append(estado, grp);
document.body.append(barra);
// ---- autoguardado ----
// ---- helpers de estado ----
const conforme = () => document.getElementById('f_conforme');
function fields() { return wrap.querySelectorAll('[data-id]'); }
function setEnabled(on) {
fields().forEach(el => el.disabled = !on);
tecnico.disabled = !on; conforme().disabled = !on;
wrap.querySelectorAll('input[name=resultado]').forEach(el => el.disabled = !on);
}
function blankFields() {
fields().forEach(el => { if (el.type === 'checkbox') el.checked = false; else el.value = ''; });
const rr = wrap.querySelector('input[name=resultado]:checked'); if (rr) rr.checked = false;
conforme().checked = false; tecnico.value = '';
}
function fillFrom(a) {
tecnico.value = a.tecnico || '';
conforme().checked = !!a.conforme;
if (a.resultado) { const el = wrap.querySelector(`input[name=resultado][value="${a.resultado}"]`); if (el) el.checked = true; }
for (const k in (a.respuestas || {})) {
const el = wrap.querySelector(`[data-id="${k}"]`); if (!el) continue;
if (el.type === 'checkbox') el.checked = !!a.respuestas[k].val; else el.value = a.respuestas[k].val || '';
}
}
function showEditable() {
setEnabled(true);
limpiar.style.display = ''; btn.style.display = ''; borrar.style.display = 'none';
wrap.classList.remove('enviada'); banner.className = 'msg';
}
function showLocked(a) {
setEnabled(false);
limpiar.style.display = 'none'; btn.style.display = 'none';
borrar.style.display = admin ? '' : 'none';
banner.className = 'msg ok';
const f = (a._recibido || '').replace('T', ' ').slice(0, 16);
banner.innerHTML = '✓ <b>Acta ya enviada</b> por <b>' + (a.tecnico || '—') + '</b>'
+ (f ? ' el ' + f : '') + '. Registro final'
+ (a.resultado ? ' — resultado <b>' + a.resultado + '</b>' : '') + '.'
+ (admin ? '' : ' Para rehacerla, pide a Leo que la borre.');
estado.textContent = 'Bloqueada';
}
// ---- autoguardado local (solo para actas nuevas, no enviadas) ----
let t, locked = false;
function snapshot() {
const r = {};
wrap.querySelectorAll('[data-id]').forEach(el => {
r[el.dataset.id] = { lbl: el.dataset.lbl, val: el.type === 'checkbox' ? el.checked : el.value };
});
fields().forEach(el => { r[el.dataset.id] = { lbl: el.dataset.lbl, val: el.type === 'checkbox' ? el.checked : el.value }; });
const rr = wrap.querySelector('input[name=resultado]:checked');
return { tipo: cfg.tipo, serie: serieSel.value, tecnico: tecnico.value,
conforme: document.getElementById('f_conforme').checked,
resultado: rr ? rr.value : '', respuestas: r };
conforme: conforme().checked, resultado: rr ? rr.value : '', respuestas: r };
}
let t;
function guardar() {
if (!serieSel.value) { estado.textContent = 'Elige el número de serie'; return; }
function guardarLocal() {
if (locked || !serieSel.value) return;
clearTimeout(t);
t = setTimeout(() => {
try { localStorage.setItem(key(), JSON.stringify(snapshot())); } catch (e) {}
estado.textContent = 'Guardado local ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
}, 400);
}
function cargar() {
function cargarLocal() {
let d; try { d = JSON.parse(localStorage.getItem(key()) || 'null'); } catch (e) {}
if (!d) { estado.textContent = 'Nueva acta'; return; }
tecnico.value = d.tecnico || '';
document.getElementById('f_conforme').checked = !!d.conforme;
if (d.resultado) { const el = wrap.querySelector(`input[name=resultado][value="${d.resultado}"]`); if (el) el.checked = true; }
for (const k in (d.respuestas || {})) {
const el = wrap.querySelector(`[data-id="${k}"]`); if (!el) continue;
if (el.type === 'checkbox') el.checked = !!d.respuestas[k].val; else el.value = d.respuestas[k].val || '';
if (d) { fillFrom(d); estado.textContent = 'Borrador recuperado'; }
else estado.textContent = 'Nueva acta';
}
estado.textContent = 'Borrador recuperado';
// ---- cargar una serie: consulta el servidor y bloquea si ya existe ----
async function loadSerie() {
msg.className = 'msg'; locked = false;
const s = serieSel.value;
blankFields(); showEditable();
if (!s) { estado.textContent = 'Elige el número de serie'; return; }
if (s.toUpperCase().indexOf('PRUEBA') >= 0) { cargarLocal(); estado.textContent = 'Modo prueba (no cuenta)'; return; }
estado.textContent = 'Consultando…';
let j = null;
try { const r = await fetch(`/api/acta?tipo=${encodeURIComponent(cfg.tipo)}&serie=${encodeURIComponent(s)}`); j = await r.json(); } catch (e) {}
if (j && j.exists) { locked = true; fillFrom(j.acta); showLocked(j.acta); }
else { cargarLocal(); }
}
wrap.addEventListener('input', guardar);
serieSel.addEventListener('change', cargar);
wrap.addEventListener('input', guardarLocal);
serieSel.addEventListener('change', loadSerie);
// ---- limpiar (solo borrador local; no aplica a enviadas) ----
limpiar.addEventListener('click', () => {
if (locked) return;
if (!confirm('¿Limpiar este formulario y empezar de nuevo?')) return;
try { localStorage.removeItem(key()); } catch (e) {}
blankFields(); msg.className = 'msg'; estado.textContent = 'Formulario limpio'; window.scrollTo(0, 0);
});
// ---- borrar / reabrir (solo admin) ----
borrar.addEventListener('click', async () => {
if (!admin) return;
if (!confirm('¿Borrar el acta de ' + serieSel.value + '? Quedará disponible para rehacerse.')) return;
borrar.disabled = true;
try {
const r = await fetch(`/api/acta?tipo=${encodeURIComponent(cfg.tipo)}&serie=${encodeURIComponent(serieSel.value)}`, { method: 'DELETE' });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
locked = false; blankFields(); showEditable();
msg.className = 'msg ok'; msg.textContent = 'Acta borrada. Ya puede rehacerse.';
estado.textContent = 'Reabierta';
} catch (e) { msg.className = 'msg err'; msg.textContent = 'No se pudo borrar: ' + e.message; }
borrar.disabled = false;
});
// ---- envío ----
btn.addEventListener('click', async () => {
if (locked) return;
const d = snapshot();
if (!d.serie) return fail('Elige el número de serie de la tarjeta.');
if (!d.tecnico.trim()) return fail('Escribe tu nombre.');
@ -137,20 +215,32 @@
try {
const r = await fetch('/api/actas', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d) });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
msg.className = 'msg ok';
msg.innerHTML = '✓ Acta enviada y registrada como <b>' + d.serie + '</b>. Puedes cerrar esta página.';
if (r.status === 409) { // ya existía: recargar bloqueada
try { localStorage.removeItem(key()); } catch (e) {}
wrap.classList.add('enviada'); estado.textContent = 'Enviada';
btn.textContent = 'Enviada ✓'; window.scrollTo(0, document.body.scrollHeight);
await loadSerie();
msg.className = 'msg err'; msg.textContent = j.error || 'Ya existe un acta para esta serie.';
btn.disabled = false; btn.textContent = 'Enviar acta firmada';
return;
}
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
try { localStorage.removeItem(key()); } catch (e) {}
msg.className = 'msg ok';
msg.innerHTML = '✓ Acta enviada y registrada como <b>' + d.serie + '</b>.';
if (!j.prueba) { locked = true; showLocked({ tecnico: d.tecnico, resultado: d.resultado, _recibido: new Date().toISOString() }); }
else { btn.disabled = false; btn.textContent = 'Enviar acta firmada'; estado.textContent = 'Prueba enviada'; }
window.scrollTo(0, document.body.scrollHeight);
} catch (e) {
fail('No se pudo enviar: ' + e.message + '. Revisa tu conexión e inténtalo otra vez (tu borrador está guardado).');
fail('No se pudo enviar: ' + e.message + '. Revisa tu conexión (tu borrador está guardado).');
btn.disabled = false; btn.textContent = 'Enviar acta firmada';
}
});
function fail(m) { msg.className = 'msg err'; msg.textContent = m; window.scrollTo(0, document.body.scrollHeight); }
cargar();
// ---- init: saber si soy admin, luego cargar la serie elegida ----
(async () => {
try { const r = await fetch('/api/whoami'); const j = await r.json(); admin = !!j.admin; } catch (e) {}
loadSerie();
})();
}
window.Acta = { render };
})();

91
docs/formularios/ver.html Normal file
View File

@ -0,0 +1,91 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ver acta — HBOX</title>
<link rel="stylesheet" href="acta.css">
<style>
.doc{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:18px}
.hdr{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:12px}
.hdr .c{background:var(--papel);border:1px solid var(--linea);border-radius:9px;padding:8px 10px}
.hdr .l{font-size:11px;color:var(--tinta2);text-transform:uppercase;letter-spacing:.05em}
.hdr .v{font-weight:700;font-size:15px}
.resu{font-weight:800}
.resu.OK{color:var(--ok)} .resu.FALLA{color:var(--falla)}
.sec{margin-top:14px} .sec h3{font-size:14px;margin:0 0 4px;color:var(--naranja)}
.row{display:flex;gap:10px;padding:5px 0;border-top:1px solid var(--linea);font-size:14px}
.row .a{flex:0 0 auto;font-weight:700}
.row .t{flex:1}
.si{color:var(--ok);font-weight:700} .no{color:var(--tinta2)}
@media print{.barra,.top .noprint,button{display:none} body{background:#fff}}
</style>
</head>
<body>
<div class="wrap">
<header class="top"><h1 id="titulo">Acta</h1><p id="sub">Cargando…</p></header>
<div style="display:flex;justify-content:space-between;margin:2px 0 10px">
<a class="volver" href="../tablero.html">← Tablero</a>
<a class="volver noprint" href="#" onclick="window.print();return false">Imprimir / PDF</a>
</div>
<div id="cont"></div>
</div>
<script>
const qs = new URLSearchParams(location.search);
const tipo = qs.get('tipo') || '';
const serie = qs.get('serie') || '';
const cont = document.getElementById('cont');
const TITULOS = { 'prueba-v5':'Acta de prueba V5', 'prueba-v4':'Acta de prueba V4', 'ensamble':'Checklist de ensamble' };
function esc(s){ const d=document.createElement('div'); d.textContent=s==null?'':String(s); return d.innerHTML; }
function fmt(iso){ return iso? iso.replace('T',' ').slice(0,16):''; }
function render(a){
document.getElementById('titulo').textContent = (TITULOS[tipo]||'Acta') + ' — ' + esc(a.serie);
document.getElementById('sub').textContent = 'Registro firmado · Helgen Technologies';
let h = '<div class="doc"><div class="hdr">'
+ '<div class="c"><div class="l">Serie</div><div class="v">'+esc(a.serie)+'</div></div>'
+ '<div class="c"><div class="l">Técnico</div><div class="v">'+esc(a.tecnico||'—')+'</div></div>'
+ '<div class="c"><div class="l">Resultado</div><div class="v resu '+esc(a.resultado)+'">'+esc(a.resultado||'—')+'</div></div>'
+ '<div class="c"><div class="l">Fecha</div><div class="v">'+esc(fmt(a._recibido))+'</div></div>'
+ '<div class="c"><div class="l">Usuario</div><div class="v">'+esc(a._usuario||'—')+'</div></div>'
+ '</div>';
// agrupar respuestas por sección (prefijo sN_)
const R = a.respuestas || {};
const keys = Object.keys(R).sort((x,y)=>{
const px=x.match(/^s(\d+)_(\d+)/), py=y.match(/^s(\d+)_(\d+)/);
if(px&&py){ return (+px[1]-+py[1]) || (+px[2]-+py[2]); } return x<y?-1:1;
});
let curSec=-1;
keys.forEach(k=>{
const m=k.match(/^s(\d+)_/); const sec=m?+m[1]:0;
if(sec!==curSec){ if(curSec!==-1) h+='</div>'; h+='<div class="sec"><h3>Sección '+(sec+1)+'</h3>'; curSec=sec; }
const it=R[k]; const val=it.val;
let vista;
if(val===true) vista='<span class="si">✓ Sí</span>';
else if(val===false) vista='<span class="no">— No marcado</span>';
else vista = val==='' ? '<span class="no">—</span>' : esc(val);
h+='<div class="row"><div class="t">'+ (it.lbl||k) +'</div><div class="a">'+vista+'</div></div>';
});
if(curSec!==-1) h+='</div>';
h += '<div class="sec"><div class="row"><div class="t">Conformidad del técnico</div><div class="a">'
+ (a.conforme?'<span class="si">✓ Confirmado</span>':'<span class="no">—</span>') + '</div></div></div>';
h += '</div>';
cont.innerHTML = h;
}
async function cargar(){
if(!tipo || !serie){ cont.innerHTML='<div class="borrador">Falta indicar tipo y serie.</div>'; return; }
try{
const r = await fetch('/api/acta?tipo='+encodeURIComponent(tipo)+'&serie='+encodeURIComponent(serie));
const j = await r.json();
if(!j.ok) throw new Error(j.error||'error');
if(!j.exists){ cont.innerHTML='<div class="borrador">No hay un acta registrada para <b>'+esc(serie)+'</b> todavía.</div>';
document.getElementById('sub').textContent='Sin registro'; return; }
render(j.acta);
}catch(e){ cont.innerHTML='<div class="borrador">No se pudo cargar: '+esc(e.message)+'</div>'; }
}
cargar();
</script>
</body>
</html>

View File

@ -37,7 +37,7 @@ y dejarla flasheada con el firmware de arranque entregado por ingeniería.
| 1 | Fuente de banco 0–30 V con límite de corriente ajustable |
| 1 | Cable USB-C ↔ USB-C (o USB-A ↔ USB-C) **con datos** (no solo carga) |
| 1 | Laptop con **STM32CubeProgrammer** instalado (o `dfu-util` en terminal) |
| 1 | Archivo de firmware: **`hbox-v5-bringup_0.1.0.bin`** (repo: `04-Source-Code/firmware/hbox_v5_bringup/release/`, SHA-256 inicia `1a9a77ae`) |
| 1 | Archivo de firmware: **`hbox-v5-bringup_0.1.1.bin`** — descarga: **https://drogheda.helgen.online/descargas/** (repo: `04-Source-Code/firmware/hbox_v5_bringup/release/`, SHA-256 inicia `91ca6f87`) |
| 1 | Jumper 2.54 mm para P11 (interruptor de corte) |
| 1 | Lupa o lámpara con aumento |
| — | Hoja de registro (sección 7) |
@ -183,7 +183,7 @@ Solo para tarjetas que pasaron las secciones 1 a 4.
2. Arriba a la derecha, en el selector de conexión elegir **USB**. Pulsar el botón de refrescar;
debe aparecer `USB1`. Pulsar **Connect**.
3. Ir a la pestaña **Erasing & Programming** (icono de flecha hacia abajo, lado izquierdo).
4. **File path** → seleccionar `hbox-v5-bringup_0.1.0.bin`.
4. **File path** → seleccionar `hbox-v5-bringup_0.1.1.bin`.
5. **Start address** = `0x08000000`. Marcar **Verify programming** y **Run after programming**.
6. Pulsar **Start Programming**. Esperar "File download complete" y "Verify… successful".
7. Pulsar **Disconnect**.
@ -192,7 +192,7 @@ Solo para tarjetas que pasaron las secciones 1 a 4.
```bash
dfu-util -l # debe listar [0483:df11]
dfu-util -a 0 -s 0x08000000:leave -D hbox-v5-bringup_0.1.0.bin
dfu-util -a 0 -s 0x08000000:leave -D hbox-v5-bringup_0.1.1.bin
```
### 5d. Verificación después del flasheo
@ -206,7 +206,7 @@ dfu-util -a 0 -s 0x08000000:leave -D hbox-v5-bringup_0.1.0.bin
| **D17** (LED azul) + LED de panel en P8 (si hay) | **Parpadean** a 1 Hz (MCU ejecutando el firmware) |
| **DB1 → DB4** (LEDs del indicador de batería) | Se encienden **en secuencia** una vez, luego se apagan; después R, G, B en P14 si hay LED RGB conectado |
| Laptop | Aparece un **puerto serie** nuevo ("STMicroelectronics Virtual COM Port" / `usbmodem…`) |
| Terminal serie a **115200 baud** | Encabezado `hbox-v5-bringup 0.1.0`, luego líneas `[PASS]` / `[FAIL]`, y al final **`>>> BOARD OK`** |
| Terminal serie a **115200 baud** | Encabezado `hbox-v5-bringup 0.1.1`, luego líneas `[PASS]` / `[FAIL]`, y al final **`>>> BOARD OK`** |
3. Si el terminal se abrió tarde y no se vio el reporte: teclear **`r`** en el terminal → repite
todas las pruebas (y vuelve a encender los LEDs en secuencia).

190
docs/tablero.html Normal file
View File

@ -0,0 +1,190 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tablero de estado — HBOX</title>
<style>
:root{
--naranja:#e8590c; --tinta:#1d1c19; --tinta2:#5a564d; --linea:#e6e1d6;
--papel:#faf8f4; --tarjeta:#fff; --superf:#fcfcfb;
--apro:#2f9e44; --apro-bg:#ebfbee; --falla:#e03131; --falla-bg:#fff5f5;
--pend:#adb5bd; --pend-bg:#f4f2ee; --ens:#1971c2; --ens-bg:#e7f1fb;
}
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){
--papel:#191b1e;--tinta:#e6e1d6;--tinta2:#a09a8d;--linea:#33363b;--tarjeta:#1f2226;--superf:#1b1d20;
--apro:#69db7c;--apro-bg:#1b2a1f;--falla:#ff8787;--falla-bg:#2c1d1d;--pend:#5c636a;--pend-bg:#26282c;--ens:#74c0fc;--ens-bg:#182734;}}
*{box-sizing:border-box}
body{margin:0;background:var(--papel);color:var(--tinta);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.wrap{max-width:1000px;margin:0 auto;padding:18px 16px 70px}
header.top{display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;
background:var(--tinta);color:#fff;padding:16px;border-radius:12px;margin-bottom:16px}
header.top h1{margin:0;font-size:20px}
header.top .meta{color:#d7d2c8;font-size:13px}
button{background:var(--naranja);color:#fff;border:0;border-radius:9px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer}
a.volver{color:#fff;font-size:13px;text-decoration:none;opacity:.85}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:18px}
.kpi{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:14px 16px}
.kpi .n{font-size:30px;font-weight:700;line-height:1}
.kpi .l{color:var(--tinta2);font-size:13px;margin-top:4px}
section.bloque{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;padding:16px;margin:14px 0}
section.bloque h2{margin:0 0 2px;font-size:17px}
.resumen{color:var(--tinta2);font-size:13.5px;margin:0 0 10px}
.barra{display:flex;height:14px;border-radius:7px;overflow:hidden;background:var(--pend-bg);margin:6px 0 14px;gap:2px}
.barra span{display:block;height:100%}
.barra .a{background:var(--apro)} .barra .f{background:var(--falla)} .barra .e{background:var(--ens)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}
.cel{display:flex;flex-direction:column;gap:2px;text-decoration:none;color:inherit;
border:1px solid var(--linea);border-radius:10px;padding:9px 10px;background:var(--superf)}
.cel .s{font-weight:700;font-size:14px}
.cel .st{font-size:11.5px;font-weight:600;display:flex;align-items:center;gap:4px}
.cel .who{font-size:11px;color:var(--tinta2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cel.apro{background:var(--apro-bg);border-color:var(--apro)} .cel.apro .st{color:var(--apro)}
.cel.falla{background:var(--falla-bg);border-color:var(--falla)} .cel.falla .st{color:var(--falla)}
.cel.ens{background:var(--ens-bg);border-color:var(--ens)} .cel.ens .st{color:var(--ens)}
.cel.pend .st{color:var(--tinta2)}
.leyenda{display:flex;flex-wrap:wrap;gap:14px;font-size:12.5px;color:var(--tinta2);margin-top:10px}
.leyenda b{font-weight:600}
table{border-collapse:collapse;width:100%;font-size:13.5px;margin-top:6px}
th{text-align:left;color:var(--tinta2);font-size:11.5px;text-transform:uppercase;letter-spacing:.05em;padding:6px 8px 6px 0;border-bottom:1px solid var(--tinta2)}
td{padding:7px 8px 7px 0;border-bottom:1px solid var(--linea)}
.pill{font-size:11.5px;font-weight:700;padding:2px 8px;border-radius:99px}
.pill.apro{background:var(--apro-bg);color:var(--apro)} .pill.falla{background:var(--falla-bg);color:var(--falla)}
.dos{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:680px){.dos{grid-template-columns:1fr}}
.err{background:var(--falla-bg);color:var(--falla);border:1px solid var(--falla);padding:10px 14px;border-radius:10px}
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<div><h1>Tablero de estado — HBOX</h1><div class="meta" id="meta">Cargando…</div></div>
<div style="display:flex;gap:12px;align-items:center">
<a class="volver" href="./">← Documentación</a>
<button id="refrescar">Actualizar</button>
</div>
</header>
<div id="cont"></div>
</div>
<script>
const STREAMS = [
{ tipo:'prueba-v5', prefijo:'V5', n:15, titulo:'Prueba de tarjeta — V5', form:'formularios/v5.html' },
{ tipo:'prueba-v4', prefijo:'V4', n:15, titulo:'Prueba de tarjeta — V4', form:'formularios/v4.html' },
{ tipo:'ensamble', prefijo:'HBOX', n:15, titulo:'Ensamble de equipo — HBOX', form:'formularios/ensamble.html', assembly:true },
];
const ICON = { apro:'✓', falla:'✕', pend:'·', ens:'▣' };
const cont = document.getElementById('cont');
const meta = document.getElementById('meta');
function fmt(iso){ return iso ? iso.replace('T',' ').slice(0,16) : ''; }
function celda(st, sc){
const cls = st.estado;
const a = document.createElement('a');
a.className = 'cel ' + cls;
a.href = (cls==='pend') ? sc.form : ('formularios/ver.html?tipo='+encodeURIComponent(sc.tipo)+'&serie='+encodeURIComponent(st.serie));
a.title = st.serie + (st.tecnico? ' — '+st.tecnico : '');
const label = cls==='apro' ? 'Aprobada' : cls==='falla' ? 'Falla' : cls==='ens' ? 'Ensamblada' : 'Pendiente';
a.innerHTML = '<span class="s">'+st.serie+'</span>'
+ '<span class="st">'+ (ICON[cls]||'·') +' '+label+'</span>'
+ '<span class="who">'+ (st.tecnico? st.tecnico : '—') +'</span>';
return a;
}
function bloque(sc, actas){
const map = {};
actas.filter(a=>a.tipo===sc.tipo).forEach(a=>{ map[a.serie]=a; });
let apro=0, falla=0, pend=0, ens=0;
const cells=[];
for(let i=1;i<=sc.n;i++){
const serie = sc.prefijo+'-'+String(i).padStart(2,'0');
const a = map[serie];
let estado='pend', tecnico='';
if(a){ tecnico=a.tecnico||'';
if(sc.assembly){ estado = a.resultado==='FALLA' ? 'falla' : 'ens'; }
else { estado = a.resultado==='FALLA' ? 'falla' : 'apro'; }
}
if(estado==='apro')apro++; else if(estado==='falla')falla++; else if(estado==='ens')ens++; else pend++;
cells.push(celda({serie,estado,tecnico,recibido:a?a.recibido:''}, sc));
}
const hechas = sc.n - pend;
const sec = document.createElement('section'); sec.className='bloque';
sec.innerHTML = '<h2>'+sc.titulo+'</h2>'
+ '<p class="resumen">'+hechas+' de '+sc.n+' registradas'
+ (sc.assembly ? ' · '+ens+' ensambladas · '+falla+' con falla'
: ' · '+apro+' aprobadas · '+falla+' con falla') + ' · '+pend+' pendientes</p>';
const barra = document.createElement('div'); barra.className='barra';
const seg=(c,n)=>{ if(!n)return; const s=document.createElement('span'); s.className=c; s.style.flex=n; barra.append(s); };
if(sc.assembly){ seg('e',ens); seg('f',falla); } else { seg('a',apro); seg('f',falla); }
const rest=document.createElement('span'); rest.style.flex=pend; barra.append(rest);
sec.append(barra);
const grid=document.createElement('div'); grid.className='grid'; cells.forEach(c=>grid.append(c));
sec.append(grid);
return sec;
}
function render(actas){
cont.innerHTML='';
// KPIs
const pruebas = actas.filter(a=>a.tipo==='prueba-v5'||a.tipo==='prueba-v4');
const ens = actas.filter(a=>a.tipo==='ensamble');
const aprob = pruebas.filter(a=>a.resultado!=='FALLA').length;
const fallas = pruebas.filter(a=>a.resultado==='FALLA').length;
const kpis=document.createElement('div'); kpis.className='kpis';
const kpi=(n,l)=>{ const d=document.createElement('div'); d.className='kpi'; d.innerHTML='<div class="n">'+n+'</div><div class="l">'+l+'</div>'; return d; };
kpis.append(kpi(pruebas.length+' / 30','Tarjetas probadas'));
kpis.append(kpi(aprob,'Aprobadas'));
kpis.append(kpi(fallas,'Con falla'));
kpis.append(kpi(ens.length+' / 15','Equipos ensamblados'));
cont.append(kpis);
// streams
STREAMS.forEach(sc=>cont.append(bloque(sc,actas)));
// leyenda
const ley=document.createElement('div'); ley.className='leyenda';
ley.innerHTML='<span><b style="color:var(--apro)">✓</b> Aprobada</span>'
+'<span><b style="color:var(--falla)">✕</b> Falla</span>'
+'<span><b style="color:var(--ens)">▣</b> Ensamblada</span>'
+'<span><b>·</b> Pendiente</span>'
+'<span>· Toca una tarjeta para ver o llenar su acta.</span>';
cont.append(ley);
// actividad reciente + por técnico
const dos=document.createElement('div'); dos.className='dos';
const rec=document.createElement('section'); rec.className='bloque';
rec.innerHTML='<h2>Actividad reciente</h2>';
const tbl=document.createElement('table');
tbl.innerHTML='<tr><th>Serie</th><th>Técnico</th><th>Resultado</th><th>Fecha</th></tr>';
actas.slice(0,12).forEach(a=>{
const tr=document.createElement('tr');
const p = a.resultado==='FALLA'?'<span class="pill falla">FALLA</span>':'<span class="pill apro">OK</span>';
tr.innerHTML='<td><b>'+(a.serie||'')+'</b></td><td>'+(a.tecnico||'')+'</td><td>'+p+'</td><td>'+fmt(a.recibido)+'</td>';
tbl.append(tr);
});
rec.append(tbl); if(!actas.length) rec.innerHTML+='<p class="resumen">Aún no hay actas registradas.</p>';
const pt=document.createElement('section'); pt.className='bloque';
pt.innerHTML='<h2>Por técnico</h2>';
const cnt={}; actas.forEach(a=>{ const t=(a.tecnico||'—').trim()||'—'; cnt[t]=(cnt[t]||0)+1; });
const t2=document.createElement('table'); t2.innerHTML='<tr><th>Técnico</th><th>Actas</th></tr>';
Object.entries(cnt).sort((x,y)=>y[1]-x[1]).forEach(([t,c])=>{ const tr=document.createElement('tr'); tr.innerHTML='<td>'+t+'</td><td>'+c+'</td>'; t2.append(tr); });
pt.append(t2); if(!actas.length) pt.innerHTML+='<p class="resumen">—</p>';
dos.append(rec,pt); cont.append(dos);
}
async function cargar(){
meta.textContent='Actualizando…';
try{
const r=await fetch('/api/actas',{cache:'no-store'});
const j=await r.json();
if(!j.ok) throw new Error(j.error||'error');
render(j.actas||[]);
meta.textContent='Actualizado '+new Date().toLocaleTimeString('es-MX',{hour:'2-digit',minute:'2-digit'})+' · '+(j.total||0)+' actas';
}catch(e){
cont.innerHTML='<div class="err">No se pudo cargar el tablero: '+e.message+'</div>';
meta.textContent='Error';
}
}
document.getElementById('refrescar').addEventListener('click',cargar);
cargar();
setInterval(cargar, 30000);
</script>
</body>
</html>

View File

@ -1,6 +1,6 @@
site_name: HBOX — Documentación Técnica
site_description: Procedimientos de banco, montaje y diagnóstico del HBOX (EinarBoard) — Helgen Technologies
site_url: https://drogheda.helgen.online/
site_url: https://docs.helgen.online/hbox/
copyright: "© Helgen Technologies — uso interno"
theme:
@ -40,6 +40,7 @@ markdown_extensions:
nav:
- Inicio: index.md
- "Tablero de estado": tablero.html
- Actas y control de calidad:
- "Índice de actas": actas/index.md
- "Acta de prueba V5": formularios/v5.html
@ -61,3 +62,8 @@ nav:
- Git:
- "Guía Git (español)": git/guia-git-es.md
- "Git guide (English)": git/git-guide-en.md
- Descargas:
- "Firmware y herramientas": descargas/index.md
- "README firmware bring-up v5": descargas/hbox-v5-bringup-README.md
- "Administración (Leo)": admin.html
- "Salir": salir.html

View File

@ -18,6 +18,10 @@ declare -a MAP=(
"03-Architecture-Hardware/0007-1000_Assembly_Guide.pdf|docs/montaje/0007-1000_Assembly_Guide.pdf"
"03-Architecture-Hardware/kicad/docs/GIT-GUIDE_Colaboradores-Externos_ES.md|docs/git/guia-git-es.md"
"03-Architecture-Hardware/kicad/docs/GIT-GUIDE_External-Collaborators_EN.md|docs/git/git-guide-en.md"
# Descargas para técnicos (firmware de bring-up v5)
"04-Source-Code/firmware/hbox_v5_bringup/release/hbox-v5-bringup_0.1.1.bin|docs/descargas/hbox-v5-bringup_0.1.1.bin"
"04-Source-Code/firmware/hbox_v5_bringup/release/SHA256SUMS|docs/descargas/SHA256SUMS.txt"
"04-Source-Code/firmware/hbox_v5_bringup/README.md|docs/descargas/hbox-v5-bringup-README.md"
)
n=0