diagnostico en campo y banco: actas con fotos y logs, reflasheo, portada e indice

This commit is contained in:
Leopoldo Rodríguez 2026-09-18 11:27:40 -06:00
parent 455dbc4dc1
commit 86af91fe4b
20 changed files with 1268 additions and 706 deletions

View File

@ -53,6 +53,7 @@ El backend la deduce sola de `ACTAS_DIR`; si prefieres fijarla, agrega a
```ini ```ini
Environment=ADJUNTOS_DIR=/srv/projects/drogheda/adjuntos Environment=ADJUNTOS_DIR=/srv/projects/drogheda/adjuntos
Environment=ADJUNTO_MAX_MB=8 Environment=ADJUNTO_MAX_MB=8
Environment=ARCHIVO_MAX_MB=20
Environment=ACTAS_REPETIBLES=diagnostico-campo,instalacion-campo Environment=ACTAS_REPETIBLES=diagnostico-campo,instalacion-campo
``` ```
@ -66,14 +67,14 @@ En el bloque `server { listen 443 … }` de `drogheda.helgen.online`, el `locati
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Remote-User $remote_user; # ← quién es el técnico proxy_set_header X-Remote-User $remote_user; # ← quién es el técnico
client_max_body_size 12m; # ← sin esto, las fotos dan 413 client_max_body_size 25m; # ← sin esto, las fotos dan 413
proxy_read_timeout 120s; # ← subidas con señal mala proxy_read_timeout 120s; # ← subidas con señal mala
} }
``` ```
Las **dos líneas marcadas son nuevas y obligatorias**: Las **dos líneas marcadas son nuevas y obligatorias**:
- Sin `client_max_body_size`, nginx corta la foto en 1 MB y el técnico ve *"la foto pesa - Sin `client_max_body_size`, nginx corta la subida en 1 MB y el técnico ve *"la foto pesa
demasiado"* aunque no lo pese. (El formulario ya reduce cada foto a ~1600 px / 300-400 kB demasiado"* aunque no lo pese. (El formulario ya reduce cada foto a ~1600 px / 300-400 kB
antes de subirla; el margen es para ráfagas de varias fotos.) antes de subirla; el margen es para ráfagas de varias fotos.)
- Sin `X-Remote-User`, el backend no sabe quién envió cada acta: el nombre no se precarga y —más - Sin `X-Remote-User`, el backend no sabe quién envió cada acta: el nombre no se precarga y —más

View File

@ -7,6 +7,11 @@ User=leo
Group=leo Group=leo
WorkingDirectory=/srv/projects/drogheda/hbox-docs/backend WorkingDirectory=/srv/projects/drogheda/hbox-docs/backend
Environment=ACTAS_DIR=/srv/projects/drogheda/actas Environment=ACTAS_DIR=/srv/projects/drogheda/actas
Environment=ADJUNTOS_DIR=/srv/projects/drogheda/adjuntos
Environment=ADJUNTO_MAX_MB=8
Environment=ARCHIVO_MAX_MB=20
Environment=ACTAS_REPETIBLES=diagnostico-campo,instalacion-campo
Environment=ACTAS_ADMINS=leo
Environment=PORT=8081 Environment=PORT=8081
ExecStart=/srv/projects/drogheda/hbox-docs/backend/venv/bin/gunicorn -w 2 -b 127.0.0.1:8081 app:app ExecStart=/srv/projects/drogheda/hbox-docs/backend/venv/bin/gunicorn -w 2 -b 127.0.0.1:8081 app:app
Restart=on-failure Restart=on-failure

View File

@ -40,8 +40,12 @@ TIPOS_REPETIBLES = {t.strip() for t in os.environ.get(
# Subida de fotos # Subida de fotos
MAX_MB = int(os.environ.get("ADJUNTO_MAX_MB", "8")) MAX_MB = int(os.environ.get("ADJUNTO_MAX_MB", "8"))
app.config["MAX_CONTENT_LENGTH"] = (MAX_MB + 2) * 1024 * 1024 # Archivos de log (capturas por USB del Blackpill, del 4G y del ArduSimple).
LOG_MAX_MB = int(os.environ.get("ARCHIVO_MAX_MB", "20"))
app.config["MAX_CONTENT_LENGTH"] = (max(MAX_MB, LOG_MAX_MB) + 2) * 1024 * 1024
EXT_OK = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"} EXT_OK = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
# Lista blanca de logs: texto plano y capturas binarias de u-blox. Nada ejecutable.
EXT_LOG = {".txt", ".log", ".csv", ".nmea", ".ubx", ".json", ".cfg", ".md"}
SAFE = re.compile(r"[^A-Za-z0-9_\-]") SAFE = re.compile(r"[^A-Za-z0-9_\-]")
SAFE_FILE = re.compile(r"[^A-Za-z0-9_\-.]") SAFE_FILE = re.compile(r"[^A-Za-z0-9_\-.]")
@ -172,6 +176,12 @@ def crear():
if isinstance(f, dict) and isinstance(f.get("ruta"), str) if isinstance(f, dict) and isinstance(f.get("ruta"), str)
and os.path.isfile(os.path.join(ADJ, f["ruta"]))] and os.path.isfile(os.path.join(ADJ, f["ruta"]))]
archivos = d.get("archivos")
if isinstance(archivos, list):
d["archivos"] = [a for a in archivos[:40]
if isinstance(a, dict) and isinstance(a.get("ruta"), str)
and os.path.isfile(os.path.join(ADJ, a["ruta"]))]
ts = datetime.datetime.now().replace(microsecond=0) ts = datetime.datetime.now().replace(microsecond=0)
iso = ts.isoformat() iso = ts.isoformat()
d["_recibido"] = iso d["_recibido"] = iso
@ -195,11 +205,12 @@ def crear():
with open(idx, "a", newline="", encoding="utf-8") as f: with open(idx, "a", newline="", encoding="utf-8") as f:
w = csv.writer(f) w = csv.writer(f)
if nuevo: if nuevo:
w.writerow(["recibido", "serie", "tecnico", "resultado", "clasificacion", w.writerow(["recibido", "fecha", "serie", "maquina", "tecnico", "resultado",
"accion", "fotos", "archivo"]) "clasificacion", "accion", "fotos", "logs", "archivo"])
w.writerow([iso, serie, tecnico, d.get("resultado", ""), w.writerow([iso, d.get("fecha", ""), serie, d.get("maquina_id", d.get("maquina", "")),
tecnico, d.get("resultado", ""),
d.get("clasificacion", ""), d.get("accion", ""), d.get("clasificacion", ""), d.get("accion", ""),
len(d.get("fotos") or []), archivo]) len(d.get("fotos") or []), len(d.get("archivos") or []), archivo])
sub = f"{tipo}/_pruebas/{archivo}" if es_prueba else f"{tipo}/{archivo}" sub = f"{tipo}/_pruebas/{archivo}" if es_prueba else f"{tipo}/{archivo}"
return jsonify(ok=True, archivo=sub, prueba=es_prueba) return jsonify(ok=True, archivo=sub, prueba=es_prueba)
@ -282,6 +293,9 @@ def listar():
for k in ("clasificacion", "accion", "motivo", "maquina", "terminal", "unidad"): for k in ("clasificacion", "accion", "motivo", "maquina", "terminal", "unidad"):
if d.get(k): if d.get(k):
fila[k] = d[k] fila[k] = d[k]
if d.get("archivos"):
fila["archivos"] = [{"ruta": a.get("ruta"), "nombre": a.get("nombre")}
for a in d["archivos"] if a.get("ruta")]
if d.get("fotos"): if d.get("fotos"):
fila["fotos"] = [f.get("ruta") for f in d["fotos"] if f.get("ruta")] fila["fotos"] = [f.get("ruta") for f in d["fotos"] if f.get("ruta")]
fila["archivo_n"] = fn fila["archivo_n"] = fn
@ -310,7 +324,9 @@ def historial():
out.append({"archivo": fn, "recibido": d.get("_recibido"), "tecnico": d.get("tecnico"), out.append({"archivo": fn, "recibido": d.get("_recibido"), "tecnico": d.get("tecnico"),
"resultado": d.get("resultado"), "clasificacion": d.get("clasificacion"), "resultado": d.get("resultado"), "clasificacion": d.get("clasificacion"),
"accion": d.get("accion"), "motivo": d.get("motivo"), "accion": d.get("accion"), "motivo": d.get("motivo"),
"fotos": len(d.get("fotos") or [])}) "fotos": len(d.get("fotos") or []),
"archivos": len(d.get("archivos") or []),
"fecha": d.get("fecha")})
out.reverse() out.reverse()
return jsonify(ok=True, total=len(out), visitas=out) return jsonify(ok=True, total=len(out), visitas=out)
@ -362,6 +378,46 @@ def subir_adjuntos():
return jsonify(ok=True, fotos=guardadas, rechazadas=rechazadas) return jsonify(ok=True, fotos=guardadas, rechazadas=rechazadas)
@app.post("/api/archivos")
def subir_archivos():
"""Sube archivos de log de campo/banco. multipart: tipo, serie, archivo (1..10), etiqueta."""
tipo = slug(request.form.get("tipo")); serie = slug(request.form.get("serie"), 24)
etiqueta = str(request.form.get("etiqueta", ""))[:80]
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
subidos = request.files.getlist("archivo")
if not subidos:
return jsonify(ok=False, error="no llegó ningún archivo"), 400
if len(subidos) > 10:
return jsonify(ok=False, error="máximo 10 archivos por envío"), 400
carpeta = os.path.join(ADJ, tipo, serie, "logs")
os.makedirs(carpeta, exist_ok=True)
guardados, rechazados = [], []
for fh in subidos:
nombre = SAFE_FILE.sub("_", os.path.basename(fh.filename or "captura"))[:80]
ext = os.path.splitext(nombre)[1].lower()
if ext not in EXT_LOG:
rechazados.append({"nombre": nombre,
"error": "extensión no permitida (usa .txt .log .csv .nmea .ubx .json)"})
continue
datos = fh.read(LOG_MAX_MB * 1024 * 1024 + 1)
if len(datos) > LOG_MAX_MB * 1024 * 1024:
rechazados.append({"nombre": nombre, "error": f"pesa más de {LOG_MAX_MB} MB"}); continue
if not datos:
rechazados.append({"nombre": nombre, "error": "el archivo está vacío"}); continue
final = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:4] + "_" + nombre
with open(os.path.join(carpeta, final), "wb") as f:
f.write(datos)
guardados.append({"ruta": f"{tipo}/{serie}/logs/{final}", "nombre": nombre,
"bytes": len(datos), "etiqueta": etiqueta, "usuario": current_user(),
"subida": datetime.datetime.now().replace(microsecond=0).isoformat()})
if not guardados:
return jsonify(ok=False, error=(rechazados[0]["error"] if rechazados else "no se guardó nada"),
rechazados=rechazados), 400
return jsonify(ok=True, archivos=guardados, rechazados=rechazados)
@app.get("/api/adjunto") @app.get("/api/adjunto")
def ver_adjunto(): def ver_adjunto():
ruta = (request.args.get("ruta") or "").strip().lstrip("/") ruta = (request.args.get("ruta") or "").strip().lstrip("/")
@ -375,8 +431,8 @@ def ver_adjunto():
@app.errorhandler(413) @app.errorhandler(413)
def _muy_grande(e): def _muy_grande(e):
return jsonify(ok=False, error=f"La foto pesa demasiado (máximo {MAX_MB} MB). " return jsonify(ok=False, error=f"El archivo pesa demasiado (fotos: máximo {MAX_MB} MB; "
"Vuelve a tomarla o espera a tener mejor señal."), 413 f"logs: máximo {LOG_MAX_MB} MB)."), 413
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""Actas HBOX — servicio para recibir y consultar formularios firmados de los técnicos.
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:
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); incluye 'componentes' en actas compuestas
Actas compuestas (integracion, ensamble): traen d["componentes"] = {clave: serie_modulo}. En los tipos
de TIPOS_UNICOS un módulo no puede aparecer en dos actas reales (409).
"""
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()}
TIPOS_UNICOS = {"ensamble"} # tipos en los que un componente solo puede usarse en un acta real
SAFE = re.compile(r"[^A-Za-z0-9_\-]")
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 componentes_en_uso(tipo, serie_actual, componentes):
"""Devuelve [(clave, valor, serie_otra)] para componentes ya usados por OTRA acta real del mismo tipo."""
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta) or not isinstance(componentes, dict):
return []
usados = []
for fn in os.listdir(carpeta):
if not fn.endswith(".json"):
continue
try:
with open(os.path.join(carpeta, fn), encoding="utf-8") as f:
d = json.load(f)
except Exception:
continue
if d.get("serie") == serie_actual or not isinstance(d.get("componentes"), dict):
continue
for k, v in componentes.items():
if v and d["componentes"].get(k) == v:
usados.append((k, v, d.get("serie")))
return usados
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"), 24)
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:
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")); serie = slug(d.get("serie"), 24)
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
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
# actas compuestas (ensamble): un módulo no puede pertenecer a dos unidades
comps = d.get("componentes")
if isinstance(comps, dict):
comps = {slug(k, 20): slug(v, 20) for k, v in comps.items()}
d["componentes"] = comps
if not es_prueba and tipo in TIPOS_UNICOS:
usados = componentes_en_uso(tipo, serie, comps)
if usados:
det = "; ".join(f"{v} ya está en {s}" for _, v, s in usados)
return jsonify(ok=False, error=f"Módulo ya asignado: {det}. Pide a Leo que revise."), 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, "_pruebas") if es_prueba else os.path.join(BASE, tipo)
os.makedirs(carpeta, exist_ok=True)
archivo = f"{serie}_{iso.replace(':', '-')}.json"
with open(os.path.join(carpeta, archivo), "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
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])
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"), 24)
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():
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
fila = {"tipo": tipo, "archivo": f"{tipo}/{fn}",
"serie": d.get("serie"), "tecnico": d.get("tecnico"),
"resultado": d.get("resultado"), "recibido": d.get("_recibido")}
if isinstance(d.get("componentes"), dict):
fila["componentes"] = d["componentes"]
filas.append(fila)
filas.sort(key=lambda r: (r.get("recibido") or ""), reverse=True)
return jsonify(ok=True, total=len(filas), actas=filas)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081")))

View File

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Agrega al bloque `location /api/` de nginx las dos líneas que necesita el acta de campo:
proxy_set_header X-Remote-User $remote_user; -> identifica al técnico (y evita que
un usuario se haga pasar por otro)
client_max_body_size 25m; -> sin esto las fotos y los logs se cortan en 1 MB
Es idempotente: si ya están, no toca nada. Hace respaldo antes de escribir.
Uso (en el servidor):
sudo python3 parche-nginx-api.py # busca el archivo solo
sudo python3 parche-nginx-api.py /ruta/al/archivo.conf
Después:
sudo nginx -t && sudo systemctl reload nginx
"""
import sys, os, glob, datetime, re
LINEAS = [
('X-Remote-User', ' proxy_set_header X-Remote-User $remote_user;'),
('client_max_body_size', ' client_max_body_size 25m;'),
('proxy_read_timeout', ' proxy_read_timeout 120s;'),
]
def candidatos():
vistos = []
for pat in ('/etc/nginx/sites-enabled/*', '/etc/nginx/sites-available/*', '/etc/nginx/conf.d/*'):
for f in glob.glob(pat):
if os.path.isfile(f) or os.path.islink(f):
real = os.path.realpath(f)
try:
if 'drogheda' in open(real, encoding='utf-8', errors='ignore').read() and real not in vistos:
vistos.append(real)
except OSError:
pass
return vistos
def bloque_api(txt):
"""Devuelve (inicio_cuerpo, fin_bloque) del location /api/ o None."""
m = re.search(r'location\s+/api/\s*\{', txt)
if not m:
return None
i = m.end() # justo después de la llave que abre
nivel, j = 1, i
while j < len(txt) and nivel:
if txt[j] == '{': nivel += 1
elif txt[j] == '}': nivel -= 1
j += 1
return (i, j)
def main():
archivos = [sys.argv[1]] if len(sys.argv) > 1 else candidatos()
if not archivos:
sys.exit("No encontré ningún archivo de nginx que mencione 'drogheda'.\n"
"Pásalo a mano: sudo python3 parche-nginx-api.py /etc/nginx/sites-available/EL-ARCHIVO")
if len(archivos) > 1:
print("Hay varios candidatos; elige uno y vuelve a correr con la ruta:")
for f in archivos:
print(" ", f)
sys.exit(1)
ruta = archivos[0]
txt = open(ruta, encoding='utf-8').read()
pos = bloque_api(txt)
if not pos:
sys.exit(f"{ruta} no tiene un bloque `location /api/ {{ … }}`.\n"
"Agrégalo a mano ANTES del `location / {` (ver DESPLIEGUE.md §4).")
ini, fin = pos
cuerpo = txt[ini:fin]
faltan = [l for clave, l in LINEAS if clave not in cuerpo]
if not faltan:
print(f"✓ {ruta}: el bloque /api/ ya tiene todo. No cambié nada.")
print(txt[ini - len('location /api/ {'):fin])
return
respaldo = ruta + '.bak-' + datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
open(respaldo, 'w', encoding='utf-8').write(txt)
nuevo = txt[:ini] + '\n' + '\n'.join(faltan) + txt[ini:]
open(ruta, 'w', encoding='utf-8').write(nuevo)
print(f"Respaldo: {respaldo}")
print(f"Agregadas {len(faltan)} línea(s) a {ruta}:")
for l in faltan:
print(" " + l.strip())
pos2 = bloque_api(nuevo)
print("\nEl bloque quedó así:\n")
print(nuevo[pos2[0] - len('location /api/ {'):pos2[1]])
print("\nAhora: sudo nginx -t && sudo systemctl reload nginx")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,59 @@
# ============================================================================
# ArduSimple simpleRTK2B + 4G NTRIP Master — configuration TEMPLATE (REDACTED)
# Project Drogheda / HBOX
#
# HOW TO USE
# 1. Copy this file to a LOCAL, git-ignored name:
# cp 4G_NTRIP_HelgenSRV-NMEA.example.txt 4G_NTRIP_HelgenSRV-NMEA.local.txt
# 2. Fill the <PLACEHOLDERS> with the real values for the deployment.
# 3. Send the $$$AS+ lines to the module per ArduSimple's config procedure.
# NEVER commit the filled-in file. Real APN/NTRIP credentials must not enter git
# (project invariant: no secrets in the repo).
#
# Lines starting with '#' are documentation — only the $$$AS+ lines are config.
# Key reference:
# AS+A APN host · AS+Q APN user · AS+q APN pass
# AS+S caster host · AS+P caster port · AS+U "user:pass" · AS+M mountpoint
# AS+B baud · AS+r/AS+h secondary server:port · AS+K station/mount id
# ============================================================================
$$$AS+I=1111
$$$AS+A=<APN_HOST>
$$$AS+Q=<APN_USER>
$$$AS+q=<APN_PASS>
$$$AS+O=0
$$$AS+L=1
$$$AS+D=1
$$$AS+f=0
$$$AS+B=115200
$$$AS+N=1
$$$AS+S=<NTRIP_CASTER_HOST>
$$$AS+P=<NTRIP_CASTER_PORT>
$$$AS+U=<NTRIP_USER>:<NTRIP_PASS>
$$$AS+M=<NTRIP_MOUNTPOINT>
$$$AS+n=0
$$$AS+s=
$$$AS+p=
$$$AS+u=
$$$AS+m=0
$$$AS+i=
$$$AS+a=0
$$$AS+Z=0
$$$AS+z=pp.services.u-blox.com
$$$AS+Y=
$$$AS+y=0
$$$AS+X=0
$$$AS+x=0
$$$AS+W=0
$$$AS+w=
$$$AS+V=
$$$AS+v=
$$$AS+t=
$$$AS+T=1
$$$AS+r=<SECONDARY_SERVER_IP>
$$$AS+h=<SECONDARY_SERVER_PORT>
$$$AS+k=2
$$$AS+K=<STATION_ID>
$$$AS+j=300
$$$AS+C=1
$$$AS+c=0
$$$AS+F=1

View File

@ -41,6 +41,67 @@ o con **STM32CubeProgrammer** → USB → Connect → Download del `.bin` en `0x
Notas de compilación y cambios: [README del firmware de bring-up](hbox-v5-bringup-README.md). Notas de compilación y cambios: [README del firmware de bring-up](hbox-v5-bringup-README.md).
## Firmware de campo — tarjetas V3 y V4
Es el firmware que gobierna la unidad en la máquina (`blackpillrtkcode_v3` / `v4`), distinto del de
bring-up del v5. Se usa en el [reflasheo en taller](../procedimientos/reflasheo-unidades.md).
| Generación | Archivo | Estado |
|---|---|---|
| **V3** — flota desplegada (RTK-003, RTK-006, RTK-014) | `hbox-fw-v3_X.Y.Z.bin` | ⬜ **pendiente de publicar** |
| **V4** | `hbox-fw-v4_X.Y.Z.bin` | ⬜ **pendiente de publicar** |
!!! danger "Mientras diga «pendiente», no hay binario aprobado"
No flashees una unidad V3 o V4 con un archivo que no esté publicado aquí con su SHA-256. Los
binarios de estas generaciones **no viven en el repositorio** (están en `.gitignore`): tienen que
compilarse, probarse en banco y publicarse. El procedimiento de generación está en el
[reflasheo §6](../procedimientos/reflasheo-unidades.md).
!!! warning "Un binario no sirve para otra generación"
La polaridad del control de carga está **invertida** entre la tarjeta de producción y la de
protoboard. Flashear el binario equivocado puede dejar la batería cargando justo cuando el
firmware cree estarla protegiendo por temperatura — la falla #1 del RCA de junio.
## Parámetros del módem 4G NTRIP Master
La configuración se envía por el puerto USB **XBEE** del simpleRTK2B Pro a 115200 baudios, línea por
línea (solo las que empiezan con `$$$AS+`). Cada una debe contestar `OK`.
| Archivo | Qué es |
|---|---|
| [**4G_NTRIP_HelgenSRV-NMEA.example.txt**](4G_NTRIP_HelgenSRV-NMEA.example.txt) | **Plantilla** con todos los parámetros y sus nombres, con los valores sensibles en blanco |
!!! danger "El archivo con los valores reales no está aquí, y es a propósito"
El archivo lleno contiene **usuario y contraseña del caster NTRIP y de la APN**. Publicarlo en el
sitio sería ponerlo al alcance de cualquiera que tenga una cuenta de técnico, y meterlo al
repositorio rompe un invariante del proyecto (*sin secretos en el repo, nunca*).
**Cómo se obtiene:** pídelo a ingeniería para la unidad que vas a configurar. Llega por un canal
directo, se usa y no se guarda en el equipo ni se reenvía.
Referencia rápida de los parámetros que más se consultan:
| Comando | Qué configura |
|---|---|
| `AS+I=1111` | Entra a modo configuración |
| `AS+A` · `AS+Q` · `AS+q` | APN: host, usuario, contraseña |
| `AS+S` · `AS+P` | Caster NTRIP: host y puerto |
| `AS+U` · `AS+M` | Caster: `usuario:contraseña` y mountpoint |
| `AS+B` | Velocidad del puerto (115200) |
| `AS+K` | **ID de estación** del módulo — se anota en el acta |
| `AS+r` · `AS+h` | Servidor secundario: IP y puerto |
## Firmware y configuración del simpleRTK2B Pro (ZED-F9P)
| Archivo | Estado | Notas |
|---|---|---|
| Configuración del ZED-F9P (u-center `.txt`) | ⬜ **pendiente de publicar** | UART1 a 115200 (es lo que lee la tarjeta) y UART2 a la velocidad del módem |
| Firmware del ZED-F9P (u-blox) | Se descarga de u-blox | Anotar la versión instalada con **UBX-MON-VER** |
Para leer o cargar la configuración: u-center → *Tools → Receiver Configuration*. Después de cargar,
**guardar en FLASH** y reconectar para confirmar que sobrevive al reinicio — una configuración que
solo quedó en RAM se pierde al primer corte de energía, y así perdimos tiempo en junio.
## Herramientas ## Herramientas
- **STM32CubeProgrammer** (Windows/macOS/Linux): https://www.st.com/en/development-tools/stm32cubeprog.html - **STM32CubeProgrammer** (Windows/macOS/Linux): https://www.st.com/en/development-tools/stm32cubeprog.html

View File

@ -25,7 +25,72 @@ estado en que estaba la unidad**. Esa es toda tu misión, y es más valiosa de l
Por eso el procedimiento dice: **no cortes la energía hasta terminar el paso 5.** Por eso el procedimiento dice: **no cortes la energía hasta terminar el paso 5.**
## 2. Qué llevar ## 2. Antes de ir — permiso de acceso y de qué máquina hablamos
Dos cosas tienen que estar resueltas **antes** de salir. Sin ellas la visita no se hace.
### 2.1 De qué máquina estamos hablando
En ICAVE nadie conoce el equipo por el número del HBOX: lo conocen por la **máquina**
(`FL-JUN-060`, `RT-ZPMC-20`, `HS-WES-62`). El permiso se pide por máquina, el operador te
lleva a una máquina y el acta se cruza por máquina.
Las tres unidades v3 desplegadas y en qué máquina va cada una:
| Máquina (como la nombra ICAVE) | Equipo | Unidad HBOX | Estado hoy |
|---|---|---|---|
| `FL-JUN-060` | Montacargas | **RTK-014** | **Instalada.** Dejó de reportar el 17-sep. Nadie la ha tocado — evidencia intacta |
| `RT-ZPMC-20` | RTG | **RTK-003** | Desmontada, en el escritorio del taller |
| `HS-WES-62` | Tractoplana | **RTK-006** | Desmontada, en el escritorio del taller |
Solo `FL-JUN-060` necesita permiso de acceso: las otras dos ya están en el taller y se revisan en banco.
!!! warning "No confundir los números"
Estas unidades de campo son **`RTK-006`** (tres dígitos). Los módems 4G del HBOX v5 son
**`RTK-0006`** (cuatro dígitos) y viven en otra acta. Son cosas distintas.
Por eso, antes de pedir el acceso hay que **saber en qué máquina está montada la unidad** que
queremos revisar. Si el registro no lo dice o hay duda, se confirma con ICAVE en el mismo correo.
Y si al llegar la unidad resulta estar en **otra** máquina de la que dice la lista, eso se anota
en el acta: el registro estaba mal y hay que corregirlo.
### 2.2 El correo de solicitud de acceso
Lo manda **Operaciones (Adriana)** al contacto de ICAVE, con copia a Agustín Alfonseca,
Leon David Piña, Leo y los técnicos que van a subir. Modelo:
> **Asunto:** Solicitud de acceso a la unidad `FL-JUN-060` — revisión de equipo HBOX
>
> Estimado **[nombre]**:
>
> Buen día.
>
> Por medio del presente, solicito de tu apoyo para que se nos proporcione acceso a la unidad
> **`FL-JUN-060`**, con la finalidad de realizar una revisión del equipo HBOX instalado en dicha unidad.
>
> La actividad será llevada a cabo por los técnicos **Alán Basurto y Daniel Martínez**, quienes
> realizarán las verificaciones correspondientes para asegurar el correcto funcionamiento del sistema.
>
> Quedamos atentos a tus comentarios y al apoyo para coordinar el acceso en el horario que mejor
> se adapte a la operación.
>
> Muchas gracias. Saludos cordiales.
Lo único que cambia entre visitas es **el número de la unidad** y, si aplica, **los nombres de
los técnicos**. Una máquina por correo: mezclar dos en el mismo correo enreda la autorización.
### 2.3 La regla
| Situación | ¿Se sube a la máquina? |
|---|---|
| Correo contestado y horario acordado | **Sí**, con la máquina detenida y bloqueada (LOTO) |
| Autorización de palabra del supervisor en sitio | Sí, pero se anota quién autorizó y a qué hora |
| Sin respuesta al correo | **No.** Se revisa desde el piso lo que se alcance y se reagenda |
| La unidad ya está desmontada, en el taller | No aplica: no hay máquina de por medio |
Una revisión incompleta se repite la semana que entra. Un accidente en un patio de maniobras, no.
## 3. Qué llevar
| Herramienta | Para qué | ¿Obligatoria? | | Herramienta | Para qué | ¿Obligatoria? |
|---|---|---| |---|---|---|
@ -37,7 +102,7 @@ estado en que estaba la unidad**. Esa es toda tu misión, y es más valiosa de l
| Bolsa antiestática | Transportar la unidad desmontada | Sí | | Bolsa antiestática | Transportar la unidad desmontada | Sí |
| Termómetro infrarrojo | Medir la temperatura interior del gabinete | Deseable — cuesta poco y mide la hipótesis térmica | | Termómetro infrarrojo | Medir la temperatura interior del gabinete | Deseable — cuesta poco y mide la hipótesis térmica |
## 3. Seguridad ## 4. Seguridad
1. **Nunca** trabajes sobre un RTG o equipo energizado sin el permiso y el bloqueo (LOTO) del 1. **Nunca** trabajes sobre un RTG o equipo energizado sin el permiso y el bloqueo (LOTO) del
terminal. Si el equipo está operando, **no subas**: agenda con el supervisor de ICAVE. terminal. Si el equipo está operando, **no subas**: agenda con el supervisor de ICAVE.
@ -47,10 +112,11 @@ estado en que estaba la unidad**. Esa es toda tu misión, y es más valiosa de l
4. El gabinete puede tener **24 V**: no es peligroso para la vida, pero un corto sí puede 4. El gabinete puede tener **24 V**: no es peligroso para la vida, pero un corto sí puede
quemar la tarjeta. Mide con la máquina apagada siempre que puedas. quemar la tarjeta. Mide con la máquina apagada siempre que puedas.
## 4. Los pasos, en orden ## 5. Los pasos, en orden
| Paso | Qué haces | Por qué importa | | Paso | Qué haces | Por qué importa |
|---|---|---| |---|---|---|
| **0** | **Permiso de acceso a la máquina** y confirmar en cuál está montada | Sin autorización no hay visita; sin máquina no hay permiso |
| 1 | Anotas **por qué vienes** y qué dijo el operador | La hora en que dejó de reportar acota la causa | | 1 | Anotas **por qué vienes** y qué dijo el operador | La hora en que dejó de reportar acota la causa |
| 2 | Fotos con el gabinete **cerrado**: máquina, gabinete, antenas | Sol, altura, antena floja o robada se ven aquí | | 2 | Fotos con el gabinete **cerrado**: máquina, gabinete, antenas | Sol, altura, antena floja o robada se ven aquí |
| 3 | **Cuentas los destellos del LED** en 10 segundos | Es el diagnóstico gratis que la unidad te está dando | | 3 | **Cuentas los destellos del LED** en 10 segundos | Es el diagnóstico gratis que la unidad te está dando |
@ -59,7 +125,7 @@ estado en que estaba la unidad**. Esa es toda tu misión, y es más valiosa de l
| 6 | *Ahora sí* puedes cortar la energía y probar | Ya está toda la evidencia guardada | | 6 | *Ahora sí* puedes cortar la energía y probar | Ya está toda la evidencia guardada |
| 7 | Clasificas, decides cómo queda y firmas | Convierte tu visita en un dato comparable | | 7 | Clasificas, decides cómo queda y firmas | Convierte tu visita en un dato comparable |
## 5. Tabla del LED — qué te está diciendo la unidad ## 6. Tabla del LED — qué te está diciendo la unidad
Mira el LED **10 segundos con el reloj** y **cuenta cuántas veces prende**. No lo estimes: Mira el LED **10 segundos con el reloj** y **cuenta cuántas veces prende**. No lo estimes:
5 destellos y 10 destellos en 10 segundos significan cosas **opuestas**. 5 destellos y 10 destellos en 10 segundos significan cosas **opuestas**.
@ -74,7 +140,7 @@ Mira el LED **10 segundos con el reloj** y **cuenta cuántas veces prende**. No
| **Dos destellos** juntos y pausa | ~7 | `RUN` + enlace en las últimas | Prácticamente sin correcciones. | | **Dos destellos** juntos y pausa | ~7 | `RUN` + enlace en las últimas | Prácticamente sin correcciones. |
| Parpadeo **rápido continuo** | no se cuentan | arrancando o reiniciándose | Puede estar reiniciándose en bucle. **Solo la captura USB lo confirma.** | | Parpadeo **rápido continuo** | no se cuentan | arrancando o reiniciándose | Puede estar reiniciándose en bucle. **Solo la captura USB lo confirma.** |
## 6. Medidas esperadas ## 7. Medidas esperadas
Con la punta negra en el chasis/negativo del gabinete: Con la punta negra en el chasis/negativo del gabinete:
@ -90,7 +156,7 @@ Con la punta negra en el chasis/negativo del gabinete:
**No hay 24 V en el GX16** → la falla es **del gabinete o de la máquina** (breaker, fusible, **No hay 24 V en el GX16** → la falla es **del gabinete o de la máquina** (breaker, fusible,
fuente, cable) → el HBOX probablemente está sano; no lo desmontes. fuente, cable) → el HBOX probablemente está sano; no lo desmontes.
## 7. La captura por USB ## 8. La captura por USB
Es el paso que más nos sirve y el que más se salta. Vale la pena cargar la laptop. Es el paso que más nos sirve y el que más se salta. Vale la pena cargar la laptop.
@ -122,7 +188,7 @@ Los cuatro campos que debes anotar aunque no puedas copiar la línea completa:
Y de la línea `BOOT`, el campo `reset=` dice **por qué arrancó la última vez** Y de la línea `BOOT`, el campo `reset=` dice **por qué arrancó la última vez**
(corte de energía, watchdog, brownout). Es la causa de muerte escrita por la propia unidad. (corte de energía, watchdog, brownout). Es la causa de muerte escrita por la propia unidad.
## 8. Catálogo de clasificaciones ## 9. Catálogo de clasificaciones
Elige lo que **observaste**, no lo que supones. *No está claro* es una respuesta válida y útil; Elige lo que **observaste**, no lo que supones. *No está claro* es una respuesta válida y útil;
inventar una causa nos hace perder semanas. inventar una causa nos hace perder semanas.
@ -140,7 +206,7 @@ inventar una causa nos hace perder semanas.
| La unidad se ve sana — parece el servidor | LED fijo y captura sana | **No desmontar.** Ingeniería revisa el backend | | La unidad se ve sana — parece el servidor | LED fijo y captura sana | **No desmontar.** Ingeniería revisa el backend |
| No está claro — se necesita banco | Todo lo demás | Desmontar y enviar | | No está claro — se necesita banco | Todo lo demás | Desmontar y enviar |
## 9. Qué pasa cuando envías el acta ## 10. Qué pasa cuando envías el acta
1. Queda guardada en el servidor con tu nombre, la hora y las fotos. 1. Queda guardada en el servidor con tu nombre, la hora y las fotos.
2. Aparece en el **[tablero de campo](tablero-campo.html)**, que ingeniería revisa en Querétaro. 2. Aparece en el **[tablero de campo](tablero-campo.html)**, que ingeniería revisa en Querétaro.

View File

@ -43,6 +43,9 @@ section.bloque h2{margin:0 0 2px;font-size:17px}
.visita .f{color:var(--tinta2);font-size:12.5px} .visita .f{color:var(--tinta2);font-size:12.5px}
.visita .cl{font-weight:700} .visita .cl{font-weight:700}
.fotos{display:flex;flex-wrap:wrap;gap:7px;margin-top:8px} .fotos{display:flex;flex-wrap:wrap;gap:7px;margin-top:8px}
.logs{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
.logs a{font-size:12.5px;font-weight:600;color:var(--info);text-decoration:none;
border:1px solid var(--linea);border-radius:7px;padding:4px 9px;background:var(--superf)}
.fotos a{display:block;width:74px;height:74px;border-radius:8px;overflow:hidden;border:1px solid var(--linea)} .fotos a{display:block;width:74px;height:74px;border-radius:8px;overflow:hidden;border:1px solid var(--linea)}
.fotos img{width:100%;height:100%;object-fit:cover;display:block;background:var(--pend-bg)} .fotos img{width:100%;height:100%;object-fit:cover;display:block;background:var(--pend-bg)}
details{margin-top:8px} details{margin-top:8px}
@ -68,6 +71,7 @@ table.det td.k{color:var(--tinta2);width:45%}
</div> </div>
<script src="../formularios/flota.js"></script> <script src="../formularios/flota.js"></script>
<script> <script>
const TIPOS = ['diagnostico-campo','diagnostico-banco'];
const TIPO = 'diagnostico-campo'; const TIPO = 'diagnostico-campo';
const cont = document.getElementById('cont'); const cont = document.getElementById('cont');
const el = (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;}; const el = (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;};
@ -103,12 +107,12 @@ async function detalle(tipo, serie, archivo, caja){
function render(actas){ function render(actas){
cont.innerHTML=''; cont.innerHTML='';
const porUnidad = {}; const porUnidad = {};
actas.filter(a=>a.tipo===TIPO).forEach(a=>{ (porUnidad[a.serie]=porUnidad[a.serie]||[]).push(a); }); actas.filter(a=>TIPOS.includes(a.tipo)).forEach(a=>{ (porUnidad[a.serie]=porUnidad[a.serie]||[]).push(a); });
const flota = (window.FLOTA||[]).slice(); const flota = (window.FLOTA||[]).slice();
Object.keys(porUnidad).forEach(s=>{ if(!flota.some(u=>u.serie===s)) flota.push({serie:s, etiqueta:s, maquina:'(fuera del registro de flota)', terminal:'Sin clasificar'}); }); Object.keys(porUnidad).forEach(s=>{ if(!flota.some(u=>u.serie===s)) flota.push({serie:s, etiqueta:s, maquina:'(fuera del registro de flota)', terminal:'Sin clasificar'}); });
const visitas = actas.filter(a=>a.tipo===TIPO); const visitas = actas.filter(a=>TIPOS.includes(a.tipo));
const conVisita = Object.keys(porUnidad).length; const conVisita = Object.keys(porUnidad).length;
const operando = flota.filter(u=>{const v=(porUnidad[u.serie]||[])[0]; return v && pill(v)[0]==='ok';}).length; const operando = flota.filter(u=>{const v=(porUnidad[u.serie]||[])[0]; return v && pill(v)[0]==='ok';}).length;
const kp = el('div',{class:'kpis'}); const kp = el('div',{class:'kpis'});
@ -136,7 +140,7 @@ function render(actas){
if(!vs.length) box.append(el('div',{class:'visita vacio',text:'Todavía no hay ningún diagnóstico registrado para esta unidad.'})); if(!vs.length) box.append(el('div',{class:'visita vacio',text:'Todavía no hay ningún diagnóstico registrado para esta unidad.'}));
vs.forEach(v=>{ vs.forEach(v=>{
const d = el('div',{class:'visita'}); const d = el('div',{class:'visita'});
d.append(el('div',{class:'f',text:fecha(v.recibido)+' · '+(v.tecnico||'—')+(v.motivo?' · '+v.motivo:'')})); d.append(el('div',{class:'f',text:(v.tipo==='diagnostico-banco'?'🔬 banco · ':'🚜 campo · ')+fecha(v.recibido)+' · '+(v.tecnico||'—')+(v.motivo?' · '+v.motivo:'')}));
if(v.clasificacion) d.append(el('div',{class:'cl',text:v.clasificacion})); if(v.clasificacion) d.append(el('div',{class:'cl',text:v.clasificacion}));
if(v.accion) d.append(el('div',{text:'→ '+v.accion})); if(v.accion) d.append(el('div',{text:'→ '+v.accion}));
if(v.fotos && v.fotos.length){ if(v.fotos && v.fotos.length){
@ -148,10 +152,19 @@ function render(actas){
}); });
d.append(f); d.append(f);
} }
if(v.archivos && v.archivos.length){
const g = el('div',{class:'logs'});
v.archivos.forEach(a=>{
const x = el('a',{href:'/api/adjunto?ruta='+encodeURIComponent(a.ruta),target:'_blank',
text:'📄 '+(a.nombre||'captura')});
g.append(x);
});
d.append(g);
}
const det = el('details'); const det = el('details');
det.append(el('summary',{text:'Ver todas las respuestas'})); det.append(el('summary',{text:'Ver todas las respuestas'}));
const caja = el('div'); det.append(caja); const caja = el('div'); det.append(caja);
det.addEventListener('toggle',()=>{ if(det.open && !caja.dataset.listo){ caja.dataset.listo='1'; detalle(TIPO, v.serie, v.archivo_n, caja); } }); det.addEventListener('toggle',()=>{ if(det.open && !caja.dataset.listo){ caja.dataset.listo='1'; detalle(v.tipo, v.serie, v.archivo_n, caja); } });
d.append(det); d.append(det);
box.append(d); box.append(d);
}); });

View File

@ -120,3 +120,12 @@ button.mini-btn{background:#fff;border:1px solid var(--linea);border-radius:6px;
.barra{padding:10px 12px} .barra{padding:10px 12px}
button.enviar{padding:15px 18px} button.enviar{padding:15px 18px}
} }
/* archivos de log adjuntos (capturas por USB) */
.mini.doc{width:auto;min-width:160px;max-width:260px;flex-direction:row;flex-wrap:wrap;
align-items:center;gap:6px;padding:8px 10px}
.mini.doc .ico{font-size:18px}
.mini.doc .nom{font-size:12.5px;font-weight:600;word-break:break-all;flex:1 1 90px}
.mini.doc .est{font-size:11.5px;flex:0 0 auto}
input[type=date]{width:100%;padding:11px 12px;border:1px solid var(--linea);border-radius:9px;
font-size:16px;background:#fff;color:var(--tinta)}

View File

@ -2,7 +2,7 @@
- Un acta real se envía UNA vez: al elegir una serie ya enviada, se carga llena y bloqueada. - 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)". - Solo el administrador (leo) ve el botón "Borrar acta (reabrir)".
- La serie PRUEBA no se bloquea (para practicar). - La serie PRUEBA no se bloquea (para practicar).
Tipos de ítem: check | medida | texto | radio | nota | foto · Envío: POST /api/actas Tipos de ítem: check | medida | texto | radio | nota | foto | archivo | fecha · Envío: POST /api/actas
Extensiones para actas de CAMPO (diagnóstico / instalación): Extensiones para actas de CAMPO (diagnóstico / instalación):
(c) cfg.repetible = true → la serie NO se bloquea: cada visita es un registro nuevo, (c) cfg.repetible = true → la serie NO se bloquea: cada visita es un registro nuevo,
@ -15,6 +15,8 @@
item.req = true → obligatorio para poder enviar item.req = true → obligatorio para poder enviar
item.guardaEn = 'clasificacion' → además copia el valor a la raíz del acta (índice/tablero) item.guardaEn = 'clasificacion' → además copia el valor a la raíz del acta (índice/tablero)
item.tipo = 'foto' → cámara del teléfono; sube a /api/adjuntos y guarda la ruta item.tipo = 'foto' → cámara del teléfono; sube a /api/adjuntos y guarda la ruta
item.tipo = 'archivo' → captura de log (.txt .log .csv .nmea .ubx); sube a /api/archivos
item.tipo = 'fecha' → selector de fecha; arranca con la de hoy
Modos de serie: Modos de serie:
(a) simple: cfg.prefijo + cfg.series (cantidad) [+ cfg.desde, cfg.pad] → "RTK-005" (a) simple: cfg.prefijo + cfg.series (cantidad) [+ cfg.desde, cfg.pad] → "RTK-005"
@ -154,7 +156,8 @@
const condicionales = []; // [{el, fn}] — secciones o ítems que solo se muestran si se cumple algo const condicionales = []; // [{el, fn}] — secciones o ítems que solo se muestran si se cumple algo
const obligatorios = []; // [{id, txt, esFoto}] const obligatorios = []; // [{id, txt, esFoto}]
const guardaEn = {}; // data-id -> nombre del campo en la raíz del acta const guardaEn = {}; // data-id -> nombre del campo en la raíz del acta
let fotos = []; // [{ruta, etiqueta, pendiente?}] let fotos = []; // [{ruta, etiqueta, campo}]
let archivos = []; // [{ruta, nombre, bytes, campo}] — capturas de log
const pendientes = []; // fotos que no lograron subir: {blob, etiqueta, li} const pendientes = []; // fotos que no lograron subir: {blob, etiqueta, li}
cfg.secciones.forEach((sec, si) => { cfg.secciones.forEach((sec, si) => {
@ -166,7 +169,8 @@
const id = it.clave || `s${si}_${ii}`; const id = it.clave || `s${si}_${ii}`;
const box = $('div', { class: 'item' }); const box = $('div', { class: 'item' });
if (typeof it.si === 'function') condicionales.push({ el: box, fn: it.si }); if (typeof it.si === 'function') condicionales.push({ el: box, fn: it.si });
if (it.req) obligatorios.push({ id, txt: String(it.txt || '').replace(/<[^>]+>/g, ''), esFoto: it.tipo === 'foto' }); if (it.req) obligatorios.push({ id, txt: String(it.txt || '').replace(/<[^>]+>/g, ''),
esFoto: it.tipo === 'foto', esArchivo: it.tipo === 'archivo' });
if (it.guardaEn) guardaEn[id] = it.guardaEn; if (it.guardaEn) guardaEn[id] = it.guardaEn;
if (it.aviso) box.append($('div', { class: 'aviso', html: it.aviso })); if (it.aviso) box.append($('div', { class: 'aviso', html: it.aviso }));
if (it.tipo === 'check') { if (it.tipo === 'check') {
@ -194,6 +198,23 @@
box.append(g); box.append(g);
} else if (it.tipo === 'nota') { } else if (it.tipo === 'nota') {
box.append($('div', { class: 'nota-item', html: it.txt })); box.append($('div', { class: 'nota-item', html: it.txt }));
} else if (it.tipo === 'fecha') {
box.append($('label', { html: it.txt }));
const hoy = new Date(); hoy.setMinutes(hoy.getMinutes() - hoy.getTimezoneOffset());
const hoyISO = hoy.toISOString().slice(0, 10);
box.append($('input', { type: 'date', 'data-id': id, 'data-lbl': String(it.txt || '').replace(/<[^>]+>/g, ''),
value: hoyISO, 'data-def': hoyISO, style: 'max-width:220px' }));
} else if (it.tipo === 'archivo') {
box.classList.add('foto');
box.append($('span', { class: 'txt', html: (it.req ? '<b>Archivo obligatorio</b> — ' : 'Archivo — ') + it.txt }));
if (it.ayuda) box.append($('div', { class: 'nota-item', html: it.ayuda }));
const inp = $('input', { type: 'file', accept: '.txt,.log,.csv,.nmea,.ubx,.json,.cfg,text/plain', multiple: 'multiple', style: 'display:none' });
const btn = $('button', { type: 'button', class: 'btn-foto', text: '📄 Subir archivo de captura' });
const tiras = $('div', { class: 'miniaturas', 'data-fotos': id });
const marca = $('input', { type: 'hidden', 'data-id': id, 'data-lbl': 'Archivos: ' + String(it.txt || '').replace(/<[^>]+>/g, ''), value: '' });
btn.addEventListener('click', () => inp.click());
inp.addEventListener('change', () => { subirArchivos(inp.files, id, it.etiqueta || String(it.txt || '').replace(/<[^>]+>/g, '').slice(0, 60), tiras, marca); inp.value = ''; });
box.append(btn, inp, tiras, marca);
} else if (it.tipo === 'foto') { } else if (it.tipo === 'foto') {
box.classList.add('foto'); box.classList.add('foto');
box.append($('span', { class: 'txt', html: (it.req ? '<b>Foto obligatoria</b> — ' : 'Foto — ') + it.txt })); box.append($('span', { class: 'txt', html: (it.req ? '<b>Foto obligatoria</b> — ' : 'Foto — ') + it.txt }));
@ -260,15 +281,30 @@
t.append(li); t.append(li);
}); });
} }
function pintarArchivos() {
archivos.forEach(a => {
const t = wrap.querySelector(`.miniaturas[data-fotos="${a.campo}"]`);
if (!t) return;
const li = $('div', { class: 'mini doc ok' });
li.append($('span', { class: 'ico', text: '📄' }),
$('span', { class: 'nom', text: String(a.nombre || '').slice(0, 28) }),
$('span', { class: 'est', text: '✓ guardado' }));
t.append(li);
});
}
function blankFields() { function blankFields() {
fotos = []; fotos = []; archivos = [];
wrap.querySelectorAll('.miniaturas').forEach(t => t.innerHTML = ''); wrap.querySelectorAll('.miniaturas').forEach(t => t.innerHTML = '');
fields().forEach(el => { if (el.type === 'checkbox' || el.type === 'radio') el.checked = false; else el.value = ''; }); fields().forEach(el => {
if (el.type === 'checkbox' || el.type === 'radio') el.checked = false;
else el.value = (el.dataset.def !== undefined ? el.dataset.def : '');
});
const rr = wrap.querySelector('input[name=resultado]:checked'); if (rr) rr.checked = false; const rr = wrap.querySelector('input[name=resultado]:checked'); if (rr) rr.checked = false;
conforme().checked = false; tecnico.value = nombreUsuario; conforme().checked = false; tecnico.value = nombreUsuario;
} }
function fillFrom(a) { function fillFrom(a) {
if (Array.isArray(a.fotos)) { fotos = a.fotos.slice(); pintarFotos(); } if (Array.isArray(a.fotos)) { fotos = a.fotos.slice(); pintarFotos(); }
if (Array.isArray(a.archivos)) { archivos = a.archivos.slice(); pintarArchivos(); }
tecnico.value = a.tecnico || ''; tecnico.value = a.tecnico || '';
conforme().checked = !!a.conforme; conforme().checked = !!a.conforme;
if (a.resultado) { const el = wrap.querySelector(`input[name=resultado][value="${a.resultado}"]`); if (el) el.checked = true; } if (a.resultado) { const el = wrap.querySelector(`input[name=resultado][value="${a.resultado}"]`); if (el) el.checked = true; }
@ -310,6 +346,9 @@
else if (el.type !== 'hidden') v[k] = el.value; else if (el.type !== 'hidden') v[k] = el.value;
}); });
v.__fotos = fotos; v.__fotos = fotos;
v.__archivos = archivos;
// atajo para condiciones: ¿ya se subió un archivo de este campo?
v.subido = c => archivos.some(x => x.campo === c);
return v; return v;
} }
function aplicarCondiciones() { function aplicarCondiciones() {
@ -331,6 +370,7 @@
obligatorios.forEach(o => { obligatorios.forEach(o => {
const el = wrap.querySelector(`[data-id="${o.id}"]`); const el = wrap.querySelector(`[data-id="${o.id}"]`);
if (!el || !visible(el)) return; // oculto por una condición: no aplica if (!el || !visible(el)) return; // oculto por una condición: no aplica
if (o.esArchivo) { if (!archivos.some(a => a.campo === o.id)) falta.push(o.txt); return; }
if (o.esFoto) { if (!fotos.some(f => f.campo === o.id)) falta.push(o.txt); return; } if (o.esFoto) { if (!fotos.some(f => f.campo === o.id)) falta.push(o.txt); return; }
const val = v[o.id]; const val = v[o.id];
if (val === '' || val === false || val === undefined || val === null) falta.push(o.txt); if (val === '' || val === false || val === undefined || val === null) falta.push(o.txt);
@ -370,6 +410,37 @@
li.append(rt); li.append(rt);
} }
} }
function kb(n) { return n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' kB'; }
async function subirArchivos(lista, campo, etiqueta, tiras, marca) {
if (!serieActual()) { fail('Primero elige la unidad: los archivos se guardan con su número de serie.'); return; }
for (const file of Array.from(lista).slice(0, 10)) {
const li = $('div', { class: 'mini doc subiendo' });
li.append($('span', { class: 'ico', text: '📄' }),
$('span', { class: 'nom', text: file.name.slice(0, 28) }));
const est = $('span', { class: 'est', text: 'subiendo…' }); li.append(est); tiras.append(li);
const fd = new FormData();
fd.append('tipo', cfg.tipo); fd.append('serie', serieActual());
fd.append('etiqueta', etiqueta); fd.append('archivo', file, file.name);
try {
const r = await fetch('/api/archivos', { method: 'POST', body: fd });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
const a = j.archivos[0]; a.campo = campo; archivos.push(a);
li.className = 'mini doc ok'; est.textContent = '✓ ' + kb(a.bytes);
const q = $('button', { type: 'button', class: 'mini-btn', text: '✕ quitar' });
q.addEventListener('click', () => {
archivos = archivos.filter(x => x !== a); li.remove();
marca.value = archivos.filter(x => x.campo === campo).length || '';
guardarLocal(); aplicarCondiciones();
});
li.append(q);
marca.value = archivos.filter(x => x.campo === campo).length;
guardarLocal(); aplicarCondiciones();
} catch (e) {
li.className = 'mini doc err'; est.textContent = 'no subió — ' + e.message;
}
}
}
async function subirFotos(lista, campo, etiqueta, tiras, marca) { async function subirFotos(lista, campo, etiqueta, tiras, marca) {
if (!serieActual()) { fail('Primero elige la unidad: las fotos se guardan con su número de serie.'); return; } if (!serieActual()) { fail('Primero elige la unidad: las fotos se guardan con su número de serie.'); return; }
for (const file of Array.from(lista).slice(0, 10)) { for (const file of Array.from(lista).slice(0, 10)) {
@ -477,6 +548,7 @@
conforme: conforme().checked, resultado: rr ? rr.value : '', respuestas: r }; conforme: conforme().checked, resultado: rr ? rr.value : '', respuestas: r };
if (compuesta) { d.componentes = seleccion(); d.verificacion = Object.assign({}, verif); } if (compuesta) { d.componentes = seleccion(); d.verificacion = Object.assign({}, verif); }
if (fotos.length) d.fotos = fotos; if (fotos.length) d.fotos = fotos;
if (archivos.length) d.archivos = archivos;
const v = valores(); const v = valores();
for (const k in guardaEn) if (v[k] !== undefined && v[k] !== '') d[guardaEn[k]] = v[k]; for (const k in guardaEn) if (v[k] !== undefined && v[k] !== '') d[guardaEn[k]] = v[k];
if (flota) { if (flota) {

View File

@ -1,381 +0,0 @@
/* 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
Modos de serie:
(a) simple: cfg.prefijo + cfg.series (cantidad) [+ cfg.desde, cfg.pad] → "RTK-005"
(b) compuesta: cfg.componentes = [{clave, etiqueta, prefijo, desde, hasta, pad, tipoActa}]
cfg.serie(sel) → número de serie calculado, p.ej. "HBOX5-E07-R010-M006"
cfg.prerequisitos = [{etiqueta, tipo, serie(sel)}] → otras actas que deben existir y estar OK
cfg.exigirAprobados = true → no se puede enviar si algún componente/prerrequisito no está aprobado
cfg.unicoPorComponente = true → un componente no puede estar en dos actas de este tipo */
(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; };
const num = s => (String(s || '').match(/(\d+)\s*$/) || [,''])[1];
const esPrueba = s => String(s || '').toUpperCase().indexOf('PRUEBA') >= 0;
function opciones(sel, prefijo, desde, hasta, pad) {
sel.append($('option', { value: '', text: '— elegir —' }));
sel.append($('option', { value: prefijo + '-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
for (let i = desde; i <= hasta; i++) {
const s = prefijo + '-' + String(i).padStart(pad, '0');
sel.append($('option', { value: s, text: s }));
}
}
function render(cfg) {
const root = document.getElementById('acta');
const compuesta = Array.isArray(cfg.componentes) && cfg.componentes.length > 0;
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);
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.' }));
if (cfg.intro) wrap.append($('div', { class: 'nota-item', style: 'margin:0 0 10px', html: cfg.intro }));
// ---------------- cabecera: serie (simple o compuesta) + técnico ----------------
const datos = $('div', { class: 'datos' });
const serieSel = $('select', { id: 'f_serie' }); // modo simple
const serieCalc = $('input', { type: 'text', id: 'f_serie_calc', readonly: 'readonly', placeholder: 'se calcula al elegir los módulos' }); // modo compuesto
const compSel = {}; // clave -> <select>
const compBadge = {}; // clave -> <span> estado
const preBadge = []; // prerrequisitos -> <span>
const tecnico = $('input', { type: 'text', id: 'f_tecnico', placeholder: 'Nombre y apellido' });
if (!compuesta) {
opciones(serieSel, cfg.prefijo, cfg.desde || 1, (cfg.desde || 1) + (cfg.series || 15) - 1, cfg.pad || 2);
const dS = $('div'); dS.append($('label', { text: cfg.etiquetaSerie || 'Número de serie' }), serieSel);
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
datos.append(dS, dT);
} else {
cfg.componentes.forEach(c => {
const sel = $('select', { id: 'f_comp_' + c.clave, 'data-comp': c.clave });
opciones(sel, c.prefijo, c.desde || 1, c.hasta || ((c.desde || 1) + (c.series || 15) - 1), c.pad || 2);
const badge = $('span', { class: 'badge pend', text: '—' });
const d = $('div'); d.append($('label', { text: c.etiqueta }), sel, badge);
datos.append(d); compSel[c.clave] = sel; compBadge[c.clave] = badge;
});
const dSer = $('div', { class: 'serie-calc' }); dSer.append($('label', { text: cfg.etiquetaSerie || 'Número de serie de la unidad (automático)' }), serieCalc);
(cfg.prerequisitos || []).forEach(p => {
const b = $('span', { class: 'badge pend', text: '—' });
const d = $('div'); d.append($('label', { text: p.etiqueta }), b); dSer.append(d); preBadge.push(b);
});
datos.append(dSer);
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
datos.append(dT);
}
const cab = $('section', { class: 'paso' }); cab.append(datos);
wrap.append(cab);
const banner = $('div', { class: 'msg', id: 'f_banner' });
wrap.append(banner);
// selección actual de componentes -> {clave: valor}
function seleccion() { const s = {}; for (const k in compSel) s[k] = compSel[k].value; return s; }
function serieActual() {
if (!compuesta) return serieSel.value;
const s = seleccion();
if (Object.values(s).some(v => !v)) return '';
if (Object.values(s).some(esPrueba)) return (cfg.prefijoPrueba || 'PRUEBA') + '-PRUEBA';
return cfg.serie(s);
}
const key = () => 'acta:' + cfg.tipo + ':' + (serieActual() || '');
// ---------------- secciones ----------------
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: it.modo || 'decimal', 'data-id': id, 'data-lbl': it.txt, placeholder: it.ph || '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: it.rows || '2', 'data-id': id, 'data-lbl': it.txt, placeholder: it.ph || '' }));
} else if (it.tipo === 'radio') {
box.append($('label', { text: it.txt }));
const g = $('div', { class: 'radios' });
(it.opciones || []).forEach(o => {
const l = $('label', { class: 'rad' });
l.append($('input', { type: 'radio', name: 'r_' + id, value: o, 'data-id': id, 'data-lbl': it.txt }), document.createTextNode(' ' + o));
g.append(l);
});
box.append(g);
} else if (it.tipo === 'nota') {
box.append($('div', { class: 'nota-item', html: it.txt }));
}
s.append(box);
});
wrap.append(s);
});
// ---------------- firma ----------------
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(cfg.textoOK || '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);
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' });
const grp = $('div', { style: 'display:flex;gap:10px' }); grp.append(limpiar, borrar, btn);
barra.append(estado, grp);
document.body.append(barra);
// ---------------- 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.type === 'radio') 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 v = a.respuestas[k].val;
const els = wrap.querySelectorAll(`[data-id="${k}"]`); if (!els.length) continue;
els.forEach(el => {
if (el.type === 'checkbox') el.checked = !!v;
else if (el.type === 'radio') el.checked = (el.value === v);
else el.value = v || '';
});
}
}
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';
}
// ---------------- verificación de componentes / prerrequisitos (modo compuesto) ----------------
let verif = {}; // clave -> 'OK' | 'FALLA' | 'SIN_ACTA' | 'PRUEBA' | 'EN_USO'
let usados = null; // { clave: { valor: serieUnidad } } — componentes ya asignados en otras actas de este tipo
function setBadge(el, st, extra) {
const map = { OK: ['ok', '✓ Aprobado'], FALLA: ['falla', '✕ FALLA'], SIN_ACTA: ['falla', '✕ Sin acta'],
PRUEBA: ['pend', '⚙ prueba'], EN_USO: ['falla', '⚠ ya en ' + (extra || 'otra unidad')], CONSULTANDO: ['pend', '…'] };
const m = map[st] || ['pend', '—'];
el.className = 'badge ' + m[0]; el.textContent = m[1];
}
async function estadoActa(tipo, serie) {
if (!serie) return 'SIN_ACTA';
if (esPrueba(serie)) return 'PRUEBA';
try {
const r = await fetch(`/api/acta?tipo=${encodeURIComponent(tipo)}&serie=${encodeURIComponent(serie)}`);
const j = await r.json();
if (!j.exists) return 'SIN_ACTA';
return j.acta && j.acta.resultado === 'FALLA' ? 'FALLA' : 'OK';
} catch (e) { return 'SIN_ACTA'; }
}
async function cargarUsados() {
if (!cfg.unicoPorComponente) { usados = {}; return; }
usados = {};
try {
const r = await fetch('/api/actas', { cache: 'no-store' }); const j = await r.json();
(j.actas || []).filter(a => a.tipo === cfg.tipo && a.componentes && !esPrueba(a.serie)).forEach(a => {
for (const k in a.componentes) { usados[k] = usados[k] || {}; usados[k][a.componentes[k]] = a.serie; }
});
} catch (e) {}
}
async function verificar() {
verif = {};
const s = seleccion();
const tareas = cfg.componentes.map(async c => {
const v = s[c.clave]; const b = compBadge[c.clave];
if (!v) { setBadge(b, null); verif[c.clave] = 'SIN_SELECCION'; return; }
setBadge(b, 'CONSULTANDO');
let st = await estadoActa(c.tipoActa, v);
if (st === 'OK' && usados && usados[c.clave] && usados[c.clave][v] && usados[c.clave][v] !== serieActual()) {
st = 'EN_USO'; setBadge(b, st, usados[c.clave][v]); verif[c.clave] = st; return;
}
setBadge(b, st); verif[c.clave] = st;
});
const pre = (cfg.prerequisitos || []).map(async (p, i) => {
const b = preBadge[i];
if (Object.values(s).some(v => !v)) { setBadge(b, null); verif['pre_' + i] = 'SIN_SELECCION'; return; }
setBadge(b, 'CONSULTANDO');
const st = await estadoActa(p.tipo, Object.values(s).some(esPrueba) ? 'PRUEBA' : p.serie(s));
setBadge(b, st); verif['pre_' + i] = st;
});
await Promise.all(tareas.concat(pre));
}
function todoAprobado() {
return Object.values(verif).every(v => v === 'OK' || v === 'PRUEBA');
}
// ---------------- autoguardado local ----------------
let t, locked = false;
function snapshot() {
const r = {};
fields().forEach(el => {
if (el.type === 'radio') { if (el.checked) r[el.dataset.id] = { lbl: el.dataset.lbl, val: el.value }; else if (!r[el.dataset.id]) r[el.dataset.id] = { lbl: el.dataset.lbl, val: '' }; }
else r[el.dataset.id] = { lbl: el.dataset.lbl, val: el.type === 'checkbox' ? el.checked : el.value };
});
const rr = wrap.querySelector('input[name=resultado]:checked');
const d = { tipo: cfg.tipo, serie: serieActual(), tecnico: tecnico.value,
conforme: conforme().checked, resultado: rr ? rr.value : '', respuestas: r };
if (compuesta) { d.componentes = seleccion(); d.verificacion = Object.assign({}, verif); }
return d;
}
function guardarLocal() {
if (locked || !serieActual()) 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 cargarLocal() {
let d; try { d = JSON.parse(localStorage.getItem(key()) || 'null'); } catch (e) {}
if (d) { fillFrom(d); estado.textContent = 'Borrador recuperado'; }
else estado.textContent = 'Nueva acta';
}
// ---------------- cargar una serie ----------------
async function loadSerie() {
msg.className = 'msg'; locked = false;
const s = serieActual();
if (compuesta) serieCalc.value = s || '';
blankFields(); showEditable();
if (compuesta) { if (usados === null) await cargarUsados(); await verificar(); }
if (!s) { estado.textContent = compuesta ? 'Elige los tres módulos' : 'Elige el número de serie'; return; }
if (esPrueba(s)) { 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();
if (compuesta && cfg.exigirAprobados && !todoAprobado()) {
banner.className = 'msg err';
banner.innerHTML = '⛔ <b>No se puede ensamblar con esta selección.</b> Todos los módulos deben estar <b>aprobados</b> en su acta y no estar ya asignados a otra unidad. Revisa los indicadores de arriba.';
}
}
}
wrap.addEventListener('input', guardarLocal);
serieSel.addEventListener('change', loadSerie);
Object.values(compSel).forEach(sel => sel.addEventListener('change', loadSerie));
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.addEventListener('click', async () => {
if (!admin) return;
if (!confirm('¿Borrar el acta de ' + serieActual() + '? Quedará disponible para rehacerse.')) return;
borrar.disabled = true;
try {
const r = await fetch(`/api/acta?tipo=${encodeURIComponent(cfg.tipo)}&serie=${encodeURIComponent(serieActual())}`, { method: 'DELETE' });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
locked = false; blankFields(); showEditable(); usados = null;
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(compuesta ? 'Elige los tres módulos.' : 'Elige el número de serie.');
if (compuesta && cfg.exigirAprobados && !esPrueba(d.serie) && !todoAprobado())
return fail('Hay módulos sin aprobar, sin acta o ya asignados a otra unidad. No se puede enviar.');
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.status === 409) {
try { localStorage.removeItem(key()); } catch (e) {}
usados = null; 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>.'
+ (compuesta && !esPrueba(d.serie) ? '<br>Etiqueta de la unidad: <b style="font-size:18px;letter-spacing:.04em">' + 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 (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); }
(async () => {
try { const r = await fetch('/api/whoami'); const j = await r.json(); admin = !!j.admin; } catch (e) {}
loadSerie();
})();
}
window.Acta = { render, num };
})();

202
docs/formularios/banco.html Normal file
View File

@ -0,0 +1,202 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acta de diagnóstico en banco — HBOX</title>
<link rel="stylesheet" href="acta.css">
</head>
<body>
<div id="acta"></div>
<script src="flota.js"></script>
<script src="acta.js"></script>
<script>
const corto = v => String(v.continuidad || '').startsWith('Sí');
const enciende = v => String(v.rampa || '').startsWith('C') || String(v.rampa || '').startsWith('D');
const listoParaFlashear = v => v.subido && v.subido('log_blackpill');
Acta.render({
tipo: 'diagnostico-banco',
repetible: true,
tecnicoDesdeUsuario: true,
nombres: { alan: 'Alán Basurto', daniel: 'Daniel Martínez', leo: 'Leo Rodríguez' },
unidades: window.FLOTA,
etiquetaSerie: 'Unidad que estás revisando',
titulo: 'Acta de diagnóstico en banco — HBOX',
subtitulo: 'Helgen Technologies · unidad sobre la mesa, con fuente de banco y multímetro.',
textoOK: 'QUEDA FUNCIONAL',
intro: '<b>Antes de empezar.</b> En una unidad ya desmontada se perdieron la temperatura máxima, '
+ 'la cuenta de recuperaciones y la causa del último arranque: el firmware v3 las guarda en '
+ 'memoria y se borraron al desconectarla. Aquí no vamos a poder probar la historia térmica con '
+ 'datos del equipo — solo con lo que se vea. Lo que sí sale de este banco: si hay corto, cuánto '
+ 'consume, si arranca, si el módem registra, si el GNSS ve cielo y en qué estado está la batería. '
+ 'Procedimiento completo: <a href="../procedimientos/diagnostico-banco/">Diagnóstico en banco</a>.',
secciones: [
{ titulo: '0 · Identificación',
items: [
{ tipo:'fecha', clave:'fecha', req:true, guardaEn:'fecha', txt:'Fecha de la revisión' },
{ tipo:'texto', clave:'maquina_id', rows:'1', guardaEn:'maquina_id',
txt:'¿De qué máquina venía esta unidad?', ph:'RT-ZPMC-20 · HS-WES-62 · FL-JUN-060' },
{ tipo:'radio', clave:'llego', txt:'¿Cómo llegó a la mesa?',
opciones:['Desmontada por nosotros','Desmontada por personal de ICAVE','No sé quién la desmontó'] },
{ tipo:'texto', clave:'antecedente', rows:'2',
txt:'¿Qué se sabe de su falla?', ph:'Dejó de reportar a fines de agosto; se desmontó el 15-sep.' },
{ tipo:'nota', txt:'<div class="peligro"><b>Orden obligatorio:</b> inspección → continuidad → '
+ 'energizado. <b>Nunca energices antes de medir continuidad.</b> Si hay un corto, la fuente '
+ 'lo encuentra quemando algo.</div>' },
] },
{ titulo: '1 · Inspección sin energía',
sub:'Con la unidad desconectada de todo y antes de mover cables.',
items: [
{ tipo:'foto', clave:'f_ext', req:true, etiqueta:'exterior', txt:'Exterior de la caja (varios lados).' },
{ tipo:'foto', clave:'f_int', req:true, etiqueta:'interior', txt:'Interior completo, antes de tocar nada.' },
{ tipo:'radio', clave:'olor', req:true, txt:'¿Huele a quemado o hay partes oscurecidas o derretidas?',
opciones:['No','Sí, leve','Sí, evidente'] },
{ tipo:'foto', clave:'f_dano', etiqueta:'dano', si:v=>String(v.olor||'').startsWith('Sí'), req:true,
txt:'Foto del daño por calor.' },
{ tipo:'radio', clave:'agua', req:true, txt:'¿Agua, marcas de agua, sarro o corrosión verde?',
opciones:['No','Marcas o humedad','Corrosión evidente'] },
{ tipo:'radio', clave:'bateria_fisica', req:true, txt:'Estado físico de la batería',
opciones:['Normal','Hinchada o abombada','Con manchas o fuga','Caliente','La unidad no trae batería'],
aviso:'Hinchada, con fuga o caliente: <b>desconéctala antes de energizar</b> y trabaja sin ella. No la cargues, no la perfores, no viaja en avión.' },
{ tipo:'foto', clave:'f_bat', etiqueta:'bateria',
si:v=>['Hinchada','Con manchas','Caliente'].some(t=>String(v.bateria_fisica||'').startsWith(t)), req:true,
txt:'Foto de la batería, dos ángulos.' },
{ tipo:'check', clave:'wiggle', txt:'Prueba de meneo en cada conector, Dupont y SMA. Anoto abajo lo que esté flojo.' },
{ tipo:'texto', clave:'nota_visual', rows:'2', txt:'¿Qué encontraste?' },
] },
{ titulo: '2 · Continuidad a tierra — antes de energizar',
sub:'Multímetro en ohms, punta negra en el negativo/chasis. La lectura sube despacio: es normal, espera a que se estabilice.',
items: [
{ tipo:'medida', clave:'r_24v', txt:'Entrada 24 V (GX16 pines 1-2)', esperado:'> 10 kΩ', u:'Ω' },
{ tipo:'medida', clave:'r_5v', txt:'Riel de 5 V (salida Mean Well)', esperado:'> 1 kΩ', u:'Ω' },
{ tipo:'medida', clave:'r_3v3', txt:'Riel de 3.3 V', esperado:'> 1 kΩ', u:'Ω' },
{ tipo:'medida', clave:'r_rtk', txt:'Alimentación del simpleRTK2B (riel conmutado)', esperado:'> 1 kΩ', u:'Ω' },
{ tipo:'medida', clave:'v_bat_vacio', txt:'Voltaje de la batería en vacío (desconectada)', esperado:'3.0 – 4.2 V', u:'V' },
{ tipo:'radio', clave:'continuidad', req:true, txt:'¿Alguna lectura se quedó fija entre 0 y 20 Ω?',
opciones:['No, todas suben o están altas','Sí — hay un corto'] },
{ tipo:'nota', si:corto, txt:'<div class="peligro"><b>Hay un corto: no energices.</b> Fotografía la zona, '
+ 'marca la unidad, complétala como FALLA y déjala aparte para enviar a Querétaro. '
+ 'Energizar un corto convierte una reparación en una tarjeta perdida.</div>' },
{ tipo:'foto', clave:'f_corto', etiqueta:'corto', si:corto, req:true, txt:'Foto de la zona donde mediste el corto.' },
] },
{ titulo: '3 · Energizado con rampa de corriente', si:v=>!corto(v),
sub:'Fuente a 24.0 V, límite de corriente 500 mA, sin antenas todavía. Mira el amperímetro los primeros 3 segundos.',
items: [
{ tipo:'medida', clave:'i_pico', txt:'Corriente de arranque (pico)', u:'mA' },
{ tipo:'medida', clave:'i_estable', txt:'Corriente estable a los 60 segundos', esperado:'150 – 400 mA con la etapa RTK encendida', u:'mA' },
{ tipo:'radio', clave:'rampa', req:true, txt:'¿Qué hizo al energizar?',
opciones:[
'A · 0 mA, la fuente no entrega nada',
'B · Se pega al límite y el voltaje se cae (corto)',
'C · 50–150 mA estables, sin LED',
'D · 150–400 mA y el LED parpadea',
'E · Sube despacio y algo se calienta'] },
{ tipo:'nota', si:v=>/^[BE]/.test(String(v.rampa||'')),
txt:'<div class="peligro"><b>Apaga la fuente ahora.</b> Busca con el dorso de la mano qué componente '
+ 'se calienta, tómale foto y no vuelvas a energizar.</div>' },
{ tipo:'foto', clave:'f_calor', etiqueta:'sobrecalentamiento', si:v=>String(v.rampa||'').startsWith('E'), req:true,
txt:'Foto del componente que se calienta.' },
] },
{ titulo: '4 · Rieles', si:enciende,
sub:'Con la unidad energizada y estable.',
items: [
{ tipo:'medida', clave:'v_24', txt:'Entrada 24 V', esperado:'23 – 26 V', u:'V' },
{ tipo:'medida', clave:'v_5', txt:'Riel 5 V (salida Mean Well)', esperado:'4.8 – 5.2 V', u:'V' },
{ tipo:'medida', clave:'v_3v3',txt:'Riel 3.3 V', esperado:'3.20 – 3.40 V', u:'V' },
{ tipo:'medida', clave:'v_rtk_arranque', txt:'Alimentación del simpleRTK2B al arrancar', esperado:'0 V (obligatorio)', u:'V',
aviso:'Si hay 5 V aquí en el instante de arrancar es un <b>defecto</b>: apagado es el estado seguro, la etapa RTK solo debe encender cuando el MCU lo pide.' },
{ tipo:'medida', clave:'v_bat_on', txt:'Batería con la unidad encendida', u:'V' },
] },
{ titulo: '5 · Captura del Blackpill (la principal)', si:enciende,
sub:'USB del Blackpill · 115200 baudios · guardar a archivo · 3 minutos, luego RESET.',
items: [
{ tipo:'archivo', clave:'log_blackpill', etiqueta:'blackpill', req:true,
txt:'Sube el archivo de la captura del Blackpill',
ayuda:'Nómbralo <code>RTK-0NN_blackpill_AAAA-MM-DD.log</code>. Súbelo aunque no entiendas lo que dice.' },
{ tipo:'texto', clave:'linea_boot', rows:'2', txt:'Pega la línea BOOT (dice qué firmware trae)', ph:'BOOT,1240,fw=3.0.3,reset=PIN' },
{ tipo:'texto', clave:'linea_tlm', rows:'3', txt:'Pega una línea TLM ya estabilizada', ph:'TLM,182000,RUN,DEGR,1,11,…' },
{ tipo:'radio', clave:'estado_fw', txt:'¿A qué estado llegó después de 10 minutos con antenas?',
opciones:['RUN con link OK','RUN pero link DEGR o LIMP','Se queda en BOOT','Se reinicia solo una y otra vez','SLEEP'] },
] },
{ titulo: '6 · Captura del módem 4G NTRIP Master', si:enciende,
sub:'Puerto USB "XBEE" del simpleRTK2B Pro · 115200 baudios · 3 minutos desde el arranque.',
items: [
{ tipo:'nota', txt:'<b>Ojo con esta consola:</b> una unidad sana imprime casi lo mismo que una '
+ 'averiada. <b>No prueba</b> que las correcciones lleguen ni que los datos se envíen — eso solo lo '
+ 'dicen el <code>link</code> del Blackpill, un fix ≥4 en el ArduSimple o el tráfico en el servidor. '
+ 'Sirve para saber si el módem enciende, si registra en Telcel y con qué configuración quedó.' },
{ tipo:'archivo', clave:'log_4g', etiqueta:'modem4g', txt:'Sube la captura del módem 4G',
ayuda:'Nómbralo <code>RTK-0NN_modem4g_AAAA-MM-DD.log</code>.' },
{ tipo:'radio', clave:'led_4g', txt:'LED de red del módulo 4G',
opciones:['Parpadeo lento o fijo (registrado)','Parpadeo rápido (buscando, no registra)','Apagado'] },
{ tipo:'texto', clave:'id_estacion', rows:'1', txt:'ID de estación (AS+K) y mountpoint, si pudiste leerlos' },
{ tipo:'texto', clave:'imei_iccid', rows:'1', txt:'IMEI del módem e ICCID de la SIM, si están a la vista' },
] },
{ titulo: '7 · Captura del simpleRTK2B Pro (ArduSimple)', si:enciende,
sub:'USB POWER+GPS · 115200 baudios · antena GNSS CON VISTA AL CIELO (dentro del taller no habrá fix).',
items: [
{ tipo:'archivo', clave:'log_ard', etiqueta:'ardusimple', txt:'Sube la captura de NMEA o el log de u-center',
ayuda:'Nómbralo <code>RTK-0NN_ardusimple_AAAA-MM-DD.nmea</code>.' },
{ tipo:'medida', clave:'sats', txt:'Satélites vistos', esperado:'≥ 8 a cielo abierto' },
{ tipo:'radio', clave:'calidad', txt:'Calidad del fix (sexto campo de $GNGGA, o Fix Mode en u-center)',
opciones:['0 — sin posición','1 — GPS solo, sin correcciones','2 — corregido básico','4 — RTK fijo','5 — RTK flotante'],
aviso:'Un receptor en <b>1</b> con buenos satélites <b>no está roto</b>: dice que las correcciones no le llegan. Eso apunta al módem o al servidor, no al GNSS.' },
{ tipo:'texto', clave:'fw_f9p', rows:'1', txt:'Versión de firmware del ZED-F9P (u-center → UBX-MON-VER)' },
] },
{ titulo: '8 · Reflasheo — solo si ya subiste la captura', si:enciende,
sub:'El flasheo borra la memoria del equipo y cambia el firmware que estaba fallando. Por eso va después de la evidencia, no antes.',
items: [
{ tipo:'nota', si:v=>!listoParaFlashear(v),
txt:'<div class="peligro">⛔ <b>Todavía no puedes reflashear.</b> Sube primero la captura del '
+ 'Blackpill en el paso 5. Sin ella, reflashear destruye la única evidencia del estado en que '
+ 'estaba la unidad.</div>' },
{ tipo:'radio', clave:'reflash', si:listoParaFlashear, txt:'¿Reflasheaste la unidad?',
opciones:['No','Sí, con el binario publicado en Descargas'] },
{ tipo:'texto', clave:'fw_nuevo', si:v=>String(v.reflash||'').startsWith('Sí'), rows:'1',
txt:'Versión que quedó (línea BOOT después del flasheo)', ph:'fw=3.0.3' },
{ tipo:'archivo', clave:'log_post', etiqueta:'post-flasheo', si:v=>String(v.reflash||'').startsWith('Sí'),
txt:'Captura DESPUÉS del flasheo', ayuda:'Las dos capturas juntas son la prueba de si el firmware cambió algo.' },
{ tipo:'radio', clave:'reflash_result', si:v=>String(v.reflash||'').startsWith('Sí'),
txt:'¿Cambió algo?', opciones:['Sí, ahora arranca y se estabiliza','No, repite la misma falla','Empeoró: ya no arranca'] },
] },
{ titulo: '9 · Veredicto',
items: [
{ tipo:'radio', clave:'clasificacion', req:true, guardaEn:'clasificacion', txt:'Clasificación',
opciones:[
'Corto a tierra',
'No enciende, sin corto',
'Enciende pero la etapa RTK no arranca',
'Módem no registra en la red',
'GNSS sin correcciones (fix 1) con módem registrado',
'Batería dañada',
'Daño por calor visible',
'Agua o corrosión',
'Funciona en banco — no se reproduce la falla',
'No está claro'] },
{ tipo:'radio', clave:'accion', req:true, guardaEn:'accion', txt:'Disposición',
opciones:[
'Lista para reinstalar',
'Reflasheada, en observación',
'Se queda en el taller esperando refacción',
'Se envía a Querétaro',
'Baja definitiva'] },
{ tipo:'texto', clave:'observaciones', rows:'4', req:true,
txt:'Cuenta qué viste y qué hiciste',
ph:'Escríbelo como se lo contarías a un compañero. Esto es lo que va a leer ingeniería.' },
] },
]
});
</script>
</body>
</html>

View File

@ -47,6 +47,34 @@ Acta.render({
{ tipo:'check', clave:'reglas', req:true, txt:'<b>Leí las cuatro reglas</b> y voy a seguir el procedimiento en orden.' }, { tipo:'check', clave:'reglas', req:true, txt:'<b>Leí las cuatro reglas</b> y voy a seguir el procedimiento en orden.' },
] }, ] },
{ titulo: '0 · Permiso de acceso y máquina — antes de subirte a nada',
sub:'En ICAVE el equipo no se identifica por el HBOX sino por la MÁQUINA, y no se sube a una máquina sin autorización por escrito.',
items: [
{ tipo:'nota', txt:'<b>Cómo funciona.</b> Operaciones (Adriana) manda a ICAVE un correo pidiendo acceso a la '
+ '<b>máquina</b> por su número, nombrando a los técnicos que van a subir. Hasta que ICAVE contesta y se acuerda '
+ 'el horario, no hay visita. Si llegaste sin ese correo contestado, <b>no subas</b>: avisa y reagenda.' },
{ tipo:'fecha', clave:'fecha', req:true, guardaEn:'fecha', txt:'Fecha de la revisión',
ayuda:'Arranca con la de hoy. Cámbiala si estás capturando una visita de otro día.' },
{ tipo:'texto', clave:'maquina_id', req:true, guardaEn:'maquina_id', rows:'1',
txt:'Número de la máquina donde está instalado el HBOX, tal como lo nombra ICAVE',
ph:'FL-JUN-060 · RT-ZPMC-20 · HS-WES-62' },
{ tipo:'radio', clave:'coincide', req:true, txt:'¿Coincide con la máquina que aparece arriba, junto a la unidad que elegiste?',
opciones:['Sí, es la misma','No — la unidad está en otra máquina (explícalo abajo)','La lista no dice la máquina'],
aviso:'Si NO coincide, avisa a ingeniería: el registro de qué unidad está en qué máquina quedó mal y hay que corregirlo.' },
{ tipo:'radio', clave:'permiso', req:true, guardaEn:'permiso',
txt:'¿Hay autorización de acceso a esa máquina?',
opciones:['Sí, autorizada por correo','Sí, autorizada en sitio por el supervisor','No — solo la reviso desde afuera, sin subir','No aplica — la unidad ya está en el taller'] },
{ tipo:'texto', clave:'permiso_quien', si:v=>String(v.permiso||'').startsWith('Sí'), rows:'1',
txt:'¿Quién autorizó y a qué hora?', ph:'Abraham (ICAVE) — correo del 18-sep, acceso 09:40' },
{ tipo:'check', clave:'loto', si:v=>String(v.permiso||'').startsWith('Sí'),
txt:'La máquina está <b>detenida y bloqueada (LOTO)</b>, o el supervisor confirmó que es seguro trabajar en ella.' },
{ tipo:'nota', si:v=>String(v.permiso||'').startsWith('No —'),
txt:'<div class="peligro"><b>Sin autorización no te subas al equipo.</b> Haz lo que se pueda desde el piso '
+ '(fotos de lejos, patrón del LED si se alcanza a ver) y déjalo anotado. Una revisión incompleta '
+ 'es aceptable; un accidente no.</div>' },
{ tipo:'texto', clave:'operador', rows:'1', txt:'¿Quién te acompañó o te dio acceso? (operador o supervisor)' },
] },
{ titulo: '1 · Por qué vienes a esta unidad', { titulo: '1 · Por qué vienes a esta unidad',
sub:'Contesta con lo que te dijeron, aunque suene vago. La versión del operador vale.', sub:'Contesta con lo que te dijeron, aunque suene vago. La versión del operador vale.',
items: [ items: [
@ -199,6 +227,11 @@ Acta.render({
{ tipo:'medida', clave:'tlm_rec', si:conUsb, txt:'Campo <code>rec</code> (veces que se ha tenido que recuperar)' }, { tipo:'medida', clave:'tlm_rec', si:conUsb, txt:'Campo <code>rec</code> (veces que se ha tenido que recuperar)' },
{ tipo:'medida', clave:'tlm_vbat', si:conUsb, txt:'Campo <code>vbat</code> (voltaje de la batería)', u:'V' }, { tipo:'medida', clave:'tlm_vbat', si:conUsb, txt:'Campo <code>vbat</code> (voltaje de la batería)', u:'V' },
{ tipo:'medida', clave:'tlm_veh', si:conUsb, txt:'Campo <code>veh</code> (1 = le llega corriente de la máquina, 0 = no)' }, { tipo:'medida', clave:'tlm_veh', si:conUsb, txt:'Campo <code>veh</code> (1 = le llega corriente de la máquina, 0 = no)' },
{ tipo:'archivo', clave:'log_blackpill', etiqueta:'captura-blackpill',
si:v=>String(v.usb||'').startsWith('Sí, con archivo'), req:true,
txt:'Sube aquí el archivo de la captura del Blackpill',
ayuda:'El .txt o .log que guardó PuTTY / TeraTerm. Es el registro más valioso de la visita: '
+ 'súbelo aunque no hayas entendido lo que dice.' },
{ tipo:'foto', clave:'f_pantalla', etiqueta:'captura-usb', si:conUsb, { tipo:'foto', clave:'f_pantalla', etiqueta:'captura-usb', si:conUsb,
txt:'Foto de la pantalla de la laptop con las líneas visibles (respaldo por si falla el copiado).' }, txt:'Foto de la pantalla de la laptop con las líneas visibles (respaldo por si falla el copiado).' },
] }, ] },

View File

@ -1,29 +1,35 @@
/* Flota HBOX desplegada — registro único de unidades en campo. /* Flota HBOX desplegada — registro único de unidades en campo.
Lo usan el acta de diagnóstico y (después) la de instalación. Lo usan el acta de diagnóstico y (después) la de instalación.
En ICAVE nadie conoce el equipo por el número del HBOX: lo conocen por la MÁQUINA.
El permiso de acceso se pide por máquina y el técnico llega buscando una máquina.
Por eso la etiqueta empieza siempre por el número de máquina.
⚠ NO CONFUNDIR: estas unidades de campo son «RTK-006» (3 dígitos, generación v3
desplegada). Los módems 4G NTRIP Master del v5 son «RTK-0006» (4 dígitos) y viven
en el acta modulo-4g. Son cosas distintas y se guardan en carpetas distintas.
PARA AGREGAR O CORREGIR UNA UNIDAD: edita este archivo y vuelve a publicar el sitio. PARA AGREGAR O CORREGIR UNA UNIDAD: edita este archivo y vuelve a publicar el sitio.
serie identificador corto y ESTABLE de la unidad (va en el acta y en el servidor). serie número del HBOX tal como viene etiquetado en la caja.
Sin espacios ni acentos. Es la etiqueta física de la caja. etiqueta lo que ve el técnico en la lista: MÁQUINA primero.
etiqueta lo que ve el técnico en la lista desplegable. maquina número de máquina TAL COMO LO NOMBRA ICAVE (el del correo de acceso).
maquina equipo donde está montada, con el número del terminal.
terminal ICAVE · TIMSA · EIT · LCT (agrupa la lista) terminal ICAVE · TIMSA · EIT · LCT (agrupa la lista)
estado operativa | falla | desmontada | repuesto estado operativa | falla | desmontada | repuesto
nota cualquier cosa que el técnico deba saber ANTES de subirse a la máquina. nota lo que el técnico debe saber ANTES de subirse a la máquina. */
⚠ Los datos de abajo están PENDIENTES DE CONFIRMAR por Leo (series y número de máquina). */
window.FLOTA = [ window.FLOTA = [
{ serie: 'HBOX-V3-RTG-020', etiqueta: 'RTG ZPM-020 — unidad desmontada', maquina: 'RTG ZPM 020', { serie: 'RTK-014', etiqueta: 'FL-JUN-060 — Montacargas · RTK-014 (sigue instalada)',
terminal: 'ICAVE', estado: 'desmontada', maquina: 'FL-JUN-060', terminal: 'ICAVE', estado: 'falla',
nota: 'Desmontada del gabinete y de la máquina; está en el escritorio del taller. Diagnóstico en banco, no en campo.' }, nota: '⚠ SIGUE INSTALADA Y NADIE LA HA TOCADO — dejó de reportar el 17-sep y es la única evidencia intacta '
+ 'que tenemos. Acceso solicitado a ICAVE el 18-sep. Sigue el procedimiento en orden y NO le cortes la '
+ 'energía hasta terminar el paso 5.' },
{ serie: 'HBOX-V3-TRACTO-01', etiqueta: 'Tractocamión / tractoplana — unidad desmontada', maquina: 'Tractoplana (confirmar número)', { serie: 'RTK-003', etiqueta: 'RT-ZPMC-20 — RTG · RTK-003 (desmontada, en taller)',
terminal: 'ICAVE', estado: 'desmontada', maquina: 'RT-ZPMC-20', terminal: 'ICAVE', estado: 'desmontada',
nota: 'Desmontada del gabinete y de la máquina; está en el escritorio del taller. Diagnóstico en banco, no en campo.' }, nota: 'Ya desmontada del gabinete y de la máquina; está sobre el escritorio del taller. '
+ 'No requiere permiso de acceso a la máquina. Revisión en banco, no en campo.' },
{ serie: 'HBOX-V3-MONTA-01', etiqueta: 'Montacargas — dejó de reportar (sigue instalada)', maquina: 'Montacargas (confirmar número)', { serie: 'RTK-006', etiqueta: 'HS-WES-62 — Tractoplana · RTK-006 (desmontada, en taller)',
terminal: 'ICAVE', estado: 'falla', maquina: 'HS-WES-62', terminal: 'ICAVE', estado: 'desmontada',
nota: '⚠ SIGUE INSTALADA Y NADIE LA HA TOCADO. Es la única evidencia intacta que tenemos: sigue el procedimiento en orden y NO le cortes la energía hasta terminar el paso 5.' }, nota: 'Ya desmontada del gabinete y de la máquina; está sobre el escritorio del taller. '
+ 'No requiere permiso de acceso a la máquina. Revisión en banco, no en campo.' },
{ serie: 'HBOX-V3-RS-SAN-001', etiqueta: 'Reach Stacker RS-SAN-001', maquina: 'Reach Stacker RS-SAN-001',
terminal: 'ICAVE', estado: 'falla', nota: 'Confirmar si sigue instalada.' },
]; ];

View File

@ -1,25 +1,135 @@
---
hide:
- toc
---
# HBOX — Documentación técnica # HBOX — Documentación técnica
Sitio interno de Helgen Technologies para técnicos de banco y colaboradores del proyecto Sitio interno de Helgen Technologies. **Busca abajo lo que vas a hacer hoy.**
HBOX (EinarBoard). Aquí vive la copia publicada de los procedimientos; la fuente de verdad
es el repositorio **Drogheda** — no edites estas páginas a mano, se regeneran con el
script de sincronización.
## Empieza aquí ## 🔧 Trabajo de campo y taller
- **¿Vas a probar y flashear el lote v5 (15 tarjetas)?** → [EinarBoard v5 — Prueba y flasheo](procedimientos/v5-prueba-y-flasheo.md) <div class="grid cards" markdown>
- **¿Armado de arneses o gabinete?** → [Pinout de arneses](montaje/pinout-arneses.md) y la [guía de ensamble](montaje/assembly-guide.md)
- **¿Una unidad en campo falla?** → [Protocolo de diagnóstico remoto](diagnostico/protocolo-diagnostico-remoto.md)
- **¿Vas a trabajar con los archivos de diseño (KiCad)?** → [Guía Git para colaboradores](git/guia-git-es.md)
## Reglas de la casa - ### 🚜 Una unidad falla en la máquina
1. Los procedimientos se siguen **en orden y completos**; una tarjeta que falla una sección
no avanza a la siguiente.
2. Toda medición fuera de rango se anota con su **valor**, no solo "FALLA".
3. Si algo del documento no coincide con lo que ves en la tarjeta, **detente y pregunta**
a ingeniería antes de continuar.
--- ---
*Última sincronización: la fecha aparece en el pie de cada página. Contacto: leo@helgen.tech* Diagnóstico paso a paso **en sitio**, con el gabinete todavía montado. Empieza por el
permiso de acceso y termina con la unidad clasificada.
[**▶ Llenar el acta de diagnóstico en campo**](formularios/diagnostico.html){ .md-button .md-button--primary }
[Leer el procedimiento primero](diagnostico/procedimiento-diagnostico-campo.md)
- ### 🔬 Una unidad ya está en la mesa
---
Diagnóstico **en banco** con fuente y multímetro: continuidad, rampa de corriente,
rieles y las tres capturas por USB.
[**▶ Llenar el acta de diagnóstico en banco**](formularios/banco.html){ .md-button .md-button--primary }
[Leer el procedimiento primero](procedimientos/diagnostico-banco.md)
- ### ⚡ Reflashear una unidad
---
Solo en taller y **solo después** de subir la captura del diagnóstico.
[Procedimiento de reflasheo](procedimientos/reflasheo-unidades.md)
- ### 📊 ¿Qué se ha revisado?
---
Todas las visitas, sus fotos y sus archivos, unidad por unidad.
[Tablero de campo](diagnostico/tablero-campo.html)
</div>
## 🏭 Producción — lote v5
<div class="grid cards" markdown>
- ### 🔌 Probar y flashear tarjetas
---
Las 15 tarjetas EinarBoard v5, una por una.
[Procedimiento](procedimientos/v5-prueba-y-flasheo.md) · [Acta V5](formularios/v5.html)
- ### 📡 Módulos RTK y 4G
---
simpleRTK2B Pro, módem 4G NTRIP Master y la integración de la pareja.
[Procedimiento](procedimientos/v5-modulos-integracion.md) · [ArduSimple](formularios/ardusimple.html) · [4G](formularios/modem4g.html) · [Integración](formularios/integracion.html)
- ### 🧰 Ensamblar la unidad
---
Armado, energizado y prueba final antes de embarcar.
[Ensamble](procedimientos/v5-ensamble.md) · [Prueba final](procedimientos/v5-prueba-final-unidad.md) · [Acta de ensamble](formularios/ensamble.html)
- ### 🔩 Arneses y gabinete
---
Pinout de conectores, BOM dividido y guía de ensamble.
[Pinout](montaje/pinout-arneses.md) · [BOM](montaje/bom-split.md) · [Guía](montaje/assembly-guide.md)
</div>
## 📥 Archivos y referencia
<div class="grid cards" markdown>
- ### 💾 Descargas
---
Firmware por generación de tarjeta, parámetros del módem 4G y configuración del ZED-F9P.
[Ir a Descargas](descargas/index.md)
- ### 📋 Todas las actas
---
Índice completo de formularios y cómo se usan.
[Índice de actas](actas/index.md) · [Tablero de producción](tablero.html)
- ### 🌿 Git y archivos de diseño
---
Para quien toque KiCad o el repositorio.
[Guía en español](git/guia-git-es.md) · [English](git/git-guide-en.md)
</div>
---
## Reglas de la casa
1. Los procedimientos se siguen **en orden y completos**; una tarjeta que falla una sección no
avanza a la siguiente.
2. Toda medición fuera de rango se anota **con su valor**, no solo "FALLA".
3. En campo, **no reinicies ni desconectes nada** antes de capturar la evidencia: el estado en que
encontraste la unidad es el dato que no se puede recuperar después.
4. Si algo del documento no coincide con lo que ves en la tarjeta, **detente y pregunta** a
ingeniería antes de continuar.
*La fuente de verdad es el repositorio **Drogheda**: no edites estas páginas a mano, se regeneran
con el script de sincronización. Contacto: leo@helgen.tech*

View File

@ -0,0 +1,222 @@
# Diagnóstico en banco — unidad HBOX sobre la mesa
**Para:** Alan y Daniel · **Versión:** 1.0 · 18-sep-2026 · **Responsable:** Leo Rodríguez
Acta que se llena mientras trabajas:
**[▶ Abrir el acta de diagnóstico en banco](../formularios/banco.html)**
Aplica a las unidades que ya están desmontadas: **RTK-003** (RTG `RT-ZPMC-20`) y **RTK-006**
(tractoplana `HS-WES-62`). Para una unidad todavía montada en la máquina, el procedimiento es
otro: [Diagnóstico en campo](../diagnostico/procedimiento-diagnostico-campo.md).
---
## 1. Qué podemos y qué ya no podemos saber
Sé honesto con lo que el banco puede dar, para no perseguir fantasmas.
**Ya se perdió** (y no se recupera): la temperatura máxima, la cuenta de recuperaciones y la causa
del último arranque. El firmware v3 guarda esos tres datos en memoria RAM, así que se borraron al
desconectar la unidad para desmontarla. Por eso en estas dos unidades **no vamos a poder probar la
hipótesis térmica con datos del propio equipo** — solo con lo que se vea físicamente.
**Sí podemos saber:** si hay un corto, cuánta corriente consume, si arranca, si los rieles están
donde deben, si el GNSS ve satélites, si el módem registra en Telcel, en qué estado está la
batería, y si el problema sigue después de reflashear.
> De ahí la regla que aplica a la unidad del montacargas (**RTK-014**, todavía instalada):
> primero la captura por USB en sitio, y hasta después se desmonta.
## 2. Equipo necesario
| Equipo | Para qué |
|---|---|
| **Fuente de banco con límite de corriente** | Energizar sin quemar nada y **leer el consumo** |
| Multímetro | Continuidad y rieles |
| Laptop + cables USB | Las tres capturas (Blackpill, 4G, ArduSimple) |
| Antena GNSS y 2 antenas LTE de prueba | Para que el receptor y el módem puedan funcionar |
| Cielo abierto o ventana | Sin vista al cielo el GNSS nunca dará fix y parecerá falla |
| Cámara | Fotos obligatorias del acta |
## 3. Seguridad
1. **Nunca energices antes del paso 5 (continuidad).** Si hay un corto, la fuente lo va a encontrar
quemando algo.
2. **Batería de litio:** si está hinchada, caliente, con fuga o huele, **desconéctala primero** y
trabaja sin ella. No la perfores, no la cargues, no la metas en un cajón. Foto y aviso.
3. Empieza siempre con el **límite de corriente bajo** (500 mA). Subirlo es fácil; deshacer un
componente quemado, no.
---
## 4. Paso 1 — Inspección sin energía
Con la unidad **desconectada de todo**, y antes de mover nada:
- [ ] Foto del exterior de la caja, de los cuatro lados.
- [ ] Abrir. **Foto del interior completo antes de tocar cables.**
- [ ] ¿Huele a quemado? ¿Hay partes oscurecidas, derretidas o abombadas?
- [ ] ¿Hay agua, marcas de agua, sarro blanco o **corrosión verde** en conectores?
- [ ] Batería: ¿hinchada, caliente, con manchas o fuga? Foto de los dos lados.
- [ ] Prueba de meneo suave en cada conector, Dupont y SMA: ¿alguno se mueve o se sale?
- [ ] Conectores de antena: ¿el centro del SMA está doblado o falta?
Cualquier hallazgo aquí ya es un resultado: fotografíalo y anótalo aunque después todo encienda.
## 5. Paso 2 — Continuidad a tierra, **antes** de energizar
Multímetro en ohms, **punta negra en el negativo/chasis**. La lectura sube lentamente (los
capacitores se están cargando con la pila del multímetro): eso es normal, espera a que se estabilice.
| Punto | Esperado | Lectura mala |
|---|---|---|
| Entrada 24 V (pines 1-2 del GX16) | **> 10 kΩ** | Fija en 0–20 Ω = **corto: no energizar** |
| Riel de 5 V (salida de la Mean Well) | **> 1 kΩ** | Fija en 0–20 Ω = corto |
| Riel de 3.3 V | **> 1 kΩ** | Fija en 0–20 Ω = corto |
| Alimentación del simpleRTK2B (riel conmutado) | **> 1 kΩ** | Fija en 0–20 Ω = corto |
| Batería (bornes, con la batería **desconectada**) | — | Anota su voltaje en vacío |
!!! danger "Si encuentras un corto"
**No energices.** Anótalo, fotografía la zona, marca la unidad como *corto a tierra* y déjala
aparte. Energizar un corto convierte una reparación de un componente en una tarjeta perdida.
## 6. Paso 3 — Energizado con rampa de corriente
Este paso es el que más información da por minuto invertido.
1. Fuente a **24.0 V**, límite de corriente en **500 mA**, salida **apagada**.
2. Conecta solo la entrada de 24 V (sin antenas todavía).
3. Enciende la salida y **mira el amperímetro en los primeros 3 segundos**.
| Lo que hace la corriente | Qué significa | Qué haces |
|---|---|---|
| **0 mA**, la fuente no entrega nada | Circuito abierto: fusible, conector o etapa de entrada muerta | Revisa fusible y cableado de entrada |
| **Se pega al límite** (500 mA) y el voltaje se cae | **Corto** | **Apaga de inmediato.** Regresa al paso 2 |
| **50–150 mA** estables, sin LED | Vive el MCU pero la etapa RTK no enciende | Sigue al paso 4 y a la captura del Blackpill |
| **150–400 mA** y el LED parpadea | Arranca normal | Sigue al paso 4 |
| Sube despacio y **algo se calienta** | Componente en corto parcial | Apaga y busca con el dorso de la mano qué quema |
Anota **el pico de arranque** y **la corriente estable a los 60 segundos**. Son dos números
distintos y los dos importan.
## 7. Paso 4 — Rieles
Con la unidad energizada y estable:
| Punto | Esperado |
|---|---|
| Entrada 24 V | 23 – 26 V |
| Riel 5 V (salida Mean Well) | 4.8 – 5.2 V |
| Riel 3.3 V | 3.20 – 3.40 V |
| Alimentación del simpleRTK2B **al arrancar** | **0 V** — el firmware enciende la etapa RTK solo cuando decide que es seguro |
| Batería de respaldo | 3.0 – 4.2 V |
!!! tip
5 V en la etapa RTK **en el instante de arrancar** es un defecto, no una buena señal: el
invariante del producto dice *apagado es el estado seguro*. Si aparece energía ahí antes de que
el MCU la pida, anótalo.
## 8. Paso 5 — Captura del Blackpill (la principal)
1. Conecta el **USB del Blackpill** a la laptop.
2. Abre el puerto serie nuevo a **115200 baudios** (PuTTY, TeraTerm o el monitor de Arduino).
3. Activa **guardar a archivo**.
4. Deja correr **3 minutos**. Luego presiona **RESET** y graba también el arranque.
5. Guarda el archivo como `RTK-0NN_blackpill_AAAA-MM-DD.log` y **súbelo en el acta**.
Qué vas a ver (firmware v3.0.x):
```
BOOT,1240,fw=3.0.3,reset=PIN
COLS,ms,state,link,fix,sat,rx,rate,vbat,v5v,veh,ign,tc,tmax,der,rec,limp,…
TLM,182000,RUN,DEGR,1,11,4210,1HZ,3.92,5.02,1,1,58,81,0,3,1,900,…
```
| Campo | Léelo así |
|---|---|
| `state` | `RUN` trabajando · `BOOT` arrancando · `SLEEP` dormida · `PWRCYC` reiniciando la etapa RTK |
| `link` | `OK` con correcciones · `DEGR` intermitentes · `LIMP` casi sin ellas · `SRCH` buscando |
| `fix` | 4 o 5 = RTK · 1 = GPS solo · 0 = sin posición |
| `vbat` / `v5v` | Voltajes que mide el propio equipo — compáralos con tu multímetro |
| `veh` / `ign` | 1 = presente. En banco, `veh` debe ser 1 y `ign` 0 si no conectaste ignición |
| `tc` / `tmax` | Temperatura actual y máxima **desde este arranque** |
| `rec` / `limp` | Recuperaciones y si quedó enganchada en modo limitado |
Si en vez de estas líneas ves un tablero que se redibuja, la unidad trae otro firmware: **anótalo y
avísale a ingeniería antes de seguir**.
## 9. Paso 6 — Captura del módem 4G NTRIP Master
El módem se configura y se escucha por el **puerto USB "XBEE"** del simpleRTK2B Pro (el módulo 4G va
montado sobre el receptor y ese USB es su consola).
1. **Sin energía**, verifica que el módulo 4G esté bien asentado en el zócalo XBee y que las
**dos antenas LTE** estén conectadas.
2. Conecta el USB **POWER+GPS** (alimenta todo) **y** el USB **XBEE** (consola del módem).
3. Terminal serie en el puerto **XBEE** a **115200 baudios**, con **guardar a archivo**.
4. Deja grabar **3 minutos** desde el arranque, sin escribir nada. Anota qué hace el **LED de red**
del módulo: parpadeo rápido = buscando; lento o fijo = registrado.
5. Para leer su configuración: `$$$AS+I=1111` entra a modo configuración. Anota el **ID de estación**
(`AS+K`) y el mountpoint.
6. Guarda como `RTK-0NN_modem4g_AAAA-MM-DD.log` y súbelo al acta.
!!! warning "Esta consola miente por omisión"
Una unidad **sana** imprime en esta consola prácticamente lo mismo que una **averiada**: unas
pocas líneas de registro y nada más. Ya nos pasó — en junio dimos por muerto un módem sano
leyendo solo esta consola. **La consola del 4G no prueba que las correcciones estén llegando ni
que los datos se estén enviando.** Eso solo se confirma con el `link` del Blackpill, con un fix
≥4 en el ArduSimple, o viendo el tráfico en el servidor.
Úsala para lo que sí demuestra: que el módem enciende, que registra en Telcel y con qué
configuración quedó.
## 10. Paso 7 — Captura del simpleRTK2B Pro (ArduSimple)
1. Antena GNSS conectada y **con vista al cielo** (ventana abierta o afuera; dentro del taller no
habrá fix y parecerá falla).
2. Conecta el USB **POWER+GPS** a la laptop.
3. **Opción rápida (terminal):** abre el puerto serie a **115200 baudios** y graba 3 minutos de
NMEA. Guarda como `RTK-0NN_ardusimple_AAAA-MM-DD.nmea`.
4. **Opción completa (u-center):** *Receiver → Connect*, y revisa:
- **UBX-MON-VER** → versión de firmware del ZED-F9P (anótala en el acta).
- **Fix Mode** → debe llegar a **3D** en menos de 2 minutos a cielo abierto.
- Número de satélites y su nivel de señal.
- Si hay correcciones llegando, el fix sube a **3D/DGNSS/FIXED**.
Cómo leer el NMEA sin u-center: busca líneas `$GNGGA` y mira el **sexto campo** (calidad):
| Calidad | Significa |
|---|---|
| `0` | Sin posición |
| `1` | GPS normal, **sin correcciones** |
| `2` | Corregido básico |
| **`4`** | **RTK fijo** — es el objetivo |
| **`5`** | RTK flotante — casi |
Un receptor que da `1` con buenos satélites **no está roto**: está diciendo que las correcciones no
le llegan, y eso apunta al módem o al servidor, no al GNSS.
## 11. Paso 8 — Batería
| Medida | Esperado | Lectura mala |
|---|---|---|
| Voltaje en vacío (desconectada) | 3.0 – 4.2 V | < 2.5 V = celda muy descargada o dañada |
| Voltaje con la unidad encendida | Sube lentamente si está cargando | Baja = no está cargando |
| Aspecto | Plana, sin marcas | Hinchada, con fuga o caliente = **fuera de servicio** |
Una batería hinchada **no se reinstala, no se carga y no viaja en avión**. Sepárala, foto, aviso.
## 12. Paso 9 — Veredicto
| Hallazgo | Disposición |
|---|---|
| Corto a tierra | No reparar en sitio → enviar a Querétaro |
| No enciende, sin corto | Enviar a Querétaro |
| Enciende y todo normal en banco | Reflashear ([procedimiento](reflasheo-unidades.md)) y volver a probar |
| Enciende pero el módem no registra | Probar la SIM en otro módulo antes de culpar al módem |
| GNSS con fix `1` y módem registrado | Revisar servidor/caster — no es hardware |
| Batería dañada | Retirar la batería, anotarlo, y decidir con ingeniería si se reinstala sin ella |
| Daño por calor visible | Enviar a Querétaro **con las fotos** — es evidencia del rediseño térmico |
Al terminar, **sube al acta los tres archivos de captura** (Blackpill, 4G y ArduSimple). Son el
insumo del análisis en Querétaro: sin ellos, el diagnóstico se queda en tu memoria.

View File

@ -0,0 +1,143 @@
# Reflasheo de unidades HBOX — solo en taller
**Para:** Alan y Daniel (ejecución) · ingeniería (generación del binario)
**Versión:** 1.0 · 18-sep-2026 · **Responsable:** Leo Rodríguez
---
## 1. Cuándo se reflashea y cuándo no
Reflashear es una **prueba diagnóstica**, no una reparación: sirve para contestar *«¿el problema
desaparece con firmware nuevo?»*. Si la unidad tiene un corto, un conector roto o una batería
hinchada, el firmware no va a arreglar nada.
!!! danger "Dos reglas que no se rompen"
**1 · Solo en taller, nunca sobre la máquina.** Un flasheo interrumpido deja la unidad muerta.
Si eso pasa en la mesa, se vuelve a intentar; si pasa arriba de un RTG, hay que bajarla otra vez.
**2 · Primero la evidencia, después el flasheo.** El flasheo borra la memoria del equipo
(temperatura máxima, recuperaciones, causa del último arranque) y sustituye el firmware que
estaba fallando. **Haz y sube las capturas del diagnóstico antes de tocar el botón.** El acta de
banco bloquea este paso hasta que el archivo de captura esté subido.
| Situación | ¿Reflashear? |
|---|---|
| Ya hiciste el diagnóstico y las capturas están subidas | **Sí** |
| La unidad tiene corto, humedad o daño visible | No — enviar a Querétaro |
| No sabes qué firmware trae | Primero anótalo (línea `BOOT` de la captura) |
| La unidad está instalada en la máquina | **No.** Se desmonta primero |
| No encuentras el `.bin` correcto en Descargas | No inventes: pregunta a ingeniería |
## 2. Qué binario le toca a cada unidad
Los binarios viven en **[Descargas](../descargas/index.md)**, con su versión y su SHA256. Cada
generación de tarjeta tiene el suyo y **no son intercambiables**: la polaridad del control de carga
está invertida entre generaciones, así que un binario equivocado puede dejar la batería cargando
cuando debería estar cortada.
| Generación de tarjeta | Binario | Estado |
|---|---|---|
| **V3** (flota desplegada: RTK-003, RTK-006, RTK-014) | `hbox-fw-v3_*.bin` | ⬜ pendiente de publicar |
| **V4** | `hbox-fw-v4_*.bin` | ⬜ pendiente de publicar |
| **V5** (EinarBoard v5, banco) | `hbox-v5-bringup_0.4.0.bin` | ✅ publicado |
Si el renglón dice *pendiente*, **no hay binario aprobado todavía**: el flasheo de esa generación
está detenido hasta que ingeniería lo publique (§6).
## 3. Qué necesitas
- Laptop con **STM32CubeProgrammer** (o `dfu-util`).
- Cable **USB con datos** (muchos cables de carga no llevan datos — si la laptop no ve nada, prueba otro).
- El `.bin` descargado del sitio, con su SHA256 verificado.
- La unidad **en la mesa**, energizada solo por el USB.
Verifica el archivo antes de flashear:
```bash
# macOS / Linux
shasum -a 256 hbox-fw-v3_X.Y.Z.bin
# Windows (PowerShell)
Get-FileHash .\hbox-fw-v3_X.Y.Z.bin -Algorithm SHA256
```
Debe coincidir **carácter por carácter** con el que publica `SHA256SUMS.txt`. Si no coincide,
descárgalo otra vez; si sigue sin coincidir, no lo uses y avisa.
## 4. El flasheo, paso a paso
1. **Anota el firmware que traía.** De la captura del diagnóstico, la línea `BOOT,…,fw=3.0.3,…`.
Va en el acta. Sin este dato el reflasheo no sirve como prueba.
2. Conecta el **USB del Blackpill** a la laptop.
3. **Entra a modo DFU:**
- Mantén presionado **BOOT0**.
- Presiona y suelta **RESET (NRST)**.
- Suelta **BOOT0**.
- En la laptop debe aparecer **«STM32 BOOTLOADER»** (identificador `0483:DF11`).
Si no aparece: repite la secuencia; si sigue sin salir, el cable no lleva datos o el MCU no arranca.
4. **Graba el binario** en la dirección **`0x08000000`**:
- *STM32CubeProgrammer* → conectar en **USB/DFU** → *Open file* → *Download*.
- o bien: `dfu-util -a 0 -s 0x08000000:leave -D hbox-fw-v3_X.Y.Z.bin`
5. Espera a **«Download complete»** y **«Verify successful»**. Si falla a la mitad, **no desconectes**:
repite el paso 3 y vuelve a grabar.
6. Presiona **RESET**.
## 5. Verificación — el flasheo no termina hasta aquí
1. Abre el puerto serie a **115200 baudios**, con **guardar a archivo**.
2. Presiona RESET otra vez y captura el arranque.
3. Comprueba:
- [ ] Aparece la línea `BOOT` con la **versión nueva** en `fw=`.
- [ ] El LED vuelve a parpadear (no se queda apagado ni en parpadeo rápido permanente).
- [ ] Empiezan a salir líneas `TLM,` cada pocos segundos.
- [ ] Ningún `EVT` de falla repitiéndose sin parar.
4. Deja correr **10 minutos** con antenas conectadas y vuelve a mirar: `state` debe llegar a `RUN`,
y `link` debe salir de `SRCH`.
5. **Sube esta captura al acta**, junto con la de antes del flasheo. Las dos juntas son la prueba de
si el firmware cambió algo.
| Resultado | Qué sigue |
|---|---|
| Arranca y se estabiliza en `RUN` | Candidata a reinstalar — coordina con ICAVE |
| Arranca pero repite la misma falla | El firmware no era la causa → enviar a Querétaro |
| Ya no arranca después del flasheo | Repite DFU con otro cable; si sigue, enviar a Querétaro |
## 6. Para ingeniería — cómo se genera el binario
*(Esta sección no es para el técnico: es para quien publica el `.bin`.)*
Hoy el repositorio **no contiene binarios compilados de V3 ni de V4** — solo el de bring-up del V5,
que sí está publicado y versionado. Los `.bin` de firmware están en `.gitignore` a propósito. Para
que el procedimiento de arriba funcione, cada generación necesita un binario **compilado, probado en
banco y publicado con su SHA256**.
Fuentes: `04-Source-Code/firmware/blackpillrtkcode_v3/` y `…_v4/`.
Compilación reproducible con `arduino-cli` (verifica el FQBN contra tu instalación del core
STM32duino antes de darlo por bueno):
```bash
arduino-cli core install STMicroelectronics:stm32
arduino-cli compile \
--fqbn STMicroelectronics:stm32:GenF4:pnum=BLACKPILL_F411CE \
--build-property "compiler.cpp.extra_flags=-DTARGET_EINARBOARD=1" \
--output-dir release/ \
blackpillrtkcode_v3/
shasum -a 256 release/*.bin >> release/SHA256SUMS
```
!!! warning "El flag de destino de tarjeta no es cosmético"
`TARGET_EINARBOARD` y `TARGET_PROTO` **invierten la polaridad del control de carga** (en
producción `CHRG_EN` en alto = carga **apagada**; en protoboard `CHRG_RUN` en alto = carga
**encendida**). Compilar con el destino equivocado puede dejar la batería cargando justo cuando
el firmware cree que la está protegiendo por temperatura — precisamente la falla #1 del RCA.
Antes de publicar:
- [ ] Compilado con el `TARGET_` correcto para la generación de tarjeta.
- [ ] **Probado en banco en una unidad real**, no solo compilado.
- [ ] Verificada la polaridad del control de carga sobre hardware.
- [ ] Publicado en `docs/descargas/` con versión en el nombre y su línea en `SHA256SUMS.txt`.
- [ ] Anotado en el `CHANGELOG.md` del repositorio de firmware, con la fecha.
- [ ] Actualizada la tabla de §2 de este documento.

View File

@ -20,7 +20,8 @@ theme:
icon: material/weather-sunny icon: material/weather-sunny
name: Modo claro name: Modo claro
features: features:
- navigation.sections - navigation.tracking
- navigation.indexes
- navigation.top - navigation.top
- search.highlight - search.highlight
- content.code.copy - content.code.copy
@ -39,41 +40,53 @@ markdown_extensions:
permalink: true permalink: true
nav: nav:
- Inicio: index.md - "🏠 Inicio": index.md
- "Tablero de estado": tablero.html
- Actas y control de calidad: - "🚜 Campo — la unidad está en la máquina":
- "Índice de actas": actas/index.md - "Cómo se diagnostica en campo": diagnostico/procedimiento-diagnostico-campo.md
- "Acta de diagnóstico en campo": formularios/diagnostico.html - "▶ Acta de diagnóstico en campo": formularios/diagnostico.html
- "Acta de prueba V5 (tarjeta)": formularios/v5.html - "Tablero de campo (qué se ha revisado)": diagnostico/tablero-campo.html
- "Acta de módulo ArduSimple (simpleRTK2B, ARD-NNN)": formularios/ardusimple.html
- "Acta de módem 4G (NTRIP Master, RTK-NNNN)": formularios/modem4g.html - "🔬 Taller — la unidad está en la mesa":
- "Acta de integración ArduSimple + 4G": formularios/integracion.html - "Cómo se diagnostica en banco": procedimientos/diagnostico-banco.md
- "Acta de ensamble (unidad HBOX v5)": formularios/ensamble.html - "▶ Acta de diagnóstico en banco": formularios/banco.html
- "Acta de prueba V4 (borrador)": formularios/v4.html - "⚡ Reflasheo de unidades": procedimientos/reflasheo-unidades.md
- Procedimientos de banco:
- "EinarBoard v5 — Prueba y flasheo (lote 15 pzas)": procedimientos/v5-prueba-y-flasheo.md - "🏭 Producción — lote v5":
- "HBOX v5 — Módulos RTK/4G e integración (antes del ensamble)": procedimientos/v5-modulos-integracion.md - "1 · Prueba y flasheo de tarjetas": procedimientos/v5-prueba-y-flasheo.md
- "HBOX v5 — Ensamble de la unidad y energizado final": procedimientos/v5-ensamble.md - "▶ Acta de tarjeta V5": formularios/v5.html
- "HBOX v5 — Prueba final de unidad (fw 0.4.0, antes de embarcar)": procedimientos/v5-prueba-final-unidad.md - "2 · Módulos RTK y 4G": procedimientos/v5-modulos-integracion.md
- "EinarBoard 1.0 — Bring-up runbook": procedimientos/einarboard-1.0-bringup.md - "▶ Acta ArduSimple": formularios/ardusimple.html
- "FW v3.0.3 — Prueba de banco (política de ventilador)": procedimientos/fan-policy-bench-test.md - "▶ Acta módem 4G": formularios/modem4g.html
- "Histórico — Proto bench v3": procedimientos/historico-proto-bench-v3.md - "▶ Acta de integración": formularios/integracion.html
- "Histórico — Protoboard": procedimientos/historico-protoboard.md - "3 · Ensamble de la unidad": procedimientos/v5-ensamble.md
- Montaje y arneses: - "▶ Acta de ensamble": formularios/ensamble.html
- "4 · Prueba final antes de embarcar": procedimientos/v5-prueba-final-unidad.md
- "Tablero de producción": tablero.html
- "🔩 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 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 en campo — procedimiento": diagnostico/procedimiento-diagnostico-campo.md - "📥 Descargas":
- "⚑ Acta de diagnóstico en campo (llenar en la máquina)": formularios/diagnostico.html - "Firmware, parámetros 4G y ZED-F9P": descargas/index.md
- "Tablero de campo (ingeniería)": diagnostico/tablero-campo.html - "README firmware bring-up v5": descargas/hbox-v5-bringup-README.md
- "Protocolo de diagnóstico remoto": diagnostico/protocolo-diagnostico-remoto.md
- Git: - "📋 Actas — índice y cómo se usan": actas/index.md
- "🌿 Git y archivos de diseño":
- "Guía Git (español)": git/guia-git-es.md - "Guía Git (español)": git/guia-git-es.md
- "Git guide (English)": git/git-guide-en.md - "Git guide (English)": git/git-guide-en.md
- Descargas:
- "Firmware y herramientas": descargas/index.md - "📚 Referencia e histórico":
- "README firmware bring-up v5": descargas/hbox-v5-bringup-README.md - "Protocolo de diagnóstico remoto (2026-06)": diagnostico/protocolo-diagnostico-remoto.md
- "Administración (Leo)": admin.html - "EinarBoard 1.0 — Bring-up runbook": procedimientos/einarboard-1.0-bringup.md
- "Salir": salir.html - "FW v3.0.3 — Prueba de banco (ventilador)": procedimientos/fan-policy-bench-test.md
- "Acta de prueba V4 (borrador)": formularios/v4.html
- "Histórico — Proto bench v3": procedimientos/historico-proto-bench-v3.md
- "Histórico — Protoboard": procedimientos/historico-protoboard.md
- "⚙ Administración (Leo)": admin.html
- "🚪 Salir": salir.html

View File

@ -11,4 +11,14 @@ PORT="${SSH_PORT:-22}"
mkdocs build --strict mkdocs build --strict
rsync -az --delete -e "ssh -p $PORT" site/ "$REMOTE:$REMOTE_PATH/" rsync -az --delete -e "ssh -p $PORT" site/ "$REMOTE:$REMOTE_PATH/"
echo "Publicado en $REMOTE:$REMOTE_PATH (puerto $PORT)" echo "Sitio publicado en $REMOTE:$REMOTE_PATH (puerto $PORT)"
# El backend NO vive en git en el servidor: se copia igual que el sitio.
# Sin --delete (ahí junto al app.py viven el venv y __pycache__).
BACKEND_PATH="$(dirname "$REMOTE_PATH")/backend"
rsync -az -e "ssh -p $PORT" backend/app.py backend/requirements.txt \
backend/parche-nginx-api.py backend/actas-hbox.service "$REMOTE:$BACKEND_PATH/"
echo "Backend copiado en $REMOTE:$BACKEND_PATH"
echo
echo "FALTA reiniciar el servicio (pide sudo, por eso no se hace aquí):"
echo " ssh $REMOTE 'sudo systemctl restart actas-hbox'"