diff --git a/esp32-firmware/artNC.ino b/esp32-firmware/artNC.ino index 4222204..fe06857 100644 --- a/esp32-firmware/artNC.ino +++ b/esp32-firmware/artNC.ino @@ -1,5 +1,7 @@ #include #include // library: "ESP32Servo" +#include +#include // ---------------------------- // Pins @@ -35,7 +37,7 @@ static const int32_t MAX_STEPS_PER_CMD = 250000; // ---------------------------- // Acceleration (new) // ---------------------------- -static const float START_STEPS_PER_SEC = 120.0f; // start speed +static const float START_STEPS_PER_SEC = 90.0f; // start speed (lower to reduce jerk) static const float ACCEL_STEPS_PER_SEC2 = 4000.0f; // accel/decel (steps/s^2) // ---------------------------- @@ -87,6 +89,72 @@ static bool qPop(Cmd& out) { return true; } +static inline void qClear() { + qHead = 0; + qTail = 0; +} + +// ---------------------------- +// Machine state + status +// ---------------------------- +enum class MachineState : uint8_t { READY, BUSY, ERROR }; + +static MachineState machineState = MachineState::READY; +static char machineDetail[96] = ""; + +static void sendLineAll(const char* line) { + Serial.print(line); + Serial.print("\n"); + if (tcpClient && tcpClient.connected()) { + tcpClient.print(line); + tcpClient.print("\n"); + } +} + +static void publishState(MachineState newState, const char* detail = nullptr) { + bool sameState = (newState == machineState); + bool sameDetail = false; + if (detail == nullptr || detail[0] == 0) { + sameDetail = (machineDetail[0] == 0); + } else { + sameDetail = (strcmp(machineDetail, detail) == 0); + } + + if (sameState && sameDetail) return; + + machineState = newState; + if (detail && detail[0]) { + strncpy(machineDetail, detail, sizeof(machineDetail) - 1); + machineDetail[sizeof(machineDetail) - 1] = 0; + } else { + machineDetail[0] = 0; + } + + const char* label = (newState == MachineState::READY) + ? "READY" + : (newState == MachineState::BUSY ? "BUSY" : "ERROR"); + + char buf[160]; + if (machineDetail[0]) { + snprintf(buf, sizeof(buf), "STATE %s %s", label, machineDetail); + } else { + snprintf(buf, sizeof(buf), "STATE %s", label); + } + sendLineAll(buf); +} + +static inline void setReady(const char* detail = nullptr) { + publishState(MachineState::READY, detail); +} + +static inline void setBusy(const char* detail) { + publishState(MachineState::BUSY, detail); +} + +static inline void setErrorState(const char* detail) { + publishState(MachineState::ERROR, detail); +} + // ---------------------------- // Helpers // ---------------------------- @@ -148,6 +216,11 @@ static void replyERR(Src src, const char* msg) { } } +static void reportError(Src src, const char* msg) { + setErrorState(msg); + replyERR(src, msg); +} + // Parse: "W L-123 R456 F1200" static bool parseW(const char* s, int32_t& l, int32_t& r, int32_t& f) { const char* pL = strchr(s, 'L'); @@ -269,53 +342,65 @@ static void handleLine(Src src, char* line) { } if (n == 0) { + setReady(); replyOK(src); return; } // END if (strcmp(line, "END") == 0) { + setBusy("END"); setEnable(false); + setReady(); replyOK(src); return; } // H if (strcmp(line, "H") == 0) { + setBusy("HOME"); posL = 0; posR = 0; + setReady(); replyOK(src); return; } // E 0 / E 1 if (line[0] == 'E') { + setBusy("ENABLE"); if (strstr(line, "0")) { setEnable(false); + setReady(); replyOK(src); return; } if (strstr(line, "1")) { setEnable(true); + setReady(); replyOK(src); return; } - replyERR(src, "BAD_E"); + reportError(src, "BAD_E"); return; } // P U / P D if (line[0] == 'P') { + setBusy("PEN"); if (strstr(line, "U")) { penUp(); + setReady(); replyOK(src); return; } if (strstr(line, "D")) { penDown(); + setReady(); replyOK(src); return; } + setReady(); replyOK(src); return; } @@ -324,15 +409,17 @@ static void handleLine(Src src, char* line) { if (line[0] == 'W') { int32_t l, r, f; if (!parseW(line, l, r, f)) { - replyERR(src, "BAD_W"); + reportError(src, "BAD_W"); return; } + setBusy("MOVE"); moveWheels(l, r, f); + setReady(); replyOK(src); return; } - replyERR(src, "UNKNOWN"); + reportError(src, "UNKNOWN"); } // ---------------------------- @@ -344,6 +431,13 @@ static int usbLen = 0; static char netBuf[160]; static int netLen = 0; +static inline void resetInputBuffers() { + usbLen = 0; + usbBuf[0] = 0; + netLen = 0; + netBuf[0] = 0; +} + static void pumpUSB() { while (Serial.available()) { char c = (char)Serial.read(); @@ -366,12 +460,26 @@ static void pumpUSB() { } static void pumpNET() { + if (tcpClient && !tcpClient.connected()) { + tcpClient.stop(); + resetInputBuffers(); + qClear(); + setEnable(false); + setReady("TCP_LOST"); + } + if (!tcpClient || !tcpClient.connected()) { WiFiClient nc = server.available(); if (nc) { + if (tcpClient) { + tcpClient.stop(); + } tcpClient = nc; tcpClient.setNoDelay(true); - netLen = 0; + resetInputBuffers(); + qClear(); + setEnable(false); + setReady("TCP_CONNECTED"); } return; } @@ -425,6 +533,7 @@ void setup() { server.begin(); server.setNoDelay(true); + publishState(MachineState::READY, "BOOT"); Serial.print("OK\n"); } @@ -438,4 +547,4 @@ void loop() { } delay(1); -} \ No newline at end of file +} diff --git a/print.py b/print.py index 4852ab7..85c7929 100644 --- a/print.py +++ b/print.py @@ -28,6 +28,7 @@ def read_acode(path: str) -> List[str]: def connect_tcp(host: str, port: int, connect_timeout: float) -> socket.socket: s = socket.create_connection((host, port), timeout=connect_timeout) s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + recv_line_poll._buf = bytearray() return s def recv_line_poll(sock: socket.socket, slice_timeout: float) -> str | None: @@ -54,6 +55,11 @@ def recv_line_poll(sock: socket.socket, slice_timeout: float) -> str | None: recv_line_poll._buf = buf return None + +def print_status_line(label: str, value: str) -> None: + sys.stdout.write(f"[{label}] {value}\n") + sys.stdout.flush() + def estimate_motion_seconds( line: str, feed_to_sps: float, @@ -122,12 +128,30 @@ def estimate_motion_seconds( return (2.0 * t_ramp) + t_cruise + 0.30 + +def handle_sideband(resp: str) -> bool: + if resp.startswith("STATE"): + print_status_line("STATE", resp[6:].strip()) + return True + return False + + +def drain_sideband(sock: socket.socket, max_wait_s: float, poll_slice_s: float) -> None: + deadline = time.time() + max_wait_s + while time.time() < deadline: + resp = recv_line_poll(sock, poll_slice_s) + if resp is None: + break + if not handle_sideband(resp): + break + def wait_ok( sock: socket.socket, line: str, expected_s: float, hard_cap_s: float, poll_slice_s: float, + sideband_cb=None, ) -> None: # Deadline with safe margin wait_s = max(4.0, expected_s * 3.0 + 2.0) @@ -142,6 +166,9 @@ def wait_ok( if resp is None: continue + if sideband_cb and sideband_cb(resp): + continue + if resp == "OK": return if resp.startswith("ERR"): @@ -167,8 +194,8 @@ def main() -> int: ap.add_argument("--servo-settle-ms", type=int, default=180) # accel model (match ESP32 defaults) - ap.add_argument("--start-sps", type=float, default=120.0) - ap.add_argument("--accel-sps2", type=float, default=8000.0) + ap.add_argument("--start-sps", type=float, default=90.0) + ap.add_argument("--accel-sps2", type=float, default=4000.0) ap.add_argument("--start", type=int, default=1) ap.add_argument("--delay-ms", type=int, default=0) @@ -189,7 +216,8 @@ def main() -> int: sock = connect_tcp(args.host, args.port, args.connect_timeout) try: - print(f"Connected to {args.host}:{args.port}") + print_status_line("WIFI", f"Connected to {args.host}:{args.port}") + drain_sideband(sock, max_wait_s=1.0, poll_slice_s=args.poll_slice) for i in range(idx0, n): line = lines[i] @@ -212,6 +240,7 @@ def main() -> int: expected_s=expected, hard_cap_s=args.hard_cap, poll_slice_s=args.poll_slice, + sideband_cb=handle_sideband, ) print_line_status(i + 1, n, line, "OK") @@ -238,6 +267,10 @@ def main() -> int: print("DONE") return 0 + except (ConnectionError, TimeoutError, RuntimeError) as exc: + print_status_line("ERROR", str(exc)) + return 3 + finally: try: sock.close() @@ -245,4 +278,4 @@ def main() -> int: pass if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/templates/index.html b/templates/index.html index 1371ffa..eb82cac 100644 --- a/templates/index.html +++ b/templates/index.html @@ -166,11 +166,24 @@

artNC

+ + + not loaded
+
+ WiFi: - + State: - +
+
@@ -218,6 +231,15 @@

artNC

+
+
+
Log połączenia
+ debug +
+
+
+
+
@@ -710,6 +732,11 @@

artNC

+
+
line_spacing_mmOdstęp między liniami (mm)
+
+
+
letter_height_mmWysokość litery w mm
@@ -880,11 +907,54 @@

artNC

return `${s}s`; } + function setLinkStatus(txt) { + qs("linkStatus").textContent = txt ? `WiFi: ${txt}` : "WiFi: -"; + } + + function setMachineState(state, detail = "") { + const suffix = detail ? ` (${detail})` : ""; + qs("machineState").textContent = state ? `State: ${state}${suffix}` : "State: -"; + } + + function applyTransport(fd) { + fd.set("transport", qs("transport").value); + fd.set("host", qs("host").value.trim()); + fd.set("port", qs("port").value.trim()); + fd.set("serial_port", qs("serial_port").value.trim()); + fd.set("serial_baud", qs("serial_baud").value.trim()); + } + + function appendLog(msg) { + const el = qs("connlog"); + if (!el) return; + const line = document.createElement("div"); + line.textContent = msg; + el.appendChild(line); + while (el.childNodes.length > 400) el.removeChild(el.firstChild); + el.scrollTop = el.scrollHeight; + } + + function loadInitialLogs(arr) { + if (!Array.isArray(arr)) return; + arr.forEach(appendLog); + } + + function applySenderAcode(text, lineCount) { + qs("acode").value = text || ""; + senderTotalLines = lineCount || 0; + qs("loaded").textContent = "loaded lines: " + (lineCount || 0); + qs("start_line").value = "1"; + qs("progress").textContent = "0/" + (lineCount || 0); + qs("current").textContent = ""; + qs("lastok").textContent = "-"; + qs("lastokline").textContent = ""; + qs("error").textContent = ""; + } + // Sender async function loadAcode() { const fd = new FormData(); - fd.set("host", qs("host").value.trim()); - fd.set("port", qs("port").value.trim()); + applyTransport(fd); fd.set("acode", qs("acode").value); fd.set("start_line", qs("start_line").value.trim()); @@ -914,6 +984,8 @@

artNC

}; // SSE + setLinkStatus("disconnected"); + setMachineState(""); const ev = new EventSource("/events"); ev.onmessage = (e) => { const data = JSON.parse(e.data); @@ -944,6 +1016,18 @@

artNC

if (payload.line) qs("lastokline").textContent = payload.line; } if (kind === "error") qs("error").textContent = payload.msg; + if (kind === "status") { + qs("runstate").textContent = payload.msg || "idle"; + if (payload.msg === "connected") setLinkStatus(`${qs("host").value}:${qs("port").value}`); + else if (payload.msg === "connecting") setLinkStatus("connecting..."); + else if (payload.msg === "stopped" || payload.msg === "disconnected") setLinkStatus("disconnected"); + } + if (kind === "machine_state") { + setMachineState(payload.state, payload.detail || ""); + } + if (kind === "log") { + appendLog(payload.msg || ""); + } return; } @@ -956,6 +1040,7 @@

artNC

if (data.last_sent) qs("current").textContent = data.last_sent; if (data.last_ok_idx) qs("lastok").textContent = data.last_ok_idx; if (data.last_ok_line) qs("lastokline").textContent = data.last_ok_line; + if (data.logs) loadInitialLogs(data.logs); if (idx) { const nextStart = idx + 1; qs("start_line").value = String(Math.max(1, Math.min(total || nextStart, nextStart))); @@ -977,8 +1062,7 @@

artNC

async function sendManualLine() { qs("serialerr").textContent = ""; const fd = new FormData(); - fd.set("host", qs("host").value.trim()); - fd.set("port", qs("port").value.trim()); + applyTransport(fd); fd.set("line", qs("manual_line").value.trim()); const out = await post("/api/serial_send", fd); if (out && out.line) { @@ -990,8 +1074,7 @@

artNC

async function jog(dir) { qs("jogerr").textContent = ""; const fd = new FormData(); - fd.set("host", qs("host").value.trim()); - fd.set("port", qs("port").value.trim()); + applyTransport(fd); fd.set("dir", dir); fd.set("jog_mm", qs("jog_mm").value.trim()); fd.set("steps_per_mm", qs("steps_per_mm").value.trim()); @@ -1008,8 +1091,7 @@

artNC

qs("pen_up").onclick = () => { qs("jogerr").textContent = ""; const fd = new FormData(); - fd.set("host", qs("host").value.trim()); - fd.set("port", qs("port").value.trim()); + applyTransport(fd); fd.set("mode", "up"); post("/api/pen", fd).catch(e => qs("jogerr").textContent = e.message); }; @@ -1017,8 +1099,7 @@

artNC

qs("pen_down").onclick = () => { qs("jogerr").textContent = ""; const fd = new FormData(); - fd.set("host", qs("host").value.trim()); - fd.set("port", qs("port").value.trim()); + applyTransport(fd); fd.set("mode", "down"); post("/api/pen", fd).catch(e => qs("jogerr").textContent = e.message); }; @@ -1026,8 +1107,7 @@

artNC

qs("step_enable").onclick = () => { qs("jogerr").textContent = ""; const fd = new FormData(); - fd.set("host", qs("host").value.trim()); - fd.set("port", qs("port").value.trim()); + applyTransport(fd); fd.set("cmd", qs("step_en_cmd").value.trim()); post("/api/steppers", fd).catch(e => qs("jogerr").textContent = e.message); }; @@ -1035,8 +1115,7 @@

artNC

qs("step_disable").onclick = () => { qs("jogerr").textContent = ""; const fd = new FormData(); - fd.set("host", qs("host").value.trim()); - fd.set("port", qs("port").value.trim()); + applyTransport(fd); fd.set("cmd", qs("step_dis_cmd").value.trim()); post("/api/steppers", fd).catch(e => qs("jogerr").textContent = e.message); }; @@ -1463,12 +1542,10 @@

artNC

qs("generr").textContent = ""; if (!lastGenId) throw new Error("generate first"); const fd = new FormData(); - fd.set("host", qs("host").value.trim()); - fd.set("port", qs("port").value.trim()); + applyTransport(fd); const out = await post("/api/push_to_sender", fd); - qs("acode").value = "loaded from generator (" + out.lines + " lines)\n"; - qs("loaded").textContent = "loaded lines: " + out.lines; + applySenderAcode(out.acode_text || "", out.lines); document.querySelector('.tab[data-tab="sender"]').click(); }; @@ -1528,12 +1605,10 @@

artNC

return; } const fd = new FormData(); - fd.set("host", qs("host").value.trim()); - fd.set("port", qs("port").value.trim()); + applyTransport(fd); const out = await post("/api/push_to_sender", fd); - qs("acode").value = "loaded from dxf (" + out.lines + " lines)\n"; - qs("loaded").textContent = "loaded lines: " + out.lines; + applySenderAcode(out.acode_text || "", out.lines); document.querySelector('.tab[data-tab="sender"]').click(); }; @@ -1587,6 +1662,7 @@

artNC

fd.set("text", text); fd.set("line_advance", qs("t_line_advance").value); fd.set("line_width_mm", qs("t_line_width_mm").value.trim()); + fd.set("line_spacing_mm", qs("t_line_spacing_mm").value.trim()); fd.set("letter_height_mm", qs("t_letter_height_mm").value.trim()); fd.set("stroke_mm", qs("t_stroke_mm").value.trim()); fd.set("render_dpi", qs("t_render_dpi").value.trim()); @@ -1640,12 +1716,10 @@

artNC

syncTextVizDefaults(); if (!lastTextId) throw new Error("generate first"); const fd = new FormData(); - fd.set("host", qs("host").value.trim()); - fd.set("port", qs("port").value.trim()); + applyTransport(fd); const out = await post("/api/push_to_sender", fd); - qs("acode").value = "loaded from text outline (" + out.lines + " lines)\n"; - qs("loaded").textContent = "loaded lines: " + out.lines; + applySenderAcode(out.acode_text || "", out.lines); document.querySelector('.tab[data-tab="sender"]').click(); }; diff --git a/ui_sender.py b/ui_sender.py index f5e9f60..eacc561 100644 --- a/ui_sender.py +++ b/ui_sender.py @@ -15,6 +15,10 @@ import json import math import socket +try: + import serial # type: ignore[import-not-found] +except Exception: # pragma: no cover - pyserial optional + serial = None # type: ignore import threading import queue import subprocess @@ -27,7 +31,7 @@ app = Flask(__name__, static_folder="static", static_url_path="/static") -DEFAULT_PORT = 23 +DEFAULT_PORT = 3333 RECV_TIMEOUT_S = 0.25 LINE_ACK_TIMEOUT_S = 60.0 @@ -44,6 +48,16 @@ os.makedirs(os.path.join(HERE, "static"), exist_ok=True) events = queue.Queue(maxsize=2000) +LOG_RING: List[str] = [] +LOG_RING_MAX = 400 + +def log_event(msg: str): + ts = time.strftime("%H:%M:%S") + line = f"[{ts}] {msg}" + LOG_RING.append(line) + if len(LOG_RING) > LOG_RING_MAX: + del LOG_RING[0:len(LOG_RING) - LOG_RING_MAX] + push_event("log", {"msg": line}) def push_event(kind: str, payload: dict): msg = {"kind": kind, "payload": payload, "ts": time.time()} @@ -67,8 +81,8 @@ def push_event(kind: str, payload: dict): "min_sps": 50.0, "max_sps": 2500.0, "servo_settle_s": 0.18, - "start_sps": 120.0, - "accel_sps2": 8000.0, + "start_sps": 90.0, + "accel_sps2": 4000.0, } def estimate_motion_seconds( @@ -158,6 +172,9 @@ class JobState: stopping: bool = False host: str = "192.168.4.1" port: int = DEFAULT_PORT + transport: str = "wifi" # wifi | serial + serial_port: str = "/dev/ttyUSB0" + serial_baud: int = 115200 lines: Optional[List[str]] = None idx: int = 0 last_sent: str = "" @@ -184,35 +201,94 @@ class GenState: gen_state = GenState(meta={}, viz_settings={}) gen_lock = threading.Lock() -def recv_line(sock: socket.socket, timeout_s: float) -> Optional[str]: - sock.settimeout(timeout_s) - buf = bytearray() - start = time.time() - while True: - if time.time() - start > timeout_s: - return None +class TransportClient: + def __init__(self, mode: str, host: str, port: int, serial_port: str, serial_baud: int): + self.mode = mode + self.host = host + self.port = port + self.serial_port = serial_port + self.serial_baud = serial_baud + self.sock: Optional[socket.socket] = None + self.ser = None + + def connect(self): + if self.mode == "serial": + if serial is None: + raise RuntimeError("pyserial not installed: pip install pyserial") + self.ser = serial.Serial(self.serial_port, self.serial_baud, timeout=RECV_TIMEOUT_S) + log_event(f"serial connected {self.serial_port}@{self.serial_baud}") + else: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((self.host, self.port)) + self.sock.settimeout(RECV_TIMEOUT_S) + log_event(f"wifi connected {self.host}:{self.port}") + + def send_line(self, line: str): + data = (line.strip() + "\n").encode("utf-8") + if self.mode == "serial" and self.ser: + self.ser.write(data) + elif self.sock: + self.sock.sendall(data) + else: + raise RuntimeError("transport not connected") + + def recv_line(self, timeout_s: float) -> Optional[str]: + end = time.time() + timeout_s + buf = bytearray() + while time.time() < end: + try: + if self.mode == "serial" and self.ser: + b = self.ser.read(1) + elif self.sock: + b = self.sock.recv(1) + else: + return None + except socket.timeout: + return None + if not b: + continue + if b == b"\n": + return buf.decode("utf-8", errors="replace").strip() + if b != b"\r": + buf.extend(b) + return None + + def close(self): try: - b = sock.recv(1) - except socket.timeout: - return None - if not b: - return None - if b == b"\n": - return buf.decode("utf-8", errors="replace").strip() - if b != b"\r": - buf.extend(b) - -def send_line(sock: socket.socket, line: str): - data = (line.strip() + "\n").encode("utf-8") - sock.sendall(data) - -def wait_ok(sock: socket.socket, timeout_s: float) -> bool: + if self.ser: + self.ser.close() + except Exception: + pass + try: + if self.sock: + self.sock.close() + except Exception: + pass + +def _parse_state_line(line: str) -> Optional[Dict[str, str]]: + if not line.startswith("STATE"): + return None + parts = line.split(maxsplit=2) + status = parts[1] if len(parts) > 1 else "" + detail = parts[2] if len(parts) > 2 else "" + return {"state": status or "?", "detail": detail} + +def _handle_sideband(line: str) -> bool: + st = _parse_state_line(line) + if st: + push_event("machine_state", st) + return True + return False + +def wait_ok(client: TransportClient, timeout_s: float) -> bool: deadline = time.time() + timeout_s while time.time() < deadline: - resp = recv_line(sock, RECV_TIMEOUT_S) + resp = client.recv_line(RECV_TIMEOUT_S) if not resp: continue s = resp.strip() + if _handle_sideband(s): + continue if s.startswith("OK"): return True if s.startswith("ERR"): @@ -222,25 +298,31 @@ def wait_ok(sock: socket.socket, timeout_s: float) -> bool: return False return False -def send_one_command(host: str, port: int, line: str, timeout_s: float = LINE_ACK_TIMEOUT_S) -> Dict[str, Any]: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +def send_one_command(client: TransportClient, line: str, timeout_s: float = LINE_ACK_TIMEOUT_S) -> Dict[str, Any]: try: - sock.connect((host, port)) - send_line(sock, line) - ok = wait_ok(sock, timeout_s) + client.connect() + push_event("status", {"msg": "connected"}) + client.send_line(line) + ok = wait_ok(client, timeout_s) + log_event(f"tx '{line}' -> {'OK' if ok else 'NO OK'}") return {"ok": ok, "line": line, "error": None if ok else "timeout or ERR"} - except OSError as e: + except Exception as e: + log_event(f"tx error: {e}") return {"ok": False, "line": line, "error": str(e)} finally: try: - sock.close() + client.close() except Exception: pass + push_event("status", {"msg": "disconnected"}) def sender_worker(): with state_lock: host = state.host port = state.port + transport = state.transport + serial_port = state.serial_port + serial_baud = state.serial_baud lines = state.lines or [] state.last_sent = "" state.last_ok = False @@ -249,10 +331,17 @@ def sender_worker(): push_event("status", {"msg": "connecting", "host": host, "port": port}) - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client = TransportClient( + mode=transport, + host=host, + port=port, + serial_port=serial_port, + serial_baud=serial_baud, + ) try: - sock.connect((host, port)) + client.connect() push_event("status", {"msg": "connected"}) + log_event(f"worker connected via {transport}") while True: with state_lock: @@ -290,14 +379,15 @@ def sender_worker(): push_event("line", {"idx": idx + 1, "total": total, "line": line}) try: - send_line(sock, line) - except OSError as e: + client.send_line(line) + except Exception as e: with state_lock: state.error = f"send failed: {e}" push_event("error", {"msg": state.error}) + log_event(state.error) break - ok = wait_ok(sock, LINE_ACK_TIMEOUT_S) + ok = wait_ok(client, LINE_ACK_TIMEOUT_S) with state_lock: state.last_ok = ok if ok: @@ -309,6 +399,7 @@ def sender_worker(): if not state.error: state.error = "timeout waiting for OK" push_event("error", {"msg": state.error}) + log_event(state.error) break push_event("ok", {"idx": idx + 1, "line": line}) @@ -320,15 +411,18 @@ def sender_worker(): with state_lock: state.error = f"connection failed: {e}" push_event("error", {"msg": state.error}) + push_event("status", {"msg": "disconnected"}) + log_event(state.error) finally: try: - sock.close() + client.close() except Exception: pass with state_lock: state.running = False state.paused = False state.stopping = False + push_event("status", {"msg": "stopped"}) def _safe_float(name: str, default: float) -> float: try: @@ -911,8 +1005,11 @@ def index(): @app.route("/api/load", methods=["POST"]) def api_load(): + transport = _safe_choice("transport", "wifi", ("wifi", "serial")) host = request.form.get("host", "").strip() or "192.168.4.1" port = _safe_int("port", DEFAULT_PORT) + serial_port = request.form.get("serial_port", "").strip() or "/dev/ttyUSB0" + serial_baud = _safe_int("serial_baud", 115200) content = request.form.get("acode", "") start_line = _safe_int("start_line", 1) @@ -926,6 +1023,9 @@ def api_load(): with state_lock: state.host = host state.port = port + state.transport = transport + state.serial_port = serial_port + state.serial_baud = serial_baud state.lines = lines state.idx = start_idx state.error = "" @@ -935,6 +1035,7 @@ def api_load(): state.last_ok_line = last_ok_line push_event("status", {"msg": "loaded", "lines": len(lines)}) + log_event(f"ACODE loaded ({len(lines)} lines) via {transport}") return jsonify({"ok": True, "lines": len(lines), "start_line": start_idx + 1, "duration_s": duration_s}) @app.route("/api/start", methods=["POST"]) @@ -992,6 +1093,7 @@ def gen(): "last_ok_idx": state.last_ok_idx, "last_ok_line": state.last_ok_line, "error": state.error, + "logs": LOG_RING[-50:], } yield "data: " + json.dumps(snap) + "\n\n" @@ -1379,8 +1481,11 @@ def download_acode(gen_id: str): @app.route("/api/push_to_sender", methods=["POST"]) def api_push_to_sender(): + transport = _safe_choice("transport", "wifi", ("wifi", "serial")) host = request.form.get("host", "").strip() port_s = request.form.get("port", "").strip() + serial_port = request.form.get("serial_port", "").strip() + serial_baud = _safe_int("serial_baud", 115200) with gen_lock: if not gen_state.acode_text: @@ -1389,6 +1494,7 @@ def api_push_to_sender(): lines = [ln.rstrip("\r\n") for ln in text.splitlines()] lines = [ln for ln in lines if ln.strip()] + normalized_text = "\n".join(lines) + ("\n" if lines else "") with state_lock: if host: @@ -1398,6 +1504,12 @@ def api_push_to_sender(): state.port = int(port_s) except ValueError: pass + if transport: + state.transport = transport + if serial_port: + state.serial_port = serial_port + if serial_baud: + state.serial_baud = serial_baud state.lines = lines state.idx = 0 state.error = "" @@ -1407,7 +1519,8 @@ def api_push_to_sender(): state.last_ok_line = "" push_event("status", {"msg": "loaded_from_generator", "lines": len(lines)}) - return jsonify({"ok": True, "lines": len(lines)}) + log_event(f"ACODE pushed from generator ({len(lines)} lines) via {transport or 'wifi'}") + return jsonify({"ok": True, "lines": len(lines), "acode_text": normalized_text}) # ---------------------------- # Sender preview + serial tools @@ -1456,14 +1569,24 @@ def api_serial_send(): if err: return jsonify({"ok": False, "error": err}), 409 + transport = _safe_choice("transport", "wifi", ("wifi", "serial")) host = request.form.get("host", "").strip() or "192.168.4.1" port = _safe_int("port", DEFAULT_PORT) + serial_port = request.form.get("serial_port", "").strip() or "/dev/ttyUSB0" + serial_baud = _safe_int("serial_baud", 115200) line = (request.form.get("line", "") or "").strip() if not line: return jsonify({"ok": False, "error": "missing line"}), 400 push_event("line", {"idx": 0, "total": 0, "line": line}) - out = send_one_command(host, port, line, timeout_s=LINE_ACK_TIMEOUT_S) + client = TransportClient( + mode=transport, + host=host, + port=port, + serial_port=serial_port, + serial_baud=serial_baud, + ) + out = send_one_command(client, line, timeout_s=LINE_ACK_TIMEOUT_S) if out["ok"]: push_event("ok", {"idx": 0, "line": line}) return jsonify({"ok": True, "line": line})