actas: formularios de prueba V5/V4 + checklist de ensamble + backend

- backend Flask (backend/): recibe formularios firmados, guarda JSON por
  numero de serie en /srv/projects/drogheda/actas, indice CSV, GET /api/actas
  + systemd unit + README de instalacion (nginx /api proxy)
- formularios (docs/formularios/): acta.css/acta.js (motor con autoguardado
  y envio), V5 fiel al procedimiento verificado, V4 borrador adaptado,
  checklist de ensamble borrador; firma = nombre + conformidad
- docs/actas/index.md + guia-ensamble-qc.md; nav actualizada
- probado end-to-end (headless): envio + validacion + guardado por serie

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJ4eAB6zjQT49RVwpgfKdt
This commit is contained in:
Leopoldo Rodríguez 2026-09-16 18:05:16 +00:00
parent a7495bfbe6
commit 57f67332eb
13 changed files with 754 additions and 0 deletions

63
backend/README.md Normal file
View File

@ -0,0 +1,63 @@
# Backend de actas — configuración en el servidor (una sola vez)
Servicio Flask que recibe los formularios firmados de los técnicos y los guarda como
JSON por número de serie en `/srv/projects/drogheda/actas/`. Corre detrás de nginx en
`127.0.0.1:8081`; nginx expone `/api/` en el mismo dominio, con la misma contraseña.
## 1. Instalar (en el servidor, como leo)
```bash
cd /srv/projects/drogheda/hbox-docs/backend
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
# carpeta donde se guardan las actas
mkdir -p /srv/projects/drogheda/actas
```
## 2. Servicio systemd (arranque automático)
```bash
sudo cp actas-hbox.service /etc/systemd/system/actas-hbox.service
sudo systemctl daemon-reload
sudo systemctl enable --now actas-hbox
systemctl status actas-hbox # debe decir "active (running)"
curl -s http://127.0.0.1:8081/api/salud # {"ok":true,...}
```
## 3. nginx — exponer /api/ en el mismo dominio
Dentro del bloque `server { ... }` de `drogheda.helgen.online` (el mismo archivo
`/etc/nginx/sites-enabled/drogheda.helgen.online.conf`, en la parte de `listen 443`),
agrega **antes** del `location / {`:
```nginx
location /api/ {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# hereda el auth_basic del server; si quieres, puedes repetirlo aquí.
}
```
Luego:
```bash
sudo nginx -t && sudo systemctl reload nginx
curl -u leo:TU_PASS https://drogheda.helgen.online/api/salud # {"ok":true,...}
```
## 4. Ver las actas recibidas
- Índice rápido por tipo: `/srv/projects/drogheda/actas/<tipo>/_indice.csv` (ábrelo en Excel).
- Listado JSON: `https://drogheda.helgen.online/api/actas`
- Un acta concreta: `https://drogheda.helgen.online/api/actas/prueba-v5/V5-07_....json`
- Respaldo: basta con copiar la carpeta `/srv/projects/drogheda/actas/`.
## Actualizar el backend
Tras un `git pull` que cambie `app.py`:
```bash
sudo systemctl restart actas-hbox
```

View File

@ -0,0 +1,16 @@
[Unit]
Description=Actas HBOX — recepcion de formularios de tecnicos
After=network.target
[Service]
User=leo
Group=leo
WorkingDirectory=/srv/projects/drogheda/hbox-docs/backend
Environment=ACTAS_DIR=/srv/projects/drogheda/actas
Environment=PORT=8081
ExecStart=/srv/projects/drogheda/hbox-docs/backend/venv/bin/gunicorn -w 2 -b 127.0.0.1:8081 app:app
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

108
backend/app.py Normal file
View File

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Actas HBOX — servicio mínimo para recibir 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.
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)
"""
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")
SAFE = re.compile(r"[^A-Za-z0-9_\-]")
def slug(s, n=40):
return SAFE.sub("", str(s or "").strip())[:n]
@app.get("/api/salud")
def salud():
return jsonify(ok=True, servicio="actas-hbox")
@app.post("/api/actas")
def crear():
try:
d = request.get_json(force=True)
except Exception:
return jsonify(ok=False, error="JSON inválido"), 400
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
tecnico = str(d.get("tecnico", "")).strip()
if not tipo or not serie:
return jsonify(ok=False, error="Faltan 'tipo' o 'serie'"), 400
if not tecnico:
return jsonify(ok=False, error="Falta el nombre del técnico"), 400
if not d.get("conforme"):
return jsonify(ok=False, error="Falta la conformidad del técnico"), 400
ts = datetime.datetime.now().replace(microsecond=0)
iso = ts.isoformat()
d["_recibido"] = iso
d["_ip"] = request.headers.get("X-Forwarded-For", request.remote_addr)
carpeta = 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:
json.dump(d, f, ensure_ascii=False, indent=2)
# índice CSV acumulado por tipo (fácil de abrir en Excel)
idx = os.path.join(carpeta, "_indice.csv")
nuevo = not os.path.exists(idx)
with open(idx, "a", newline="", encoding="utf-8") as f:
w = csv.writer(f)
if nuevo:
w.writerow(["recibido", "serie", "tecnico", "resultado", "archivo"])
w.writerow([iso, serie, tecnico, d.get("resultado", ""), archivo])
return jsonify(ok=True, archivo=f"{tipo}/{archivo}")
@app.get("/api/actas")
def listar():
filas = []
if os.path.isdir(BASE):
for tipo in sorted(os.listdir(BASE)):
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
continue
for fn in sorted(os.listdir(carpeta)):
if fn.endswith(".json"):
try:
with open(os.path.join(carpeta, fn), encoding="utf-8") as f:
d = json.load(f)
except Exception:
continue
filas.append({
"tipo": tipo, "archivo": f"{tipo}/{fn}",
"serie": d.get("serie"), "tecnico": d.get("tecnico"),
"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")))

2
backend/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
flask>=3.0
gunicorn>=21.2

34
docs/actas/index.md Normal file
View File

@ -0,0 +1,34 @@
# Actas y control de calidad
Formularios que el técnico llena **en pantalla mientras prueba o ensambla** cada
tarjeta. Al terminar se firman (nombre + conformidad) y se envían al servidor, donde
quedan registrados por número de serie. Así queda constancia de que se siguió el
procedimiento en cada unidad.
## Formularios
**[▶ Acta de prueba — EinarBoard V5](../formularios/v5.html)**
Lote de 15 tarjetas (V5-01 … V5-15). Procedimiento verificado §1–§6: inspección,
continuidad, rieles 3V3 / 5 V / 24 V, flasheo DFU y POST.
**[▶ Acta de prueba — EinarBoard V4](../formularios/v4.html)** — *borrador*
V4-01 … V4-15. Adaptado de V5; la sección de MCU/firmware está marcada como
**pendiente de definir** para V4. Revisar antes de uso oficial.
**[▶ Checklist de ensamble — HBOX](../formularios/ensamble.html)** — *borrador*
Control de calidad durante el armado: recepción de PCBA, módulos, mecánica, arnés,
etiquetado y energizado final. Ver la [guía de ensamble](../montaje/guia-ensamble-qc.md).
## Cómo se usan
1. Abre el formulario de la versión que estás probando y **elige el número de serie**.
2. Ve llenando cada sección conforme avanzas. El formulario **se guarda solo** en el
navegador: si se cierra, al reabrir con el mismo número de serie recuperas lo escrito.
3. Al final marca el **resultado** (Aprobada / Falla), escribe tu **nombre** y confirma
que seguiste el procedimiento.
4. Pulsa **Enviar acta firmada**. Queda registrada en el servidor.
!!! note "Para ingeniería"
Los envíos se guardan en el servidor por tipo y número de serie. Índice rápido en
`…/actas/<tipo>/_indice.csv`, o el listado completo en
[`/api/actas`](/api/actas){:target="_blank"}.

57
docs/formularios/acta.css Normal file
View File

@ -0,0 +1,57 @@
/* Estilo compartido de las actas HBOX — pensado para tablet/laptop en el banco */
:root{
--naranja:#e8590c; --naranja-suave:#fff1e8; --tinta:#1d1c19; --tinta2:#5a564d;
--linea:#e2ddd2; --papel:#faf8f4; --tarjeta:#ffffff; --ok:#2f9e44; --falla:#e03131;
--ok-bg:#ebfbee; --falla-bg:#fff5f5; --aviso:#f08c00; --aviso-bg:#fff9db;
}
*{box-sizing:border-box}
body{margin:0;background:var(--papel);color:var(--tinta);
font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%}
.wrap{max-width:820px;margin:0 auto;padding:16px 16px 120px}
header.top{background:var(--tinta);color:#fff;padding:16px;border-radius:12px;margin-bottom:16px}
header.top h1{margin:0;font-size:20px}
header.top p{margin:4px 0 0;color:#d7d2c8;font-size:14px}
.borrador{background:var(--aviso-bg);border:1px solid var(--aviso);color:#a5620a;
padding:10px 14px;border-radius:10px;margin-bottom:16px;font-size:14px}
.datos{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:8px}
@media(max-width:560px){.datos{grid-template-columns:1fr}}
label{display:block;font-weight:600;font-size:14px;margin-bottom:4px}
input[type=text],input[type=number],select,textarea{
width:100%;padding:11px 12px;border:1px solid var(--linea);border-radius:9px;
font-size:16px;background:#fff;color:var(--tinta)}
input:focus,select:focus,textarea:focus{outline:2px solid var(--naranja);border-color:var(--naranja)}
section.paso{background:var(--tarjeta);border:1px solid var(--linea);border-radius:12px;
padding:14px 16px;margin:14px 0}
section.paso h2{margin:0 0 4px;font-size:17px;color:var(--naranja)}
section.paso .sub{color:var(--tinta2);font-size:13.5px;margin:0 0 10px}
.item{padding:10px 0;border-top:1px solid var(--linea)}
.item:first-of-type{border-top:0}
.chk{display:flex;gap:12px;align-items:flex-start;cursor:pointer}
.chk input{width:26px;height:26px;margin:1px 0 0;flex:0 0 auto;accent-color:var(--naranja)}
.chk .txt{flex:1}
.medida{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center}
.medida .txt{flex:1 1 100%;font-weight:600}
.medida .esp{flex:1 1 100%;color:var(--tinta2);font-size:13px;margin-top:-2px}
.medida input{max-width:150px}
.medida .u{color:var(--tinta2)}
.nota-item{color:var(--tinta2);font-size:14px}
.aviso{background:var(--aviso-bg);border-left:3px solid var(--aviso);padding:8px 12px;
border-radius:0 8px 8px 0;font-size:13.5px;margin:6px 0}
.firma{background:var(--naranja-suave);border:1px solid #ffd8a8}
.resultado{display:flex;gap:10px;margin-top:6px}
.resultado label{flex:1;border:2px solid var(--linea);border-radius:10px;padding:12px;
text-align:center;cursor:pointer;font-weight:700;margin:0}
.resultado input{position:absolute;opacity:0}
.resultado label:has(input:checked).ok{border-color:var(--ok);background:var(--ok-bg);color:var(--ok)}
.resultado label:has(input:checked).falla{border-color:var(--falla);background:var(--falla-bg);color:var(--falla)}
.barra{position:fixed;left:0;right:0;bottom:0;background:#fff;border-top:1px solid var(--linea);
padding:12px 16px;display:flex;gap:12px;align-items:center;justify-content:space-between}
.barra .estado{font-size:13px;color:var(--tinta2)}
button.enviar{background:var(--naranja);color:#fff;border:0;border-radius:10px;
padding:13px 22px;font-size:16px;font-weight:700;cursor:pointer}
button.enviar:disabled{opacity:.5;cursor:not-allowed}
.msg{padding:12px 14px;border-radius:10px;margin-top:12px;font-weight:600;display:none}
.msg.ok{display:block;background:var(--ok-bg);color:var(--ok);border:1px solid var(--ok)}
.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}

156
docs/formularios/acta.js Normal file
View File

@ -0,0 +1,156 @@
/* 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 */
(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 || '');
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' }));
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 —' }));
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 }));
}
const tecnico = $('input', { type: 'text', id: 'f_tecnico', placeholder: 'Nombre y apellido' });
const dS = $('div'); dS.append($('label', { text: 'Número de serie de la tarjeta' }), serieSel);
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
datos.append(dS, dT);
const cab = $('section', { class: 'paso' }); cab.append(datos);
wrap.append(cab);
// secciones del procedimiento
cfg.secciones.forEach((sec, si) => {
const s = $('section', { class: 'paso' });
s.append($('h2', { text: sec.titulo }));
if (sec.sub) s.append($('p', { class: 'sub', text: sec.sub }));
(sec.items || []).forEach((it, ii) => {
const id = `s${si}_${ii}`;
const box = $('div', { class: 'item' });
if (it.aviso) box.append($('div', { class: 'aviso', html: it.aviso }));
if (it.tipo === 'check') {
const lab = $('label', { class: 'chk' });
lab.append($('input', { type: 'checkbox', 'data-id': id, 'data-lbl': it.txt }));
lab.append($('span', { class: 'txt', html: it.txt }));
box.append(lab);
} else if (it.tipo === 'medida') {
box.classList.add('medida');
box.append($('span', { class: 'txt', text: it.txt }));
if (it.esperado) box.append($('span', { class: 'esp', html: 'Esperado: ' + it.esperado }));
box.append($('input', { type: 'text', inputmode: 'decimal', 'data-id': id, 'data-lbl': it.txt, placeholder: 'medido' }));
if (it.u) box.append($('span', { class: 'u', text: it.u }));
} else if (it.tipo === 'texto') {
box.append($('label', { text: it.txt }));
box.append($('textarea', { rows: '2', 'data-id': id, 'data-lbl': it.txt, placeholder: it.ph || '' }));
} else if (it.tipo === 'nota') {
box.append($('div', { class: 'nota-item', html: it.txt }));
}
s.append(box);
});
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.' }));
const res = $('div', { class: 'resultado' });
const rOK = $('label', { class: 'ok' }); rOK.append($('input', { type: 'radio', name: 'resultado', value: 'OK' }), document.createTextNode('APROBADA'));
const rNO = $('label', { class: 'falla' }); rNO.append($('input', { type: 'radio', name: 'resultado', value: 'FALLA' }), document.createTextNode('FALLA'));
res.append(rOK, rNO);
firma.append($('label', { text: 'Resultado final' }), res);
const conf = $('label', { class: 'chk', style: 'margin-top:14px' });
conf.append($('input', { type: 'checkbox', id: 'f_conforme' }));
conf.append($('span', { class: 'txt', html: 'Confirmo que <b>seguí el procedimiento completo</b> y que los datos anotados son verídicos.' }));
firma.append(conf);
wrap.append(firma);
const msg = $('div', { class: 'msg', id: 'f_msg' });
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 btn = $('button', { class: 'enviar', id: 'f_enviar', text: 'Enviar acta firmada' });
barra.append(estado, btn);
document.body.append(barra);
// ---- autoguardado ----
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 };
});
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 };
}
let t;
function guardar() {
if (!serieSel.value) { estado.textContent = 'Elige el número de serie'; 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() {
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 || '';
}
estado.textContent = 'Borrador recuperado';
}
wrap.addEventListener('input', guardar);
serieSel.addEventListener('change', cargar);
// ---- envío ----
btn.addEventListener('click', async () => {
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.');
if (!d.resultado) return fail('Marca el resultado final (Aprobada o Falla).');
if (!d.conforme) return fail('Debes confirmar que seguiste el procedimiento.');
btn.disabled = true; btn.textContent = 'Enviando…';
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.';
try { localStorage.removeItem(key()); } catch (e) {}
wrap.classList.add('enviada'); estado.textContent = 'Enviada';
btn.textContent = '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).');
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();
}
window.Acta = { render };
})();

View File

@ -0,0 +1,83 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Checklist de ensamble — HBOX (borrador)</title>
<link rel="stylesheet" href="acta.css">
</head>
<body>
<div id="acta"></div>
<script src="acta.js"></script>
<script>
/* BORRADOR — checklist de control de calidad durante el ensamble del HBOX.
Basado en el BOM de ensamble y el pinout de arneses. Ingeniería debe validar
los pasos antes de usarlo como registro oficial. */
Acta.render({
tipo: 'ensamble',
prefijo: 'HBOX',
series: 15,
borrador: true,
titulo: 'Control de calidad de ensamble — HBOX',
subtitulo: 'Llena mientras armas la unidad. Un acta por equipo (número de serie).',
secciones: [
{ titulo: '1 · Recepción de la PCBA',
sub: 'Revisión de la tarjeta ensamblada que llega de JLCPCB, antes de montar nada.',
items: [
{ tipo:'check', txt:'La tarjeta corresponde al modelo/revisión correcto (serigrafía).' },
{ tipo:'check', txt:'Sin componentes faltantes, torcidos, ni con soldadura fría (inspección visual con lupa).' },
{ tipo:'check', txt:'Sin puentes de soldadura, sobre todo en los ICs de paso fino.' },
{ tipo:'check', txt:'Conectores y zócalos rectos, sin pines doblados.' },
{ tipo:'texto', txt:'Defectos encontrados en la PCBA', ph:'ninguno / describir' },
] },
{ titulo: '2 · Soldadura manual (THT y conectores)',
sub: 'Partes que se sueldan a mano después de la PCBA (si aplica en este lote).',
items: [
{ tipo:'check', txt:'Terminal de potencia <b>P1</b> (Phoenix/GX16) firme y con soldadura completa.' },
{ tipo:'check', txt:'Conectores JST (<b>P2, P4, P6, P8, P10, P11, P12</b>…) soldados y sin frío.' },
{ tipo:'check', txt:'Relevador <b>K1</b>, botón <b>SW1</b> y electrolíticos radiales bien asentados y en polaridad correcta.' },
{ tipo:'check', txt:'Zócalos de módulos (J1/J2, J7/J8, MOD1) soldados a ras y rectos.' },
] },
{ titulo: '3 · Módulos enchufables',
sub: 'Se instalan sobre la tarjeta ya probada. Verifica orientación antes de energizar.',
items: [
{ tipo:'check', txt:'MCU (STM32) correctamente asentado y en la orientación correcta.' },
{ tipo:'check', txt:'simpleRTK2B Pro insertado en <b>MOD1</b> sin forzar pines.' },
{ tipo:'check', txt:'Módulo 4G insertado en su zócalo (2 mm) en la orientación correcta.' },
{ tipo:'check', txt:'Ninguna antena/módulo obstruye el cierre del gabinete.' },
] },
{ titulo: '4 · Mecánica y gabinete',
items: [
{ tipo:'check', txt:'Tarjeta fijada con tornillos/separadores M2.5/M3 en los barrenos correctos, sin flexión.' },
{ tipo:'check', txt:'Ventilador montado y con flujo en la dirección correcta.' },
{ tipo:'check', txt:'Conectores de panel (GX16, RJ45, antenas) alineados con sus recortes del gabinete.' },
{ tipo:'check', txt:'Sin cables pellizcados; el gabinete cierra sin forzar.' },
] },
{ titulo: '5 · Arnés y cableado',
sub: 'Verifica cada conector contra el pinout de arneses.',
items: [
{ tipo:'check', txt:'<b>P1</b> alimentación de máquina: pin 1 = 24V, pin 2 = GND, pin 3 = ignición.' },
{ tipo:'check', txt:'<b>P2</b> entrada 5 V (Mean Well) en polaridad correcta.' },
{ tipo:'check', txt:'<b>P4</b> batería 1S4P + NTC conectado (pin 1 = BAT+, 2 = GND, 3 = NTC).' },
{ tipo:'check', txt:'<b>P6</b> ventilador y demás JST insertados a fondo (clic), cada uno en su lugar.' },
{ tipo:'check', txt:'Sin conectores intercambiados; colores/etiquetas del arnés coinciden.' },
{ tipo:'texto', txt:'Observaciones del arnés', ph:'ninguno / describir' },
] },
{ titulo: '6 · Etiquetado e identidad',
items: [
{ tipo:'check', txt:'Número de serie del equipo colocado de forma visible y legible.' },
{ tipo:'check', txt:'Etiqueta no tapa serigrafía, tornillos ni ventilación.' },
] },
{ titulo: '7 · Energizado final',
sub: 'Primera energización del equipo ya armado (con la tarjeta previamente aprobada en su acta de prueba).',
items: [
{ tipo:'check', txt:'Enciende sin olor, humo ni calentamiento anormal.' },
{ tipo:'check', txt:'LED(s) de estado responden como se espera.' },
{ tipo:'check', txt:'Ventilador gira.' },
{ tipo:'texto', txt:'Número de serie de la tarjeta (acta de prueba) usada en este equipo', ph:'p.ej. V5-07' },
] },
]
});
</script>
</body>
</html>

85
docs/formularios/v4.html Normal file
View File

@ -0,0 +1,85 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de prueba — EinarBoard V4 (borrador)</title>
<link rel="stylesheet" href="acta.css">
</head>
<body>
<div id="acta"></div>
<script src="acta.js"></script>
<script>
/* BORRADOR V4 — adaptado del procedimiento verificado de V5.
V4 usa módulos enchufables (Blackpill en J1/J2, 4G XBee en J7/J8, simpleRTK2B en MOD1)
en lugar del MCU y Ethernet on-board de V5. Ingeniería debe confirmar los puntos
marcados "PENDIENTE V4" antes de usarlo como registro oficial. */
Acta.render({
tipo: 'prueba-v4',
prefijo: 'V4',
series: 15,
borrador: true,
titulo: 'Acta de prueba — EinarBoard V4',
subtitulo: 'Borrador adaptado de V5 — revisar con ingeniería antes de uso oficial.',
secciones: [
{ titulo: '1 · Inspección visual (sin energía)',
sub: 'Con lupa. Marca cada casilla solo cuando lo verifiques.',
items: [
{ tipo:'check', txt:'Sin puentes de soldadura entre pines de <b>U6</b> (LTC4040), <b>U2</b>/<b>U7</b> (bucks MP1584), <b>U3</b>/<b>U4</b> (sensores).' },
{ tipo:'check', txt:'Zócalos de módulos limpios y sin pines doblados: <b>J1/J2</b> (Blackpill), <b>J7/J8</b> (4G), <b>MOD1</b> (simpleRTK2B).' },
{ tipo:'check', txt:'Botón <b>SW1</b> (RESET) presente y con "clic".' },
{ tipo:'check', txt:'Jumpers del cargador presentes: <b>JP2, JP3, JP4</b> (no tocar su posición).', aviso:'PENDIENTE V4: confirmar cristales y refs exactas con ingeniería.' },
{ tipo:'check', txt:'Sin componentes faltantes ni marcas de quemado.' },
] },
{ titulo: '2 · Continuidad — cortos a tierra (sin energía)',
sub: 'Multímetro en ohms, punta negra en GND. Espera a que la lectura se estabilice.',
items: [
{ tipo:'medida', txt:'Riel 3V3 (TP 3V3)', esperado:'> 1 kΩ (sube lento)', u:'Ω' },
{ tipo:'medida', txt:'P2 pin 1 — VEH5V (entrada 5 V)', esperado:'> 1 kΩ', u:'Ω' },
{ tipo:'medida', txt:'P11 pin 1 — MAIN5V', esperado:'> 1 kΩ', u:'Ω' },
{ tipo:'medida', txt:'P1 pin 1 — 24V_IN', esperado:'> 10 kΩ', u:'Ω' },
{ tipo:'medida', txt:'MOD1 pin "5V" — SW_5V (etapa RTK)', esperado:'> 1 kΩ', u:'Ω',
aviso:'Una lectura fija de 0–20 Ω = corto = FALLA.' },
] },
{ titulo: '3 · Riel 3.3 V',
sub: 'Energiza según el método de V4 (PENDIENTE V4: confirmar si hay USB DFU o se alimenta por P2).',
items: [
{ tipo:'medida', txt:'Riel 3V3 respecto a GND', esperado:'3.20 – 3.40 V', u:'V' },
{ tipo:'check', txt:'LED de encendido presente.' },
{ tipo:'check', txt:'Regulador 3V3 (U17 AMS1117) tibio, no quema.' },
] },
{ titulo: '4a · Riel 5 V con fuente de banco (entrada P2)',
sub: 'Fuente 5.0 V, límite 500 mA. Jumper en P11. Sin módulos insertados.',
items: [
{ tipo:'medida', txt:'Corriente de la fuente', esperado:'< 300 mA', u:'mA' },
{ tipo:'medida', txt:'P11 pin 1 — MAIN5V', esperado:'4.8 – 5.1 V', u:'V' },
{ tipo:'medida', txt:'P11 pin 2 — SYS5V (con jumper)', esperado:'4.8 – 5.1 V', u:'V' },
{ tipo:'medida', txt:'Riel 3V3', esperado:'3.20 – 3.40 V', u:'V' },
{ tipo:'medida', txt:'MOD1 pin "5V" — SW_5V', esperado:'0 V (obligatorio)', u:'V',
aviso:'Si hay 5 V aquí → FALLA (la etapa RTK debe arrancar apagada).' },
{ tipo:'check', txt:'<b>U6</b> tibio, no quema.' },
] },
{ titulo: '4b · Riel 24 V → buck del ventilador (entrada P1)',
sub: 'Fuente 24.0 V, límite 200 mA. Pin 3 (ignición) sin conectar.',
items: [
{ tipo:'medida', txt:'Corriente de la fuente', esperado:'< 50 mA', u:'mA' },
{ tipo:'medida', txt:'P6 pin 1 — 5V_FAN (contra GND)', esperado:'4.9 – 5.1 V', u:'V' },
{ tipo:'check', txt:'<b>U2</b> (buck) frío o tibio.' },
] },
{ titulo: '5 · Módulos y firmware (PENDIENTE V4)',
sub: 'V4 usa MCU Blackpill enchufable. El flujo de flasheo difiere de V5 — ingeniería debe definirlo.',
items: [
{ tipo:'nota', txt:'⚠ <b>PENDIENTE V4:</b> definir el método de flasheo del Blackpill y el firmware de arranque para V4. Mientras tanto, anota abajo lo que se hizo.' },
{ tipo:'check', txt:'Blackpill insertado correctamente en J1/J2 (orientación verificada).' },
{ tipo:'texto', txt:'Firmware cargado / observaciones', ph:'versión, método, resultado del arranque…' },
] },
{ titulo: '6 · Cierre',
items: [
{ tipo:'check', txt:'Tarjeta etiquetada con su número de serie y fecha.' },
{ tipo:'check', txt:'Guardada en bolsa antiestática (las FALLA aparte con la nota del punto que falló).' },
] },
]
});
</script>
</body>
</html>

91
docs/formularios/v5.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>Acta de prueba — EinarBoard V5</title>
<link rel="stylesheet" href="acta.css">
</head>
<body>
<div id="acta"></div>
<script src="acta.js"></script>
<script>
Acta.render({
tipo: 'prueba-v5',
prefijo: 'V5',
series: 15,
titulo: 'Acta de prueba y flasheo — EinarBoard V5',
subtitulo: 'Lote 15 tarjetas (JLC 22-Ago). Llena cada sección durante la prueba.',
secciones: [
{ titulo: '1 · Inspección visual (sin energía)',
sub: 'Con lupa. Marca cada casilla solo cuando lo verifiques.',
items: [
{ tipo:'check', txt:'Sin puentes de soldadura entre pines de <b>U16</b> (MCU), <b>U11</b> (W5500), <b>U10</b> (SC16IS752), <b>U6</b> (LTC4040).' },
{ tipo:'check', txt:'<b>Y2</b> dice <code>3.6864</code> (o <code>36.864</code>). Y1 y Y3 = <code>25.000</code>, Y4 = 32.768 kHz.', aviso:'Si Y2 dice <code>1.8432</code> → marca y avisa a ingeniería.' },
{ tipo:'check', txt:'Botones <b>SW1</b> y <b>SW2</b> presentes y con "clic".' },
{ tipo:'check', txt:'Conectores <b>J11</b>, <b>J9</b>, <b>J10 (RJ45)</b> bien asentados, sin pines doblados.' },
{ tipo:'check', txt:'Jumpers del cargador presentes: <b>JP2, JP3, JP4</b> (no tocar su posición).' },
{ tipo:'check', txt:'Sin componentes faltantes ni marcas de quemado.' },
] },
{ titulo: '2 · Continuidad — cortos a tierra (sin energía)',
sub: 'Multímetro en ohms, punta negra en TP6 (GND). Espera a que la lectura se estabilice.',
items: [
{ tipo:'medida', txt:'TP7 — riel 3V3', esperado:'> 1 kΩ (sube lento)', u:'Ω' },
{ tipo:'medida', txt:'P2 pin 1 — VEH5V (entrada 5 V)', esperado:'> 1 kΩ', u:'Ω' },
{ tipo:'medida', txt:'P11 pin 1 — MAIN5V', esperado:'> 1 kΩ', u:'Ω' },
{ tipo:'medida', txt:'P1 pin 1 — 24V_IN', esperado:'> 10 kΩ', u:'Ω' },
{ tipo:'medida', txt:'MOD1 pin "5V" — SW_5V (etapa RTK)', esperado:'> 1 kΩ', u:'Ω',
aviso:'Una lectura fija de 0–20 Ω = corto = FALLA. Unos cientos de Ω que suben es normal (capacitores cargando).' },
] },
{ titulo: '3 · Riel 3.3 V por USB (J11)',
sub: 'Conecta el USB-C en J11 (junto a los botones). Solo energiza el MCU.',
items: [
{ tipo:'check', txt:'<b>D18</b> (LED verde PWR) <b>encendido</b>.' },
{ tipo:'medida', txt:'TP7 respecto a TP6', esperado:'3.20 – 3.40 V', u:'V' },
{ tipo:'check', txt:'<b>U17</b> (AMS1117) tibio tras 1 min (si quema → FALLA).' },
{ tipo:'nota', txt:'Deja el USB conectado para las siguientes secciones.' },
] },
{ titulo: '4a · Riel 5 V con fuente de banco (entrada P2)',
sub: 'Fuente 5.0 V, límite 500 mA. Coloca el jumper en P11. MCU virgen (sin firmware).',
items: [
{ tipo:'medida', txt:'Corriente de la fuente', esperado:'< 300 mA (típ. 100–250)', u:'mA' },
{ tipo:'medida', txt:'P11 pin 1 — MAIN5V', esperado:'4.8 – 5.1 V', u:'V' },
{ tipo:'medida', txt:'P11 pin 2 — SYS5V (con jumper)', esperado:'4.8 – 5.1 V', u:'V' },
{ tipo:'medida', txt:'TP7 — 3V3', esperado:'3.20 – 3.40 V', u:'V' },
{ tipo:'medida', txt:'MOD1 pin "5V" — SW_5V', esperado:'0 V (obligatorio)', u:'V',
aviso:'Si hay 5 V aquí → FALLA (la etapa RTK no arranca apagada).' },
{ tipo:'check', txt:'<b>U6</b> y <b>U12</b> tibios, no queman.' },
] },
{ titulo: '4b · Riel 24 V → buck del ventilador (entrada P1)',
sub: 'Fuente 24.0 V, límite 200 mA. Pin 3 (ignición) sin conectar.',
items: [
{ tipo:'medida', txt:'Corriente de la fuente', esperado:'< 50 mA (sin ventilador)', u:'mA' },
{ tipo:'medida', txt:'P6 pin 1 — 5V_FAN (contra TP6)', esperado:'4.9 – 5.1 V', u:'V' },
{ tipo:'check', txt:'<b>U2</b> (buck) frío o tibio.' },
{ tipo:'nota', txt:'Si algún valor está fuera de rango: marca FALLA y no flashees la tarjeta.' },
] },
{ titulo: '5 · Flasheo por USB DFU (J11)',
sub: 'Solo tarjetas que pasaron 1–4. Firmware: hbox-v5-bringup_0.1.0.bin @ 0x08000000.',
items: [
{ tipo:'check', txt:'DFU: mantener <b>SW2 (BOOT0)</b>, presionar y soltar <b>SW1 (RESET)</b>, soltar SW2 → aparece "STM32 BOOTLOADER" (0483:DF11).' },
{ tipo:'check', txt:'Flasheado con STM32CubeProgrammer (o dfu-util): "download complete" + "verify successful".' },
] },
{ titulo: '5d · Verificación después del flasheo',
sub: 'Abre un terminal serie a 115200 baud y pulsa SW1 (RESET).',
items: [
{ tipo:'check', txt:'<b>D17</b> (LED azul) parpadea a 1 Hz.' },
{ tipo:'check', txt:'<b>DB1 → DB4</b> se encienden en secuencia una vez.' },
{ tipo:'check', txt:'Aparece un puerto serie nuevo en la laptop.' },
{ tipo:'check', txt:'Encabezado <code>hbox-v5-bringup 0.1.0</code> y al final <code>&gt;&gt;&gt; BOARD OK</code>.' },
{ tipo:'texto', txt:'Reporte / observaciones (anota cualquier línea [FAIL] completa)', ph:'---- RESULT: 12 PASS / 0 FAIL ----' },
] },
{ titulo: '6 · Cierre',
items: [
{ tipo:'check', txt:'Tarjeta etiquetada con su número de serie y fecha (cerca de MH4).' },
{ tipo:'check', txt:'Guardada en bolsa antiestática (las FALLA en bolsa aparte con la nota del punto que falló).' },
] },
]
});
</script>
</body>
</html>

View File

@ -0,0 +1,52 @@
# Guía de ensamble y control de calidad — HBOX
!!! warning "Borrador para revisión"
Esta guía es un **borrador** basado en el BOM de ensamble y el pinout de arneses.
Ingeniería debe validarla antes de adoptarla como procedimiento oficial. El
**checklist interactivo** que la acompaña está en
[Actas → Checklist de ensamble](../actas/index.md).
El objetivo es que cada HBOX se arme siempre igual y quede un registro firmado por
unidad. El ensamble parte de una **PCBA ya probada y aprobada** en su acta de prueba
(V4 o V5); esta guía cubre lo que ocurre *después* de esa prueba: montaje de módulos,
mecánica, arnés y energizado final.
## Orden de trabajo
1. **Recepción de la PCBA.** Inspección visual de la tarjeta que llega de JLCPCB:
componentes completos, sin soldadura fría ni puentes, conectores rectos.
2. **Soldadura manual** (si el lote la requiere): terminal de potencia P1, conectores
JST, relevador K1, botón SW1, electrolíticos radiales y zócalos de módulos. Cuidar
polaridad de diodos y electrolíticos.
3. **Prueba eléctrica de la tarjeta** → se registra en su acta de prueba
([V5](../formularios/v5.html) / [V4](../formularios/v4.html)). **No se montan módulos
hasta que la tarjeta pase.**
4. **Módulos enchufables** sobre la tarjeta aprobada: MCU, simpleRTK2B en MOD1, módulo
4G. Verificar orientación **antes** de energizar.
5. **Mecánica:** fijar la tarjeta con separadores en sus barrenos, montar ventilador
(dirección de flujo), alinear conectores de panel con el gabinete.
6. **Arnés:** conectar cada JST contra el pinout, sin intercambiar conectores.
7. **Etiquetado:** número de serie del equipo, visible y legible.
8. **Energizado final** del equipo armado y registro del acta de ensamble.
## Referencia de conectores (arnés)
| Conector | Función | Puntos clave |
|---|---|---|
| **P1** GX16-5 | Alimentación de máquina + ignición | 1 = 24V ⚡, 3 = ignición, 2 = GND |
| **P2** | Entrada 5 V (Mean Well SD-15B) | 1 = VEH5V ⚡, 2 = GND |
| **P4** | Batería 1S4P + NTC | 1 = BAT+ ⚡, 3 = NTC, 2 = GND |
| **P6** | Ventilador | 1 = 5V_FAN ⚡, 2 = retorno, 4 = tacómetro |
| **P11** | Corte de energía (SYS→MAIN 5 V) | 1 = MAIN5V ⚡, 2 = SYS5V ⚡ |
| **MOD1** | Zócalo simpleRTK2B Pro | pin "5V" debe estar en 0 V con la tarjeta virgen |
*(Pinout completo en [Pinout de arneses](pinout-arneses.md). La guía de ensamble
detallada del gabinete es el [PDF 0007-1000](assembly-guide.md).)*
## Reglas de calidad
- Cada unidad lleva **dos actas**: la de **prueba de la tarjeta** y la de **ensamble**.
La de ensamble anota el número de serie de la tarjeta usada, para trazabilidad.
- Una tarjeta con FALLA **no se ensambla**; va a la bolsa de rechazos con su nota.
- Cualquier diferencia entre lo que ves y lo que dice el documento: **detente y
pregunta** a ingeniería antes de continuar.

View File

@ -30,6 +30,7 @@ markdown_extensions:
- admonition - admonition
- tables - tables
- attr_list - attr_list
- md_in_html
- pymdownx.details - pymdownx.details
- pymdownx.superfences - pymdownx.superfences
- pymdownx.tasklist: - pymdownx.tasklist:
@ -39,6 +40,11 @@ markdown_extensions:
nav: nav:
- Inicio: index.md - Inicio: index.md
- Actas y control de calidad:
- "Índice de actas": actas/index.md
- "Acta de prueba V5": formularios/v5.html
- "Acta de prueba V4 (borrador)": formularios/v4.html
- "Checklist de ensamble (borrador)": formularios/ensamble.html
- Procedimientos de banco: - Procedimientos de banco:
- "EinarBoard v5 — Prueba y flasheo (lote 15 pzas)": procedimientos/v5-prueba-y-flasheo.md - "EinarBoard v5 — Prueba y flasheo (lote 15 pzas)": procedimientos/v5-prueba-y-flasheo.md
- "EinarBoard 1.0 — Bring-up runbook": procedimientos/einarboard-1.0-bringup.md - "EinarBoard 1.0 — Bring-up runbook": procedimientos/einarboard-1.0-bringup.md
@ -48,6 +54,7 @@ nav:
- Montaje y arneses: - Montaje y arneses:
- "Pinout de arneses y conectores": montaje/pinout-arneses.md - "Pinout de arneses y conectores": montaje/pinout-arneses.md
- "BOM dividido para ensamble": montaje/bom-split.md - "BOM dividido para ensamble": montaje/bom-split.md
- "Guía de ensamble y control de calidad": montaje/guia-ensamble-qc.md
- "Guía de ensamble (PDF)": montaje/assembly-guide.md - "Guía de ensamble (PDF)": montaje/assembly-guide.md
- Diagnóstico: - Diagnóstico:
- "Protocolo de diagnóstico remoto": diagnostico/protocolo-diagnostico-remoto.md - "Protocolo de diagnóstico remoto": diagnostico/protocolo-diagnostico-remoto.md

0
scripts/deploy.sh Normal file → Executable file
View File