diff --git a/backend/app.py b/backend/app.py index 494bb88..302ebb8 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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// -> 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,28 +85,85 @@ 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) - 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]) + 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: + 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}") + 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}", - "serie": d.get("serie"), "tecnico": d.get("tecnico"), - "resultado": d.get("resultado"), "recibido": d.get("_recibido"), - }) + 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//") -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"))) diff --git a/docs/admin.html b/docs/admin.html new file mode 100644 index 0000000..ce14be1 --- /dev/null +++ b/docs/admin.html @@ -0,0 +1,86 @@ + + + + + +Administración — HBOX + + + + +
+

Administración de actas

Solo para el administrador.

+ +

Comprobando permisos…

+
+ + + diff --git a/docs/descargas/SHA256SUMS.txt b/docs/descargas/SHA256SUMS.txt new file mode 100644 index 0000000..b84ea21 --- /dev/null +++ b/docs/descargas/SHA256SUMS.txt @@ -0,0 +1 @@ +91ca6f87a86686be33ce013110f214a7e94ae7c8909c9eea1dbe846d9b5247f3 hbox-v5-bringup_0.1.1.bin diff --git a/docs/descargas/hbox-v5-bringup-README.md b/docs/descargas/hbox-v5-bringup-README.md new file mode 100644 index 0000000..f447570 --- /dev/null +++ b/docs/descargas/hbox-v5-bringup-README.md @@ -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. diff --git a/docs/descargas/hbox-v5-bringup_0.1.1.bin b/docs/descargas/hbox-v5-bringup_0.1.1.bin new file mode 100644 index 0000000..488d01c Binary files /dev/null and b/docs/descargas/hbox-v5-bringup_0.1.1.bin differ diff --git a/docs/descargas/index.md b/docs/descargas/index.md new file mode 100644 index 0000000..efbefaa --- /dev/null +++ b/docs/descargas/index.md @@ -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 diff --git a/docs/formularios/acta.css b/docs/formularios/acta.css index b3706fb..55c442f 100644 --- a/docs/formularios/acta.css +++ b/docs/formularios/acta.css @@ -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)} diff --git a/docs/formularios/acta.js b/docs/formularios/acta.js index c5d5f58..4fa2200 100644 --- a/docs/formularios/acta.js +++ b/docs/formularios/acta.js @@ -1,28 +1,34 @@ -/* Motor de actas HBOX — renderiza el formulario desde una config y lo envía al servidor. - Uso: 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: '⚠ BORRADOR — 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 = '✓ Acta ya enviada por ' + (a.tecnico || '—') + '' + + (f ? ' el ' + f : '') + '. Registro final' + + (a.resultado ? ' — resultado ' + a.resultado + '' : '') + '.' + + (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 || ''; - } - estado.textContent = 'Borrador recuperado'; + if (d) { fillFrom(d); estado.textContent = 'Borrador recuperado'; } + else estado.textContent = 'Nueva acta'; } - wrap.addEventListener('input', guardar); - serieSel.addEventListener('change', cargar); + + // ---- 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', 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.status === 409) { // ya existía: recargar bloqueada + try { localStorage.removeItem(key()); } catch (e) {} + 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)); - msg.className = 'msg ok'; - msg.innerHTML = '✓ Acta enviada y registrada como ' + d.serie + '. 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); + msg.className = 'msg ok'; + msg.innerHTML = '✓ Acta enviada y registrada como ' + d.serie + '.'; + 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 }; })(); diff --git a/docs/formularios/ver.html b/docs/formularios/ver.html new file mode 100644 index 0000000..e30f883 --- /dev/null +++ b/docs/formularios/ver.html @@ -0,0 +1,91 @@ + + + + + +Ver acta — HBOX + + + + +
+

Acta

Cargando…

+ +
+
+ + + diff --git a/docs/procedimientos/v5-prueba-y-flasheo.md b/docs/procedimientos/v5-prueba-y-flasheo.md index 0a9e4cc..f168446 100644 --- a/docs/procedimientos/v5-prueba-y-flasheo.md +++ b/docs/procedimientos/v5-prueba-y-flasheo.md @@ -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). diff --git a/docs/tablero.html b/docs/tablero.html new file mode 100644 index 0000000..d01385f --- /dev/null +++ b/docs/tablero.html @@ -0,0 +1,190 @@ + + + + + +Tablero de estado — HBOX + + + +
+
+

Tablero de estado — HBOX

Cargando…
+
+ ← Documentación + +
+
+
+
+ + + diff --git a/mkdocs.yml b/mkdocs.yml index c67be98..c71f318 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/scripts/sync-docs.sh b/scripts/sync-docs.sh index 932de77..85d39ec 100755 --- a/scripts/sync-docs.sh +++ b/scripts/sync-docs.sh @@ -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