#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ =============================================================================== NEXUS 3D ESTUDIO V7 — PUENTE NEXUS (Impresoras WiFi) ------------------------------------------------------------------------------- Corre este programa en una PC, laptop o Raspberry Pi conectada a la MISMA red WiFi que tus impresoras. El puente: • Escanea tu red y detecta impresoras (Klipper/Moonraker, OctoPrint, PrusaLink, Bambu Lab modo LAN y Elegoo SDCP). • Envía su estado (temperaturas, progreso, materiales/AMS, volumen) a tu panel NEXUS en Hostinger, para verlo desde el celular, tablet o PC. • Recibe la cola de impresión: subir archivos, iniciar, pausar, continuar, cancelar, copias y trabajos programados. Solo usa la librería estándar de Python 3.8+ (no instala nada). FORMA MÁS FÁCIL: en tu panel (Centro de Impresión > Conexión WiFi) descarga el puente personalizado: Windows (doble clic), Android (app Pydroid 3), Mac o Linux. Ya trae tu token y tu servidor, no tienes que escribir nada. USO MANUAL: python nexus_puente.py (la primera vez te pide servidor y token) python nexus_puente.py --token TU_TOKEN --server https://tusitio/api_nexus3d.php python nexus_puente.py --scan (solo escanear la red y mostrar) En Windows puedes crear un acceso directo en la carpeta "Inicio" (Win+R → shell:startup) para que arranque solo al prender la PC. =============================================================================== """ import base64 import ftplib import http.client import ipaddress import json import os import platform import queue import shlex import socket import ssl import struct import subprocess import sys import tempfile import threading import time import urllib.error import urllib.parse import urllib.request from concurrent.futures import ThreadPoolExecutor VERSION = "7.7.0" # Se rellenan solos cuando descargas el puente personalizado desde tu panel NEXUS EMBED_SERVER = "" EMBED_TOKEN = "" DEFAULT_SERVER = "https://nexus3d.pro/api_nexus3d.php" CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "nexus_puente.json") UNVERIFIED = ssl._create_unverified_context() def log(*a): print(time.strftime("[%H:%M:%S]"), *a, flush=True) # ============================================================================= # CONFIGURACIÓN # ============================================================================= def load_config(): cfg = {"server": DEFAULT_SERVER, "token": "", "slicer_path": "", "slicer_profile": "", "slicer_args": '"{slicer}" --export-gcode --load "{profile}" -o "{out}" "{inp}"', "extra_subnets": []} if os.path.isfile(CONFIG_FILE): try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: cfg.update(json.load(f)) except Exception as e: log("No pude leer", CONFIG_FILE, e) if EMBED_SERVER: cfg["server"] = EMBED_SERVER if EMBED_TOKEN and not cfg.get("token"): cfg["token"] = EMBED_TOKEN if EMBED_TOKEN and cfg.get("token") != EMBED_TOKEN and EMBED_SERVER: cfg["token"] = EMBED_TOKEN args = sys.argv[1:] for i, a in enumerate(args): if a == "--token" and i + 1 < len(args): cfg["token"] = args[i + 1].strip() if a == "--server" and i + 1 < len(args): cfg["server"] = args[i + 1].strip() if "--scan" not in args and not cfg.get("token") and sys.stdin and sys.stdin.isatty(): print("\n=== PRIMERA CONFIGURACIÓN DEL PUENTE NEXUS ===") print("En tu panel: Centro de Impresión → Puentes → 'Crear puente' y copia el TOKEN.") s = input("URL de tu api_nexus3d.php [" + cfg["server"] + "]: ").strip() if s: cfg["server"] = s cfg["token"] = input("Token del puente: ").strip() save_config(cfg) return cfg def save_config(cfg): try: with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=2, ensure_ascii=False) except Exception as e: log("No pude guardar la configuración:", e) # ============================================================================= # HTTP BÁSICO (LAN y servidor) # ============================================================================= def http_req(method, url, headers=None, body=None, timeout=8, digest=None): """Devuelve (status, bytes, headers). digest=(usuario, clave) para PrusaLink.""" headers = dict(headers or {}) req = urllib.request.Request(url, data=body, method=method, headers=headers) handlers = [] if digest: pm = urllib.request.HTTPPasswordMgrWithDefaultRealm() pm.add_password(None, url, digest[0], digest[1]) handlers.append(urllib.request.HTTPDigestAuthHandler(pm)) if url.startswith("https"): handlers.append(urllib.request.HTTPSHandler(context=UNVERIFIED)) opener = urllib.request.build_opener(*handlers) try: with opener.open(req, timeout=timeout) as r: return r.status, r.read(), dict(r.headers) except urllib.error.HTTPError as e: try: data = e.read() except Exception: data = b"" return e.code, data, dict(e.headers or {}) def jreq(method, url, headers=None, payload=None, timeout=8, digest=None): h = dict(headers or {}) body = None if payload is not None: body = json.dumps(payload).encode() h["Content-Type"] = "application/json" st, data, _ = http_req(method, url, h, body, timeout, digest) try: return st, json.loads(data.decode("utf-8", "replace") or "null") except Exception: return st, None def multipart(fields, file_field, file_name, file_bytes): boundary = "----NexusPuente" + os.urandom(8).hex() parts = [] for k, v in fields.items(): parts.append(("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n" % (boundary, k, v)).encode()) parts.append(("--%s\r\nContent-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\nContent-Type: application/octet-stream\r\n\r\n" % (boundary, file_field, file_name)).encode()) parts.append(file_bytes) parts.append(("\r\n--%s--\r\n" % boundary).encode()) return b"".join(parts), "multipart/form-data; boundary=" + boundary class Server: def __init__(self, cfg): self.cfg = cfg self.ctx = ssl.create_default_context() def _open(self, req, timeout): try: return urllib.request.urlopen(req, timeout=timeout, context=self.ctx) except urllib.error.URLError as e: if isinstance(getattr(e, "reason", None), ssl.SSLError) or "CERTIFICATE" in str(e).upper(): self.ctx = UNVERIFIED # equipos sin certificados raíz actualizados return urllib.request.urlopen(req, timeout=timeout, context=self.ctx) raise def poll(self, payload): url = self.cfg["server"] + "?action=bridge_poll" req = urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST", headers={"Content-Type": "application/json", "X-Bridge-Token": self.cfg["token"], "User-Agent": "NexusPuente/" + VERSION}) with self._open(req, 25) as r: return json.loads(r.read().decode("utf-8")) def download_job_file(self, job_id, dest): url = self.cfg["server"] + "?action=bridge_file&job_id=" + str(job_id) req = urllib.request.Request(url, headers={"X-Bridge-Token": self.cfg["token"], "User-Agent": "NexusPuente/" + VERSION}) with self._open(req, 300) as r: ctype = r.headers.get("Content-Type", "") data = r.read() if ctype.startswith("application/json") and data[:1] == b"{": raise Exception(json.loads(data.decode()).get("error", "Error al descargar")) with open(dest, "wb") as f: f.write(data) return dest # ============================================================================= # CONTROLADORES DE IMPRESORAS # ============================================================================= def norm_state(s): return s if s in ("offline", "idle", "printing", "paused", "finished", "error", "busy") else "busy" class BasePrinter: def __init__(self, cfg): self.cfg = cfg self.host = (cfg.get("host") or "").strip() self.key = cfg.get("api_key") or "" self.user = cfg.get("username") or "" def close(self): pass class Moonraker(BasePrinter): """Klipper + Moonraker: Creality K1/K2 (root/Helper), Elegoo Neptune 4, Sovol SV06+/SV08, Voron, Anycubic Kobra (Kobra OS)…""" def __init__(self, cfg): super().__init__(cfg) self.base = "http://%s:%s" % (self.host, cfg.get("port") or 7125) self.sensors = None def h(self): return {"X-Api-Key": self.key} if self.key else {} def status(self): if self.sensors is None: st, j = jreq("GET", self.base + "/printer/objects/list", self.h(), timeout=5) objs = (j or {}).get("result", {}).get("objects", []) if st == 200 else [] self.sensors = [o for o in objs if o.startswith("filament_switch_sensor") or o.startswith("filament_motion_sensor")] q = ["print_stats", "virtual_sdcard", "extruder", "heater_bed", "toolhead", "display_status", "webhooks"] + self.sensors st, j = jreq("GET", self.base + "/printer/objects/query?" + "&".join(urllib.parse.quote(x) for x in q), self.h(), timeout=6) if st != 200 or not j: return {"state": "offline", "message": "Moonraker no responde (HTTP %s)" % st} s = j.get("result", {}).get("status", {}) ps = s.get("print_stats", {}) wh = s.get("webhooks", {}) raw = ps.get("state", "standby") state = {"printing": "printing", "paused": "paused", "complete": "finished", "error": "error"}.get(raw, "idle") if wh.get("state") and wh.get("state") != "ready": state = "error" prog = s.get("display_status", {}).get("progress") or s.get("virtual_sdcard", {}).get("progress") or 0 dur = ps.get("print_duration") or 0 left = (dur / prog - dur) if prog and prog > 0.01 else None th = s.get("toolhead", {}) build = None if th.get("axis_maximum") and th.get("axis_minimum"): mx, mn = th["axis_maximum"], th["axis_minimum"] build = [round(mx[0] - max(0, mn[0]), 1), round(mx[1] - max(0, mn[1]), 1), round(mx[2] - max(0, mn[2]), 1)] mats = [] for sname in self.sensors or []: sd = s.get(sname, {}) mats.append({"name": sname.split(" ", 1)[-1], "present": bool(sd.get("filament_detected")), "enabled": bool(sd.get("enabled", True))}) info = ps.get("info") or {} return {"state": state, "raw_state": raw, "progress": round(prog * 100, 1), "file": ps.get("filename") or "", "temps": {"nozzle": s.get("extruder", {}).get("temperature"), "nozzle_target": s.get("extruder", {}).get("target"), "bed": s.get("heater_bed", {}).get("temperature"), "bed_target": s.get("heater_bed", {}).get("target")}, "elapsed_s": int(dur), "time_left_s": int(left) if left else None, "layer": info.get("current_layer"), "total_layers": info.get("total_layer"), "materials": mats, "build": build, "message": ps.get("message") or wh.get("state_message", "")} def upload_and_print(self, path, name): with open(path, "rb") as f: body, ct = multipart({"root": "gcodes", "print": "true"}, "file", name, f.read()) h = self.h(); h["Content-Type"] = ct st, data, _ = http_req("POST", self.base + "/server/files/upload", h, body, timeout=600) if st not in (200, 201): raise Exception("Moonraker rechazó el archivo (HTTP %s) %s" % (st, data[:200])) def pause(self): http_req("POST", self.base + "/printer/print/pause", self.h(), b"") def resume(self): http_req("POST", self.base + "/printer/print/resume", self.h(), b"") def cancel(self): http_req("POST", self.base + "/printer/print/cancel", self.h(), b"") class OctoPrint(BasePrinter): def __init__(self, cfg): super().__init__(cfg) self.base = "http://%s:%s" % (self.host, cfg.get("port") or 80) self.build = None def h(self): return {"X-Api-Key": self.key} def status(self): st, job = jreq("GET", self.base + "/api/job", self.h(), timeout=6) if st in (401, 403): return {"state": "error", "message": "API key de OctoPrint inválida"} if st != 200 or not job: return {"state": "offline", "message": "OctoPrint no responde (HTTP %s)" % st} raw = job.get("state", "") low = raw.lower() if low.startswith("printing"): state = "printing" elif low.startswith("paus"): state = "paused" elif low.startswith("operational"): state = "idle" elif "error" in low: state = "error" elif "offline" in low or "closed" in low: state = "offline" else: state = "busy" st2, pr = jreq("GET", self.base + "/api/printer", self.h(), timeout=6) temps = {} if st2 == 200 and pr: t = pr.get("temperature", {}) temps = {"nozzle": t.get("tool0", {}).get("actual"), "nozzle_target": t.get("tool0", {}).get("target"), "bed": t.get("bed", {}).get("actual"), "bed_target": t.get("bed", {}).get("target")} if self.build is None: st3, prof = jreq("GET", self.base + "/api/printerprofiles", self.h(), timeout=6) self.build = [] for p in ((prof or {}).get("profiles") or {}).values(): if p.get("default") or p.get("current"): v = p.get("volume", {}) self.build = [v.get("width"), v.get("depth"), v.get("height")] prog = job.get("progress") or {} return {"state": state, "raw_state": raw, "progress": round(prog.get("completion") or 0, 1), "file": ((job.get("job") or {}).get("file") or {}).get("name") or "", "temps": temps, "elapsed_s": prog.get("printTime"), "time_left_s": prog.get("printTimeLeft"), "build": self.build or None, "materials": []} def upload_and_print(self, path, name): with open(path, "rb") as f: body, ct = multipart({"select": "true", "print": "true"}, "file", name, f.read()) h = self.h(); h["Content-Type"] = ct st, data, _ = http_req("POST", self.base + "/api/files/local", h, body, timeout=600) if st not in (200, 201): raise Exception("OctoPrint rechazó el archivo (HTTP %s) %s" % (st, data[:200])) def _job(self, payload): jreq("POST", self.base + "/api/job", self.h(), payload) def pause(self): self._job({"command": "pause", "action": "pause"}) def resume(self): self._job({"command": "pause", "action": "resume"}) def cancel(self): self._job({"command": "cancel"}) class PrusaLink(BasePrinter): """Prusa MK4/MK4S, MK3.9, MINI+, XL, CORE One con PrusaLink.""" def __init__(self, cfg): super().__init__(cfg) self.base = "http://%s:%s" % (self.host, cfg.get("port") or 80) self.job_id = None def auth(self): if self.user: return {}, (self.user, self.key) return {"X-Api-Key": self.key}, None def status(self): h, dg = self.auth() st, j = jreq("GET", self.base + "/api/v1/status", h, timeout=6, digest=dg) if st == 401: return {"state": "error", "message": "Usuario/clave de PrusaLink incorrectos"} if st != 200 or not j: return {"state": "offline", "message": "PrusaLink no responde (HTTP %s)" % st} p = j.get("printer", {}) jb = j.get("job") or {} self.job_id = jb.get("id") raw = p.get("state", "") state = {"PRINTING": "printing", "PAUSED": "paused", "FINISHED": "finished", "IDLE": "idle", "READY": "idle", "STOPPED": "idle", "ERROR": "error", "ATTENTION": "error", "BUSY": "busy"}.get(raw, "busy") fname = "" if self.job_id: st2, jj = jreq("GET", self.base + "/api/v1/job", h, timeout=6, digest=dg) if st2 == 200 and jj: fname = (jj.get("file") or {}).get("display_name") or (jj.get("file") or {}).get("name") or "" return {"state": state, "raw_state": raw, "progress": jb.get("progress") or 0, "file": fname, "temps": {"nozzle": p.get("temp_nozzle"), "nozzle_target": p.get("target_nozzle"), "bed": p.get("temp_bed"), "bed_target": p.get("target_bed")}, "elapsed_s": jb.get("time_printing"), "time_left_s": jb.get("time_remaining"), "materials": [], "build": None} def upload_and_print(self, path, name): h, dg = self.auth() with open(path, "rb") as f: data = f.read() h.update({"Content-Type": "application/octet-stream", "Print-After-Upload": "?1", "Overwrite": "?1"}) last = None for storage in ("usb", "local"): st, resp, _ = http_req("PUT", self.base + "/api/v1/files/%s/%s" % (storage, urllib.parse.quote(name)), h, data, timeout=600, digest=dg) if st in (200, 201, 204): return last = "HTTP %s %s" % (st, resp[:200]) raise Exception("PrusaLink rechazó el archivo: " + str(last)) def _jobcmd(self, method, suffix): if not self.job_id: self.status() if self.job_id: h, dg = self.auth() http_req(method, self.base + "/api/v1/job/%s%s" % (self.job_id, suffix), h, b"", digest=dg) def pause(self): self._jobcmd("PUT", "/pause") def resume(self): self._jobcmd("PUT", "/resume") def cancel(self): self._jobcmd("DELETE", "") # ---------------- Bambu Lab (modo LAN): MQTT + FTPS ---------------- def _mqtt_str(s): b = s.encode() return struct.pack("!H", len(b)) + b def _mqtt_len(n): out = b"" while True: d = n % 128 n //= 128 if n: d |= 0x80 out += bytes([d]) if not n: return out class ImplicitFTPTLS(ftplib.FTP_TLS): """FTPS implícito (puerto 990) que usan las impresoras Bambu Lab.""" def __init__(self, *a, **k): super().__init__(*a, **k) self._sock = None @property def sock(self): return self._sock @sock.setter def sock(self, value): if value is not None and not isinstance(value, ssl.SSLSocket): value = self.context.wrap_socket(value) self._sock = value def ntransfercmd(self, cmd, rest=None): conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest) if self._prot_p: conn = self.context.wrap_socket(conn, server_hostname=self.host, session=self.sock.session) return conn, size class Bambu(BasePrinter): """Bambu Lab X1/P1/A1/H2 en 'Modo LAN' (código de acceso de 8 dígitos + número de serie).""" def __init__(self, cfg): super().__init__(cfg) self.serial = (cfg.get("serial") or "").strip() self.data = {} self.ok = False self.err = "" self.lock = threading.Lock() self.sock = None self.stop = False self.seq = 1 threading.Thread(target=self._loop, daemon=True).start() def close(self): self.stop = True try: self.sock and self.sock.close() except Exception: pass def _send(self, b): with self.lock: self.sock.sendall(b) def _publish(self, obj): if not self.sock: raise Exception("Bambu no conectada") self.seq += 1 for v in obj.values(): if isinstance(v, dict): v["sequence_id"] = str(self.seq) payload = json.dumps(obj).encode() body = _mqtt_str("device/%s/request" % self.serial) + payload self._send(b"\x30" + _mqtt_len(len(body)) + body) def _read_exact(self, n): buf = b"" while len(buf) < n: chunk = self.sock.recv(n - len(buf)) if not chunk: raise Exception("conexión cerrada") buf += chunk return buf def _read_packet(self): h = self._read_exact(1)[0] mult, length = 1, 0 while True: d = self._read_exact(1)[0] length += (d & 127) * mult mult *= 128 if not d & 128: break return h, self._read_exact(length) if length else b"" def _merge(self, dst, src): for k, v in src.items(): if isinstance(v, dict) and isinstance(dst.get(k), dict): self._merge(dst[k], v) else: dst[k] = v def _loop(self): while not self.stop: try: if not self.serial or not self.key: self.err = "Falta número de serie o código de acceso" time.sleep(10) continue raw = socket.create_connection((self.host, int(self.cfg.get("port") or 8883)), timeout=10) self.sock = UNVERIFIED.wrap_socket(raw, server_hostname=self.host) vh = _mqtt_str("MQTT") + bytes([4, 0xC2]) + struct.pack("!H", 60) pl = _mqtt_str("nexus_" + os.urandom(3).hex()) + _mqtt_str("bblp") + _mqtt_str(self.key) self._send(b"\x10" + _mqtt_len(len(vh) + len(pl)) + vh + pl) t, body = self._read_packet() if t >> 4 != 2 or (len(body) > 1 and body[1] != 0): raise Exception("Código de acceso rechazado (revisa Modo LAN y el código)") sub = struct.pack("!H", 1) + _mqtt_str("device/%s/report" % self.serial) + b"\x00" self._send(b"\x82" + _mqtt_len(len(sub)) + sub) self.ok, self.err = True, "" self._publish({"pushing": {"command": "pushall"}}) self.sock.settimeout(1.0) last_ping = time.time() while not self.stop: if time.time() - last_ping > 30: self._send(b"\xc0\x00") last_ping = time.time() try: t, body = self._read_packet() except socket.timeout: continue if t >> 4 == 3: tl = struct.unpack("!H", body[:2])[0] off = 2 + tl + (2 if (t >> 1) & 3 else 0) try: msg = json.loads(body[off:].decode("utf-8", "replace")) self._merge(self.data, msg) except Exception: pass except Exception as e: self.ok = False self.err = str(e) try: self.sock and self.sock.close() except Exception: pass self.sock = None time.sleep(10) def status(self): if not self.ok: return {"state": "offline", "message": "Bambu: " + (self.err or "conectando...")} p = self.data.get("print", {}) raw = p.get("gcode_state", "") state = {"RUNNING": "printing", "PREPARE": "printing", "SLICING": "busy", "PAUSE": "paused", "FINISH": "finished", "FAILED": "error", "IDLE": "idle"}.get(raw, "idle" if not raw else "busy") mats = [] for unit in (p.get("ams", {}) or {}).get("ams", []) or []: for tr in unit.get("tray", []) or []: if tr.get("tray_type"): mats.append({"name": "AMS %s-%s" % (int(unit.get("id", 0)) + 1, int(tr.get("id", 0)) + 1), "type": tr.get("tray_type"), "color": "#" + (tr.get("tray_color") or "FFFFFFFF")[:6], "remain": tr.get("remain"), "present": True}) else: mats.append({"name": "AMS %s-%s" % (int(unit.get("id", 0)) + 1, int(tr.get("id", 0)) + 1), "present": False}) vt = p.get("vt_tray") or {} if vt.get("tray_type"): mats.append({"name": "Carrete externo", "type": vt.get("tray_type"), "color": "#" + (vt.get("tray_color") or "FFFFFFFF")[:6], "remain": vt.get("remain"), "present": True}) left = p.get("mc_remaining_time") return {"state": state, "raw_state": raw, "progress": p.get("mc_percent") or 0, "file": p.get("subtask_name") or p.get("gcode_file") or "", "temps": {"nozzle": p.get("nozzle_temper"), "nozzle_target": p.get("nozzle_target_temper"), "bed": p.get("bed_temper"), "bed_target": p.get("bed_target_temper"), "chamber": p.get("chamber_temper")}, "time_left_s": int(left) * 60 if isinstance(left, (int, float)) else None, "layer": p.get("layer_num"), "total_layers": p.get("total_layer_num"), "materials": mats, "message": "Error HMS %s" % p.get("print_error") if p.get("print_error") else "", "has_ams": bool((p.get("ams") or {}).get("ams"))} def upload_and_print(self, path, name): ftp = ImplicitFTPTLS(context=UNVERIFIED) ftp.connect(self.host, 990, timeout=30) ftp.login("bblp", self.key) ftp.prot_p() with open(path, "rb") as f: conn = ftp.transfercmd("STOR " + name) while True: chunk = f.read(65536) if not chunk: break conn.sendall(chunk) conn.close() try: ftp.voidresp() except Exception: pass try: ftp.quit() except Exception: pass st = self.status() if name.lower().endswith(".3mf"): self._publish({"print": {"command": "project_file", "param": "Metadata/plate_1.gcode", "url": "file:///sdcard/" + name, "subtask_name": name, "md5": "", "timelapse": False, "bed_type": "auto", "bed_levelling": True, "flow_cali": False, "vibration_cali": False, "layer_inspect": False, "use_ams": bool(st.get("has_ams")), "profile_id": "0", "project_id": "0", "subtask_id": "0", "task_id": "0"}}) else: self._publish({"print": {"command": "gcode_file", "param": "/sdcard/" + name}}) def pause(self): self._publish({"print": {"command": "pause"}}) def resume(self): self._publish({"print": {"command": "resume"}}) def cancel(self): self._publish({"print": {"command": "stop"}}) def light(self, on): self._publish({"system": {"command": "ledctrl", "led_node": "chamber_light", "led_mode": "on" if on else "off", "led_on_time": 500, "led_off_time": 500, "loop_times": 0, "interval_time": 0}}) DRIVERS = {"moonraker": Moonraker, "octoprint": OctoPrint, "prusalink": PrusaLink, "bambu": Bambu} # ============================================================================= # ESCÁNER DE RED WIFI # ============================================================================= def local_ip(): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(("8.8.8.8", 80)) return s.getsockname()[0] except Exception: return "127.0.0.1" finally: s.close() def port_open(host, port, timeout=0.45): try: with socket.create_connection((host, port), timeout=timeout): return True except Exception: return False def probe_host(ip): found = [] try: if port_open(ip, 7125): st, j = jreq("GET", "http://%s:7125/server/info" % ip, timeout=3) if st in (200, 401, 403): name = "" st2, j2 = jreq("GET", "http://%s:7125/printer/info" % ip, timeout=3) if st2 == 200 and j2: name = (j2.get("result") or {}).get("hostname", "") found.append({"type": "moonraker", "host": ip, "port": 7125, "name": name or "Klipper " + ip, "note": "Klipper/Moonraker" + (" (requiere API key)" if st in (401, 403) else "")}) for port in (80, 5000): if not port_open(ip, port): continue st, body, hdr = http_req("GET", "http://%s:%s/" % (ip, port), timeout=3) text = body[:20000].decode("utf-8", "replace").lower() server = (hdr.get("Server") or hdr.get("server") or "").lower() if "octoprint" in text or "octoprint" in server: found.append({"type": "octoprint", "host": ip, "port": port, "name": "OctoPrint " + ip, "note": "Necesita API key (Ajustes → API)"}) elif "prusalink" in text or "prusalink" in server or "prusa" in server: found.append({"type": "prusalink", "host": ip, "port": port, "name": "Prusa " + ip, "note": "Usuario 'maker' + clave de PrusaLink"}) elif ("mainsail" in text or "fluidd" in text) and not any(f["type"] == "moonraker" for f in found): found.append({"type": "moonraker", "host": ip, "port": 7125, "name": "Klipper " + ip, "note": "Interfaz Mainsail/Fluidd detectada"}) elif "creality" in text: found.append({"type": "moonraker", "host": ip, "port": 7125, "name": "Creality " + ip, "note": "Creality: el control requiere Moonraker (root / Creality Helper Script)"}) if port_open(ip, 8883) and not any(f["host"] == ip for f in found): found.append({"type": "bambu", "host": ip, "port": 8883, "name": "Bambu Lab " + ip, "note": "Activa Modo LAN; necesitas código de acceso y número de serie"}) except Exception: pass return found def ssdp_bambu(seconds=6): """Las Bambu anuncian su serie por UDP 2021 (SSDP).""" out = {} try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(("", 2021)) s.settimeout(1) end = time.time() + seconds while time.time() < end: try: data, addr = s.recvfrom(4096) except socket.timeout: continue txt = data.decode("utf-8", "replace") if "bambu" not in txt.lower(): continue info = {} for line in txt.splitlines(): if ":" in line: k, v = line.split(":", 1) info[k.strip().lower()] = v.strip() ip = info.get("location", addr[0]) out[ip] = {"type": "bambu", "host": ip, "port": 8883, "serial": info.get("usn", ""), "model": info.get("devmodel.bambu.com", ""), "name": info.get("devname.bambu.com", "Bambu Lab " + ip), "note": "Bambu Lab detectada (necesita código de acceso LAN)"} s.close() except Exception: pass return list(out.values()) def sdcp_elegoo(seconds=3): """Elegoo Centauri / Saturn / Mars (protocolo SDCP).""" out = [] try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) s.settimeout(1) s.sendto(b"M99999", ("255.255.255.255", 3000)) end = time.time() + seconds while time.time() < end: try: data, addr = s.recvfrom(4096) except socket.timeout: continue try: j = json.loads(data.decode()) at = (j.get("Data") or {}).get("Attributes") or j.get("Data") or {} out.append({"type": "sdcp", "host": at.get("MainboardIP", addr[0]), "port": 3030, "name": at.get("Name") or at.get("MachineName") or "Elegoo", "model": at.get("MachineName", ""), "note": "Elegoo SDCP detectada (monitoreo desde su app; control directo en una versión futura)"}) except Exception: pass s.close() except Exception: pass return out def scan_network(extra_subnets=None): ip = local_ip() nets = [] try: nets.append(ipaddress.ip_network(ip + "/24", strict=False)) except Exception: pass for sn in extra_subnets or []: try: nets.append(ipaddress.ip_network(sn, strict=False)) except Exception: pass hosts = [] for n in nets: hosts += [str(h) for h in n.hosts()] log("Escaneando %d direcciones en tu red (%s)..." % (len(hosts), ", ".join(str(n) for n in nets))) found = [] ssdp_res = [] t = threading.Thread(target=lambda: ssdp_res.extend(ssdp_bambu(8)), daemon=True) t.start() with ThreadPoolExecutor(max_workers=96) as ex: for res in ex.map(probe_host, hosts): found.extend(res) found.extend(sdcp_elegoo()) t.join(10) by_host = {(f["host"], f["type"]): f for f in found} for b in ssdp_res: key = (b["host"], "bambu") if key in by_host: by_host[key].update({k: v for k, v in b.items() if v}) else: by_host[key] = b found = list(by_host.values()) log("Escaneo terminado: %d impresora(s) detectada(s)." % len(found)) for f in found: log(" •", f["type"], f["host"], f.get("name", ""), f.get("serial", "")) return found # ============================================================================= # REBANADOR OPCIONAL (STL → G-code con PrusaSlicer/SuperSlicer CLI) # ============================================================================= def slice_if_needed(cfg, path, ptype): ext = path.rsplit(".", 1)[-1].lower() if ext in ("gcode", "bgcode"): return path if ext == "3mf" and ptype == "bambu": return path if not cfg.get("slicer_path") or not cfg.get("slicer_profile"): raise Exception("Este archivo es .%s: la impresora necesita G-code. Rebánalo en tu slicer (Cura/Orca/PrusaSlicer/Bambu Studio) y " "sube el .gcode, o configura 'slicer_path' y 'slicer_profile' en nexus_puente.json." % ext) out = path.rsplit(".", 1)[0] + ".gcode" cmd = cfg.get("slicer_args").format(slicer=cfg["slicer_path"], profile=cfg["slicer_profile"], out=out, inp=path) log("Rebanando:", cmd) r = subprocess.run(cmd if os.name == "nt" else shlex.split(cmd), shell=(os.name == "nt"), capture_output=True, text=True, timeout=900) if r.returncode != 0 or not os.path.isfile(out): raise Exception("El slicer falló: " + (r.stderr or r.stdout)[-400:]) return out # ============================================================================= # CICLO PRINCIPAL # ============================================================================= class Bridge: def __init__(self, cfg): self.cfg = cfg self.server = Server(cfg) self.printers = {} # id -> (cfg, driver) self.states = {} # id -> último estado self.active = {} # id -> {"job_id", "t0", "seen_printing"} self.results = [] self.events = [] self.discovered = None self.lock = threading.Lock() self.busy_jobs = set() def sync_printers(self, plist): ids = set() for p in plist: pid = str(p["id"]) ids.add(pid) if not int(p.get("enabled", 1)): continue old = self.printers.get(pid) sig = json.dumps({k: p.get(k) for k in ("type", "host", "port", "api_key", "username", "serial")}, sort_keys=True) if old and old[0] == sig: continue if old: old[1].close() drv = DRIVERS.get(p.get("type")) if drv and p.get("host"): self.printers[pid] = (sig, drv(p)) log("Impresora conectada:", p.get("name"), "(%s %s)" % (p.get("type"), p.get("host"))) for pid in list(self.printers): if pid not in ids: self.printers.pop(pid)[1].close() def read_states(self): def one(item): pid, (_, drv) = item try: st = drv.status() except Exception as e: st = {"state": "offline", "message": str(e)[:200]} st["state"] = norm_state(st.get("state")) return pid, st with ThreadPoolExecutor(max_workers=8) as ex: for pid, st in ex.map(one, list(self.printers.items())): self.states[pid] = st self.track_job(pid, st) def track_job(self, pid, st): a = self.active.get(pid) if not a: return state = st["state"] if state in ("printing", "paused"): a["seen_printing"] = True self.events.append({"job_id": a["job_id"], "event": "progress", "progress": st.get("progress", 0), "paused": state == "paused"}) elif a["seen_printing"] and state in ("finished", "idle"): canceled = (st.get("raw_state") or "").lower() in ("cancelled", "stopped") or a.get("cancel") self.events.append({"job_id": a["job_id"], "event": "canceled" if canceled else "done"}) log("Trabajo #%s %s" % (a["job_id"], "cancelado" if canceled else "terminado")) self.active.pop(pid, None) elif state == "error" and a["seen_printing"]: self.events.append({"job_id": a["job_id"], "event": "failed", "msg": st.get("message") or "Error en la impresora"}) self.active.pop(pid, None) elif not a["seen_printing"] and time.time() - a["t0"] > 240: self.events.append({"job_id": a["job_id"], "event": "failed", "msg": "La impresora no empezó a imprimir en 4 minutos"}) self.active.pop(pid, None) def run_command(self, c): cid, pid, cmd, pl = c["id"], str(c.get("printer_id") or ""), c["cmd"], c.get("payload") or {} try: if cmd == "scan": self.discovered = scan_network(self.cfg.get("extra_subnets")) return self.done(cid, True, "%d detectadas" % len(self.discovered)) if pid not in self.printers: raise Exception("Impresora no conectada al puente") drv = self.printers[pid][1] if cmd == "start_job": return threading.Thread(target=self.start_job, args=(cid, pid, drv, pl), daemon=True).start() if cmd == "pause": drv.pause() elif cmd == "resume": drv.resume() elif cmd == "cancel": drv.cancel() if pid in self.active: self.active[pid]["cancel"] = True if not self.active[pid]["seen_printing"]: self.events.append({"job_id": self.active.pop(pid)["job_id"], "event": "canceled"}) elif cmd in ("light_on", "light_off") and hasattr(drv, "light"): drv.light(cmd == "light_on") self.done(cid, True, "ok") except Exception as e: log("Comando", cmd, "falló:", e) self.done(cid, False, str(e)) def start_job(self, cid, pid, drv, pl): job_id = pl.get("job_id") if job_id in self.busy_jobs: return self.busy_jobs.add(job_id) tmpdir = tempfile.mkdtemp(prefix="nexus_") try: name = os.path.basename(pl.get("file_name") or ("trabajo_%s.%s" % (job_id, pl.get("ext", "gcode")))) safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in name) local = os.path.join(tmpdir, safe) log("Descargando trabajo #%s (%s) copia %s/%s..." % (job_id, safe, pl.get("copy"), pl.get("copies"))) self.server.download_job_file(job_id, local) ptype = self.printers[pid][0] and json.loads(self.printers[pid][0])["type"] local = slice_if_needed(self.cfg, local, ptype) remote = "nexus_" + os.path.basename(local) log("Enviando a la impresora:", remote) drv.upload_and_print(local, remote) self.active[pid] = {"job_id": job_id, "t0": time.time(), "seen_printing": False} self.events.append({"job_id": job_id, "event": "started", "msg": "Enviado a la impresora"}) self.done(cid, True, "Iniciado") except Exception as e: log("No se pudo iniciar el trabajo #%s: %s" % (job_id, e)) self.events.append({"job_id": job_id, "event": "failed", "msg": str(e)[:900]}) self.done(cid, False, str(e)) finally: self.busy_jobs.discard(job_id) try: for f in os.listdir(tmpdir): os.remove(os.path.join(tmpdir, f)) os.rmdir(tmpdir) except Exception: pass def done(self, cid, ok, msg): with self.lock: self.results.append({"cmd_id": cid, "ok": ok, "msg": msg}) def loop(self): log("Puente Nexus V%s → %s" % (VERSION, self.cfg["server"])) first = True fails = 0 while True: try: self.read_states() with self.lock: results, self.results = self.results, [] events, self.events = self.events, [] payload = {"version": VERSION, "local_ip": local_ip(), "hostname": socket.gethostname(), "platform": platform.platform(), "slicer": self.cfg.get("slicer_path") or "", "printers": self.states, "results": results, "job_events": events} if self.discovered is not None: payload["discovered"] = self.discovered self.discovered = None resp = self.server.poll(payload) if not resp.get("ok"): log("Servidor:", resp.get("error")) if resp.get("code") == "BRIDGE": log("El token no es válido. Edita", CONFIG_FILE, "o vuelve a ejecutar con --token NUEVO") time.sleep(30) continue if first: log("Conectado al panel NEXUS. Impresoras configuradas:", len(resp.get("printers", []))) first = False threading.Thread(target=lambda: setattr(self, "discovered", scan_network(self.cfg.get("extra_subnets"))), daemon=True).start() fails = 0 self.sync_printers(resp.get("printers", [])) for c in resp.get("commands", []): if c["cmd"] == "scan": threading.Thread(target=self.run_command, args=(c,), daemon=True).start() else: self.run_command(c) time.sleep(max(2, int(resp.get("poll_seconds", 3)))) except KeyboardInterrupt: log("Puente detenido.") for _, d in self.printers.values(): d.close() return except Exception as e: fails += 1 log("Sin conexión con el servidor (%s). Reintentando..." % e) time.sleep(min(60, 5 * fails)) if __name__ == "__main__": if "--scan" in sys.argv: for f in scan_network(): print(json.dumps(f, ensure_ascii=False)) sys.exit(0) Bridge(load_config()).loop()