Commit 30cbdf0e by Lucía Elizo Gómez

Plugin Kadi4Mat

parents
# kadi-plugin-biaprint
Plugin de **Kadi4Mat** que expone un endpoint HTTP para recibir archivos
automáticamente desde una Raspberry Pi y crearlos como Records en la plataforma.
## Arquitectura
```
Raspberry Pi ──► POST /biaprint/upload ──► Plugin BiaPrint ──► Record en Kadi4Mat
(multipart/form-data) + archivos adjuntos
api_token + metadata JSON + extras/metadatos
+ files[] (mp4, stl, …) + Collection
```
## Instalación en el servidor Kadi4Mat
```bash
# Dentro del entorno virtual de Kadi4Mat
pip install -e /ruta/al/kadi_plugin_biaprint/
# Verificar que Kadi detecta el plugin
flask kadi list-plugins
```
## Configuración (kadi.cfg)
```python
# Token que la Pi debe enviar para autenticarse
BIAPRINT_API_TOKEN = "pat_286fd4dd7b415a0448c4d9be052a509adf1ac396494613fb"
# Identifier de la Collection donde se añadirán los Records
# (debe existir previamente en Kadi4Mat)
BIAPRINT_COLLECTION = "biaprint-trabajos"
# Directorio temporal para los archivos durante el upload
BIAPRINT_UPLOAD_DIR = "/tmp/biaprint_uploads"
```
## Endpoints disponibles
| Método | Ruta | Descripción |
|--------|--------------------|----------------------------------------------|
| GET | /biaprint/health | Comprobación rápida (sin autenticación) |
| POST | /biaprint/upload | Recibe archivos y crea/actualiza un Record |
## Formato del JSON de metadatos
```json
{
"job_name": "pieza-soporte-v3",
"material": "PLA",
"layer_height_mm": 0.2,
"print_time_s": 3600,
"printer_id": "prusa-mk4-01",
"operator": "usuario@ejemplo.com",
"custom": {
"cualquier_campo": "cualquier_valor"
}
}
```
## Ejemplo de llamada desde curl
```bash
curl -X POST https://kadi.tudominio.com/biaprint/upload \
-H "Authorization: Bearer pat_ccc6bfdb17f9ff7c1bb271fe997eafee3b91561c67405ea9" \
-F "metadata={\"job_name\":\"pieza-test\",\"material\":\"PLA\"}" \
-F "files[]=@/ruta/al/video.mp4" \
-F "files[]=@/ruta/al/config.json"
```
Metadata-Version: 2.4
Name: biaprint
Version: 0.1.0
Summary: Kadi4Mat plugin for 3D printing sessions
Requires-Python: >=3.10
Requires-Dist: kadi
README.md
pyproject.toml
setup.py
biaprint.egg-info/PKG-INFO
biaprint.egg-info/SOURCES.txt
biaprint.egg-info/dependency_links.txt
biaprint.egg-info/entry_points.txt
biaprint.egg-info/requires.txt
biaprint.egg-info/top_level.txt
kadi_plugin_biaprint/__init__.py
kadi_plugin_biaprint/plugin.py
\ No newline at end of file
[kadi_plugins]
biaprint = kadi_plugin_biaprint.plugin
PLUGINS = ["biaprint"]
PLUGIN_CONFIG = {
"biaprint": {
"api_token": "pat_286fd4dd7b415a0448c4d9be052a509adf1ac396494613fb",
"upload_dir": "/tmp/biaprint_uploads",
},
}
# kadi_plugin_biaprint/__init__.py
# Paquete del plugin BiaPrint para Kadi4Mat.
# La versión se sincroniza con pyproject.toml.
__version__ = "0.1.0"
"""
Kadi4Mat plugin: Biaprint
--------------------------
Endpoint para recibir sesiones de impresion 3D desde la Biaprint,
crear un Record con metricas y archivos, y vincularlo a coleccion
del año actual.
Hooks:
- kadi_get_blueprints → registra el PluginBlueprint
- kadi_post_app_initialization → log de arranque
Endpoint:
POST /api/biaprint/folder
Autenticación:
Authorization: Bearer <personal_access_token> (scope: record.create)
Form-data:
folder_name (str) nombre de la carpeta / sesion
description (str) descripcion opcional
files[] (file, varios) archivos de la sesion
"""
import json
import re
from datetime import datetime
from pathlib import Path
from flask import request
from kadi.lib.api.blueprint import bp as api_bp
from kadi.lib.api.core import (
get_access_token,
json_error_response,
json_response,
scopes_required,
)
from kadi.lib.plugins.core import PluginBlueprint, get_plugin_config
from kadi.plugins import hookimpl
PLUGIN_NAME = "biaprint"
# Subcarpetas de camara
CAMERA_DIRS = {"cam1", "cam2"}
# Formatos de imagen
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"}
# ── PluginBlueprint ─────────────────────────────────────────
plugin_bp = PluginBlueprint(PLUGIN_NAME, __name__, url_prefix="/biaprint")
@plugin_bp.get("/health")
def health():
return json_response(200, {"status": f"{PLUGIN_NAME} plugin activo"})
# ── Helpers ───────────────────────────────────────────────────
def _make_identifier(texto: str) -> str:
slug = texto.lower().strip()
slug = re.sub(r"[^a-z0-9]+", "-", slug)
return slug.strip("-")[:80] or "sesion"
def _analizar_sesion(samples: list) -> dict:
"""Extrae metricas del archivo JSON."""
if not samples:
return {}
primero = samples[0]
ultimo = samples[-1]
def vals(key_path):
"""Extrae una lista de valores siguiendo una ruta de claves."""
resultado = []
for s in samples:
obj = s.get("printer", {})
for k in key_path:
if not isinstance(obj, dict):
obj = None
break
obj = obj.get(k)
if obj is not None:
resultado.append(obj)
return resultado
def safe_max(lst): return round(max(lst), 3) if lst else None
def safe_min(lst): return round(min(lst), 3) if lst else None
def safe_mean(lst): return round(sum(lst) / len(lst), 3) if lst else None
# ── Tiempo ──
t_inicio = primero.get("wall_time_utc", "")
t_fin = ultimo.get("wall_time_utc", "")
duracion_s = None
try:
dt0 = datetime.fromisoformat(t_inicio)
dt1 = datetime.fromisoformat(t_fin)
duracion_s = round((dt1 - dt0).total_seconds(), 1)
except Exception:
pass
# ── Hotend ──
hotend_temps = vals(["hotend", "temp_c"])
hotend_target = None
for s in samples:
t = s.get("printer", {}).get("hotend", {}).get("target_c")
if t and t > 0:
hotend_target = t
break
# ── Cama ──
cama_temps = vals(["cama", "temp_c"])
cama_target = None
for s in samples:
t = s.get("printer", {}).get("cama", {}).get("target_c")
if t and t > 0:
cama_target = t
break
return {
# Identificacion
"gcode_file": primero.get("printer", {}).get("estado", {}).get("filename", ""),
"n_muestras": len(samples),
"tiempo_inicio_utc": t_inicio,
"tiempo_fin_utc": t_fin,
"duracion_sesion_s": duracion_s,
# Duración de impresion
"duracion_impresion_s": safe_max(vals(["estado", "print_duration_s"])),
"duracion_total_s": safe_max(vals(["estado", "total_duration_s"])),
# Hotend
"hotend_temp_objetivo_c": hotend_target,
"hotend_temp_max_c": safe_max(hotend_temps),
"hotend_temp_min_c": safe_min(hotend_temps),
"hotend_temp_media_c": safe_mean(hotend_temps),
# Cama
"cama_temp_objetivo_c": cama_target,
"cama_temp_max_c": safe_max(cama_temps),
"cama_temp_min_c": safe_min(cama_temps),
"cama_temp_media_c": safe_mean(cama_temps),
# Filamento y progreso
"filamento_usado_mm": safe_max(vals(["estado", "filament_used_mm"])),
"progreso_max_pct": safe_max(vals(["sdcard", "progress_pct"])),
# Movimiento
"velocidad_max_mms": safe_max(vals(["movimiento", "max_velocity_mms"])),
"aceleracion_max_mms2": safe_max(vals(["movimiento", "max_accel_mms2"])),
# Sistema
"cpu_media_pct": safe_mean(vals(["sistema", "cpu_pct"])),
"ram_media_mb": safe_mean(vals(["sistema", "mem_used_mb"])),
"pi_temp_max_c": safe_max(vals(["temperaturas", "pi_c"])),
"einsy_temp_max_c": safe_max(vals(["temperaturas", "einsy_board_c"])),
}
def _generar_tags(metricas: dict) -> list:
""" Genera etiquetas automaticas segun las caracteristicas de la impresion """
gcode = metricas.get("gcode_file", "").lower()
tags = ["impresion-3d", "klipper"]
# Material
for mat in ["pla", "petg", "abs", "tpu", "nylon", "asa", "pc"]:
if mat in gcode:
tags.append(mat.upper())
break
# Modelo impresora
for modelo in ["mk3s", "mk4", "xl", "mini"]:
if modelo in gcode:
tags.append(f"prusa-{modelo}")
break
# Temperatura hotend
t_hotend = metricas.get("hotend_temp_objetivo_c")
if t_hotend:
if t_hotend >= 240:
tags.append("alta-temperatura")
elif t_hotend <= 200:
tags.append("baja-temperatura")
# Temperatura cama
t_cama = metricas.get("cama_temp_objetivo_c")
if t_cama is not None:
if t_cama == 0:
tags.append("sin-cama-caliente")
elif t_cama > 0:
tags.append("cama-caliente")
# Duracion de impresion
dur = metricas.get("duracion_impresion_s")
if dur:
if dur < 600:
tags.append("impresion-corta")
elif dur < 3600:
tags.append("impresion-media")
else:
tags.append("impresion-larga")
# Filamento
fil = metricas.get("filamento_usado_mm")
if fil:
tags.append(f"filamento-{int(fil)}mm")
return tags
def _build_extras(metricas: dict) -> list:
extras = []
for key, value in metricas.items():
if value is None:
continue
if isinstance(value, bool):
extras.append({"key": key, "type": "str", "value": str(value)})
elif isinstance(value, (int, float)):
# Igual que el script: todo numérico va como float
extras.append({"key": key, "type": "float", "value": float(value)})
else:
extras.append({"key": key, "type": "str", "value": str(value)})
return extras
def _is_camera_photo(filename: str) -> bool:
"""Devuelve True si el archivo viene de una subcarpeta de cámara
(cam1/ o cam2/) y tiene formato de imagen. """
parts = Path(filename).parts
ext = Path(filename).suffix.lower()
return ext in IMAGE_EXTENSIONS and any(p in CAMERA_DIRS for p in parts)
def _get_or_create_collection(host: str, pat: str, year_title: str):
"""Busca la colección del año por título exacto.
Si no existe, la crea."""
import requests as req
session = req.Session()
session.headers["Authorization"] = f"Bearer {pat}"
session.verify = False # ajustar segun entorno
# Busqueda
r = session.get(
f"{host}/api/v1/collections",
params={"query": f'"{year_title}"', "per_page": 10},
)
if r.ok:
for col in r.json().get("items", []):
if col.get("title") == year_title:
return col, False
# Creacion
identifier = _make_identifier(year_title)
r = session.post(
f"{host}/api/v1/collections",
json={
"title": year_title,
"identifier": identifier,
"description": f"Sesiones de impresion 3D del año {year_title}",
},
)
if r.status_code not in (200, 201):
raise RuntimeError(f"Error creando coleccion: {r.text}")
return r.json(), True
def _get_or_create_record(host: str, pat: str, identifier: str,
title: str, description: str,
tags: list, extras: list) -> dict:
"""Busca un record por codigo de identificacion. Si no existe lo crea."""
import requests as req
session = req.Session()
session.headers["Authorization"] = f"Bearer {pat}"
session.verify = False
# Busqueda por codigo de identificacion
r = session.get(
f"{host}/api/v1/records",
params={"filter[identifier]": identifier, "per_page": 5},
)
if r.ok:
for item in r.json().get("items", []):
if item.get("identifier") == identifier:
return item
# No existe: crear
body = {
"title": title,
"identifier": identifier,
"description": description,
"tags": tags,
"extras": extras,
}
r = session.post(f"{host}/api/v1/records", json=body)
if r.status_code not in (200, 201):
raise RuntimeError(f"Error creando record ({r.status_code}): {r.text}")
return r.json()
def _link_record_to_collection(host: str, pat: str, record_id: int, collection_id: int):
"""Vincula un record a una coleccion via API REST."""
import requests as req
session = req.Session()
session.headers["Authorization"] = f"Bearer {pat}"
session.verify = False
r = session.post(
f"{host}/api/v1/collections/{collection_id}/records",
json={"id": record_id},
)
if r.status_code not in (200, 201):
print(f"warn linking collection ({r.status_code}): {r.text}")
def _upload_file_to_record(host: str, pat: str, record_id: int,
filename: str, data: bytes):
"""Sube un archivo al record:
1. POST /records/{id}/uploads → registra nombre y tamaño
2. PUT /records/{id}/uploads/{upload_id} → envía el binario
Firma corregida: recibe los datos ya leídos (bytes) en lugar de
una Path, porque los ficheros llegan como streams de Flask.
"""
import mimetypes
import requests as req
session = req.Session()
session.headers["Authorization"] = f"Bearer {pat}"
session.verify = False
size = len(data)
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
# Paso 1: registrar
r = session.post(
f"{host}/api/v1/records/{record_id}/uploads",
json={"name": filename, "size": size, "mimetype": mime},
)
if r.status_code == 409:
# El archivo ya existe en el record — omitir
print(f" ⚠ '{filename}' ya existe, omitiendo.")
return
if r.status_code not in (200, 201):
raise RuntimeError(f"upload init failed ({r.status_code}): {r.text}")
upload_id = r.json()["id"]
# Paso 2: enviar binario
r2 = session.put(
f"{host}/api/v1/records/{record_id}/uploads/{upload_id}",
data=data,
headers={
"Content-Type": "application/octet-stream",
"Kadi-Chunk-Index": "0",
"Kadi-Chunk-Size": str(size),
},
)
if r2.status_code not in (200, 201):
raise RuntimeError(f"upload data failed ({r2.status_code}): {r2.text}")
# ── Endpoint principal ─────────────────────────────────────────────────────
@api_bp.post("/biaprint/folder")
@scopes_required("record.create")
def upload_folder():
"""Recibe una sesion de impresion 3D de la Biaprint y registra en Kadi4Mat:
1. Analiza el .json si viene incluido
2. Calcula metricas, tags y extras
3. Busca o crea la coleccion del año actual
4. Crea el Record con descripcion, tags y extras
5. Vincula el Record a la coleccion
6. Sube todos los archivos al Record,
excluyendo fotos que vengan de subcarpetas cam1/ o cam2/
"""
# ── Campos obligatorios ────────────────────────────────────────────────
folder_name = (request.form.get("folder_name") or "").strip()
if not folder_name:
return json_error_response(400, description="El campo 'folder_name' es obligatorio.")
uploaded_files = request.files.getlist("files[]")
if not uploaded_files:
return json_error_response(400, description="No se recibio ningun archivo en 'files[]'.")
token = get_access_token()
# ── Config del plugin ──────────────────────────────────────────────────
cfg = get_plugin_config(PLUGIN_NAME)
host = (cfg.get("host", "https://localhost") if cfg else "https://localhost").rstrip("/")
# Extraer el PAT directamente del header Authorization.
_auth_header = request.headers.get("Authorization", "")
if _auth_header.lower().startswith("bearer "):
pat = _auth_header[7:].strip()
else:
return json_error_response(401, description="Header Authorization Bearer requerido.")
# ── Leer todos los archivos en memoria ─────────────────
file_buffers = {} # filename → bytes
for f in uploaded_files:
if f.filename:
file_buffers[f.filename] = f.read()
# ── Analizar archivo json ────────────────────────────────
metricas = {}
tags = []
extras = []
for filename, data in file_buffers.items():
if Path(filename).name == "session_data.json":
try:
samples = json.loads(data.decode("utf-8"))
if isinstance(samples, list):
metricas = _analizar_sesion(samples)
tags = _generar_tags(metricas)
extras = _build_extras(metricas)
except Exception:
pass
break
# ── Descripcion ─────────────────────────────────────────────
description = (request.form.get("description") or "").strip()
if not description and metricas:
gcode = metricas.get("gcode_file", "")
fecha = (metricas.get("tiempo_inicio_utc") or "")[:10]
dur = metricas.get("duracion_impresion_s", "?")
fil = metricas.get("filamento_usado_mm", "?")
description = (
f"Sesion de impresion 3D registrada el {fecha}. "
f"Archivo: {gcode}. "
f"Duración de impresion: {dur} s. "
f"Filamento usado: {fil} mm."
)
# ── Coleccion ────
year_title = str(datetime.now().year)
try:
collection_data, col_created = _get_or_create_collection(host, pat, year_title)
collection_id = collection_data["id"]
except Exception as exc:
return json_error_response(500, description=f"Error con la colección: {exc}")
# ── Crear Record por API REST ──────────────────────────────────────────
identifier = _make_identifier(folder_name)
try:
record_data = _get_or_create_record(
host=host,
pat=pat,
identifier=identifier,
title=folder_name,
description=description,
tags=tags,
extras=extras,
)
record_id = record_data["id"]
record_identifier = record_data["identifier"]
except Exception as exc:
return json_error_response(500, description=f"Error creando el Record: {exc}")
# ── Vincular el record a coleccion ───────────────────────────────────────────────
_link_record_to_collection(host, pat, record_id, collection_id)
# ── Subir archivos (excluyendo fotos de cam1/cam2) ─────────────────────
saved, errors = [], []
for filename, data in file_buffers.items():
if _is_camera_photo(filename):
continue
try:
safe_name = Path(filename).name
_upload_file_to_record(host, pat, record_id, safe_name, data)
saved.append(filename)
except Exception as exc:
errors.append({"file": filename, "error": str(exc)})
# ── Respuesta ──────────────────────────────────────────────────────────
body = {
"record_identifier": record_identifier,
"record_id": record_id,
"collection_title": year_title,
"collection_id": collection_id,
"collection_created": col_created,
"files_saved": saved,
}
if errors:
body["warnings"] = errors
return json_response(207 if errors else 201, body)
# ── Hooks ──────────────────────────────────────────────────────────────────
@hookimpl
def kadi_get_blueprints():
return plugin_bp
@hookimpl
def kadi_post_app_initialization(app):
app.logger.info(
"biaprint: plugin activo. "
"Endpoint: POST /api/biaprint/folder"
)
\ No newline at end of file
[project]
name = "biaprint"
version = "0.1.0"
description = "Kadi4Mat plugin for 3D printing sessions"
requires-python = ">=3.10"
dependencies = [
"kadi"
]
[project.entry-points."kadi_plugins"]
biaprint = "kadi_plugin_biaprint.plugin"
from setuptools import setup, find_packages
setup(
name="kadi_plugin_biaprint",
version="0.1.0",
packages=find_packages(),
entry_points={
"kadi_plugins": [
"biaprint = kadi_plugin_biaprint.plugin",
],
},
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment