// libBambuSource.so: LAN-only video source for Bambu Lab printers, as // consumed by Bambu Studio's `gstbambusrc` element. // // Bambu Studio loads this library via NetworkAgent::get_bambu_source_entry() // (`dlopen` of `/plugins/libBambuSource.so`). We support both // LAN video protocols: // // * MJPG over TLS on port 6000 - used by A1 / A1 mini / P1 / P1P. // Protocol is the 80-byte auth packet + 16-byte frame headers // documented in OpenBambuAPI/video.md and implemented below. // // * RTSPS on port 322 - used by X1 / P1S / P2S / N7. The RTSP/RTSPS // handshake plus RTP-H.264 depacketisation lives in // stubs/rtsp_client.cpp; stubs/rtsp_passthrough.cpp wraps that in // a worker thread that hands Annex-B-framed access units to the // C ABI. We do NOT decode or transcode: gstbambusrc.c (vendored // verbatim by both Bambu Studio and Orca Slicer on Linux) feeds // whatever Bambu_ReadSample returns into h264parse + avdec_h264 / // openh264dec / vaapih264dec, so the slicer-side pipeline does // all the heavy lifting and we stay free of any in-process // libavcodec dependency. // // URL formats we accept (all three appear in Studio's source): // // bambu:///local/?port=6000&user=&passwd=

&... // bambu:///local/.?port=6000&user=&passwd=

&... (legacy) // -> TCP/TLS MJPG on port 6000 (P1/A1 firmware protocol) // // bambu:///rtsps___:@/streaming/live/1?proto=rtsps // bambu:///rtsp___:@/streaming/live/1?proto=rtsp // -> RTSP(S) on port 322 (X1/P1S/P2S/N7 firmware protocol); // routed through obn::rtsp::Passthrough (raw H.264 byte stream). // // Extra query parameters (device=, net_ver=, dev_ver=, cli_id=, ...) are // ignored by the printer but device= is used for LAN TLS verify (SNI + // CN=serial). The printer only cares about the auth packet (MJPG) or // the RTSP DESCRIBE/SETUP/PLAY exchange. // // Protocol summary (see OpenBambuAPI/video.md for the canonical spec): // // 1. TLS handshake over TCP on :; chain + CN=serial verified // via printer.cer unless OBN_SKIP_TLS_VERIFY is set. // 2. Send 80-byte auth packet: // [0..3] little-endian uint32 = 0x40 (payload size) // [4..7] little-endian uint32 = 0x3000 (type: auth) // [8..11] little-endian uint32 = 0 (flags) // [12..15] little-endian uint32 = 0 // [16..47] 32 bytes: ASCII username, NUL-padded // [48..79] 32 bytes: ASCII password, NUL-padded // 3. Server then streams frames indefinitely. Each frame is: // 16-byte header (payload_size u32, itrack u32, flags u32, pad u32) // followed by `payload_size` bytes of JPEG data (FF D8 ... FF D9). // // gstbambusrc contract (see gstbambusrc.c): // // Bambu_Create (parse URL, allocate tunnel) // Bambu_SetLogger (attach log callback) // Bambu_Open (blocking connect + TLS handshake + auth) // Bambu_StartStream(video=1) until it returns != would_block // Bambu_GetStreamCount / Bambu_GetStreamInfo (once) // loop { // Bambu_ReadSample() // would_block is fine, gst sleeps 33 ms // ...if success, emit buffer... // } // Bambu_Close + Bambu_Destroy at teardown. // // Thread safety: `gstbambusrc` calls us from a single streaming thread per // tunnel; we only need to be safe against the logger callback being fired // from that same thread. No global locks are held. #include #include #include #include "obn/os_compat.hpp" #if defined(_WIN32) # ifndef WIN32_LEAN_AND_MEAN # define WIN32_LEAN_AND_MEAN # endif # ifndef NOMINMAX # define NOMINMAX # endif # include # include # include #else # include # include # include # include # include # include #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "image_io.hpp" #include #include "obn/ftps.hpp" #include "obn/json_lite.hpp" #include "obn/lan_tls.hpp" #include "obn/lan_tls_env.hpp" #include "obn/zip_reader.hpp" #include "source_log.hpp" #include "rtsp_passthrough.hpp" #include "tls_socket.hpp" #if defined(_WIN32) # define OBN_EXPORT extern "C" __declspec(dllexport) #else # define OBN_EXPORT extern "C" __attribute__((visibility("default"))) #endif // ----------------------------------------------------------------------- // Types redeclared from BambuTunnel.h. We do NOT include the original // header because it is part of Bambu Studio's proprietary build tree // (GPL-incompatible). All layout / enum values are checked against // OpenBambuAPI documentation and gstbambusrc.c behaviour. // ----------------------------------------------------------------------- extern "C" { typedef void* Bambu_Tunnel; // Studio's tchar contract differs by platform: Linux/macOS pass char*, // Windows passes wchar_t* (matches wxMediaCtrl2's Bambu_FreeLogMsg / // gstbambusrc's bambu_log signature). #if defined(_WIN32) using tchar = wchar_t; #else using tchar = char; #endif enum Bambu_StreamType { VIDE = 0, AUDI = 1 }; enum Bambu_VideoSubType { AVC1 = 0, MJPG = 1 }; enum Bambu_FormatType { video_avc_packet = 0, video_avc_byte_stream, video_jpeg, audio_raw, audio_adts, }; enum Bambu_Error { Bambu_success = 0, Bambu_stream_end, Bambu_would_block, Bambu_buffer_limit }; struct Bambu_StreamInfo { int type; // Bambu_StreamType int sub_type; // Bambu_VideoSubType / Bambu_AudioSubType union { struct { int width; int height; int frame_rate; } video; struct { int sample_rate; int channel_count; int sample_size; } audio; } format; int format_type; // Bambu_FormatType int format_size; int max_frame_size; unsigned char const* format_buffer; }; struct Bambu_Sample { int itrack; int size; int flags; unsigned char const* buffer; unsigned long long decode_time; // 100ns units, per gstbambusrc expectations }; // Studio's Logger typedef. We keep the C-visible alias so the // exported Bambu_SetLogger / Bambu_FreeLogMsg signatures stay // byte-identical with what gstbambusrc and wxMediaCtrl2 expect. using Logger = void (*)(void* context, int level, tchar const* msg); } // extern "C" // ----------------------------------------------------------------------- // All log/last-error helpers live in stubs/source_log.{hpp,cpp} so the // RTSP client and FTPS bridge can share them. We pull the names into // the anonymous namespace below so existing call sites (`log_fmt`, // `log_at`, `mirror_log_fp`, `set_last_error`, `LL_DEBUG`, ...) keep // compiling unchanged. // ----------------------------------------------------------------------- namespace { using obn::source::log_at; using obn::source::log_fmt; using obn::source::mirror_log_fp; using obn::source::noop_logger; using obn::source::set_last_error; using obn::source::LL_TRACE; using obn::source::LL_DEBUG; using obn::source::LL_INFO; using obn::source::LL_WARN; using obn::source::LL_ERROR; using obn::source::LL_OFF; // ----------------------------------------------------------------------- // URL parser. Bambu URLs: // bambu:///local/?port=6000&user=&passwd=

&... // bambu:///local/.?port=6000&... (note the trailing dot) // ----------------------------------------------------------------------- enum class Scheme { Local, // MJPG over TCP/TLS on (default 6000) Rtsps, // RTSPS on (default 322) Rtsp, // plain RTSP on (default 554) }; struct TunnelUrl { Scheme scheme = Scheme::Local; std::string host; int port = 6000; std::string user = "bblp"; std::string passwd; std::string device; std::string path = "/streaming/live/1"; // RTSP(S) only }; std::string url_decode(const std::string& s) { std::string out; out.reserve(s.size()); for (size_t i = 0; i < s.size(); ++i) { if (s[i] == '%' && i + 2 < s.size()) { auto hex = [](char c) -> int { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return 10 + c - 'a'; if (c >= 'A' && c <= 'F') return 10 + c - 'A'; return -1; }; int a = hex(s[i + 1]); int b = hex(s[i + 2]); if (a >= 0 && b >= 0) { out.push_back(static_cast((a << 4) | b)); i += 2; continue; } } out.push_back(s[i] == '+' ? ' ' : s[i]); } return out; } bool parse_url(const std::string& url, TunnelUrl* out) { // Recognise the three URL shapes Studio hands us. Whichever it is, // strip the prefix and leave `rest` = "<...>[?query]". static const std::string p_local = "bambu:///local/"; static const std::string p_rtsps = "bambu:///rtsps___"; static const std::string p_rtsp = "bambu:///rtsp___"; std::string rest; if (url.compare(0, p_local.size(), p_local) == 0) { out->scheme = Scheme::Local; out->port = 6000; rest = url.substr(p_local.size()); } else if (url.compare(0, p_rtsps.size(), p_rtsps) == 0) { out->scheme = Scheme::Rtsps; out->port = 322; rest = url.substr(p_rtsps.size()); } else if (url.compare(0, p_rtsp.size(), p_rtsp) == 0) { out->scheme = Scheme::Rtsp; out->port = 554; rest = url.substr(p_rtsp.size()); } else { // Bare ":/..." fallback. out->scheme = Scheme::Local; out->port = 6000; rest = url; } // Split host.part vs ?query. auto q_pos = rest.find('?'); std::string host_part = (q_pos == std::string::npos) ? rest : rest.substr(0, q_pos); std::string query = (q_pos == std::string::npos) ? "" : rest.substr(q_pos + 1); if (out->scheme == Scheme::Rtsps || out->scheme == Scheme::Rtsp) { // ":@[:port]/" (path is required and // Studio always sends "streaming/live/1"). auto at_pos = host_part.find('@'); if (at_pos != std::string::npos) { std::string userinfo = host_part.substr(0, at_pos); host_part = host_part.substr(at_pos + 1); auto col = userinfo.find(':'); if (col != std::string::npos) { out->user = url_decode(userinfo.substr(0, col)); out->passwd = url_decode(userinfo.substr(col + 1)); } else { out->user = url_decode(userinfo); } } auto slash = host_part.find('/'); if (slash != std::string::npos) { out->path = host_part.substr(slash); // includes leading '/' host_part = host_part.substr(0, slash); } // Host may still carry ":". Fall through to the colon // handling below. } else { // Legacy MJPG URL: ".?port=..." -> trim trailing . and / while (!host_part.empty() && (host_part.back() == '/' || host_part.back() == '.')) host_part.pop_back(); } // Optional ":" in host_part. auto colon = host_part.find(':'); if (colon != std::string::npos) { out->host = host_part.substr(0, colon); try { out->port = std::stoi(host_part.substr(colon + 1)); } catch (...) { return false; } } else { out->host = host_part; } // Parse query. Local-scheme URLs carry user/passwd here; // RTSP(S) URLs carry them in the userinfo above, so these are // effectively a no-op for those. size_t i = 0; while (i < query.size()) { auto amp = query.find('&', i); if (amp == std::string::npos) amp = query.size(); auto kv = query.substr(i, amp - i); auto eq = kv.find('='); std::string key = (eq == std::string::npos) ? kv : kv.substr(0, eq); std::string val = (eq == std::string::npos) ? "" : url_decode(kv.substr(eq + 1)); if (key == "port") { try { out->port = std::stoi(val); } catch (...) { /* keep default */ } } else if (key == "user") { out->user = val; } else if (key == "passwd") { out->passwd = val; } else if (key == "device") { out->device = val; } i = amp + 1; } return !out->host.empty() && out->port > 0; } // ----------------------------------------------------------------------- // OpenSSL one-time init via tls_socket (shared with RTSPS / FTPS paths). // ----------------------------------------------------------------------- void ssl_init_once() { // Ensures OpenSSL + the shared tls_socket SSL_CTX are ready. (void)obn::tls::shared_ctx(); } // ----------------------------------------------------------------------- // Tunnel state. All network IO is synchronous (blocking) on purpose; // gstbambusrc already runs us on a dedicated streaming thread. // ----------------------------------------------------------------------- // Reasonable upper bound for a single 1280x720 JPEG frame. The stock // camera tops out around 60 KB but we give ourselves a whole megabyte // of headroom in case Bambu ships higher-res firmware later. constexpr size_t kMaxFrameSize = 1u << 20; // -------------------------------------------------------------------- // PrinterFileSystem CTRL state. // // Studio opens a port-6000 tunnel through us (Bambu_Create/Bambu_Open // on `bambu:///local/.?port=6000&user=bblp&passwd=&...`) and // then calls `Bambu_StartStreamEx(tunnel, CTRL_TYPE=0x3001)`. After // that point it stops asking for video and instead pushes JSON request // strings through `Bambu_SendMessage(CTRL_TYPE)`, expecting JSON // responses through `Bambu_ReadSample`. This is how Studio's Files tab // (MediaFilePanel + PrinterFileSystem) enumerates, previews, and // downloads files from the printer. // // We run those requests against the printer's FTPS service (port 990, // same login as the MJPG tunnel) on a dedicated worker thread per // tunnel. Responses are queued back to Studio through an out-queue; // `Bambu_ReadSample` pops them one at a time. Each response is a // single JSON object optionally followed by "\n\n" + binary blob (the // format PrinterFileSystem::HandleResponse expects). // // Lifetime: Bambu_Open is synchronous, Bambu_StartStreamEx spawns the // worker, Bambu_SendMessage enqueues a request (returns immediately), // Bambu_ReadSample dequeues a reply (Bambu_would_block if none), and // Bambu_Close signals the worker to drain and exit. // -------------------------------------------------------------------- constexpr int kCtrlType = 0x3001; // Matches PrinterFileSystem::Command enum (FILE_DEL, FILE_DOWNLOAD, // REQUEST_MEDIA_ABILITY, ...). We don't redeclare the enum: these // numbers are set in ABI stone on the Studio side. constexpr int kCmdListInfo = 0x0001; constexpr int kCmdSubFile = 0x0002; constexpr int kCmdFileDel = 0x0003; constexpr int kCmdFileDownload = 0x0004; constexpr int kCmdFileUpload = 0x0005; constexpr int kCmdRequestMediaAbility = 0x0007; constexpr int kCmdTaskCancel = 0x1000; // PrinterFileSystem result codes we emit back. constexpr int kResOK = 0; constexpr int kResContinue = 1; constexpr int kResErrJson = 2; constexpr int kResErrPipe = 3; constexpr int kResErrCancel = 4; constexpr int kResFileNoExist = 10; constexpr int kResStorUnavail = 17; constexpr int kResApiUnsupport = 18; struct CtrlRequest { int cmdtype = 0; int sequence = 0; std::string body; // full JSON text, including "{...}\n\n" if present }; struct CtrlReply { // "wire bytes" already laid out in the PrinterFileSystem format: // "\n\n". For multi-chunk responses (like // FILE_DOWNLOAD's progressive replies) the worker pushes one // CtrlReply per chunk, all with the same `sequence`. std::string data; }; struct Tunnel { TunnelUrl url; Logger logger = noop_logger; void* log_ctx = nullptr; // ---- MJPG/TLS state (Scheme::Local) ---- obn::os::socket_t fd = obn::os::kInvalidSocket; SSL* ssl = nullptr; // ---- RTSP(S) state (Scheme::Rtsps/Rtsp) ---- // Custom RTSP/RTSPS client wrapped by an Annex-B passthrough // worker (rtsp_passthrough.hpp). Built lazily by open_rtsp(); // destroyed by tunnel_close(). Hands raw H.264 byte-stream // straight to gstbambusrc, which decodes via h264parse + // avdec_h264/openh264dec on the slicer side -- no in-process // libavcodec is required (Bambu Studio's bundled libavcodec is // decoder-only, and Orca Slicer doesn't ship one at all). std::unique_ptr rtsp_pass; // Subtype of the video carried by this tunnel, filled in by // Bambu_GetStreamInfo. MJPG for local-scheme tunnels (port 6000, // A1/P1/A1 mini), AVC1 for RTSP(S) tunnels (X1/P1S/P2S/N7). int sub_type = MJPG; // Bookkeeping for GetStreamInfo. We don't know the real frame rate // until we've observed several frames, so these are "advisory" and // Studio uses them only for display. int width = 1280; int height = 720; int frame_rate = 15; // Reused across ReadSample calls so the Bambu_Sample::buffer pointer // stays valid until the NEXT ReadSample is invoked (matches what // gstbambusrc does with `g_memdup(sample.buffer, sample.size)`). std::vector frame_buf; // Monotonic "decode_time" in the 100-ns units gstbambusrc feeds to // gstreamer. We derive it from a steady_clock zeroed at Open() time. std::chrono::steady_clock::time_point t0{}; bool started = false; // Cancellation flag set from a different thread by Bambu_Close. std::atomic closing{false}; // Serialises access to `ssl` / `fd` against tunnel_close. Held by // every SSL_read iteration on the streaming thread, and acquired // by tunnel_close after it has shut the socket down (which wakes // any blocked SSL_read so the lock can actually be obtained). // Without this Studio's reconnect-on-stall path used to free SSL // out from under the reader and segfault. std::mutex mjpg_io_mu; // Diagnostic counter; we log a line every Nth frame so the mirror // file tells us "stream is alive" without drowning in per-frame spam. std::uint64_t frame_count = 0; // ---- PrinterFileSystem CTRL state (Scheme::Local + CTRL_TYPE) ---- // When Studio calls Bambu_StartStreamEx(CTRL_TYPE) on us, we close // the MJPG TCP socket we opened in Bambu_Open and switch to CTRL // mode: the tunnel now multiplexes JSON request/response messages // against FTPS on the same printer. bool ctrl_mode = false; std::unique_ptr ftp; // True if FTPS root == storage mount (P2S / USB-only printers). // False means /sdcard and /usb coexist under /. bool root_is_storage = false; // One of "sdcard" / "usb" / "" (unknown). Used as the top-level // directory for list operations. std::string storage_label; // Pre-computed prefix used when talking to the FTPS server: // "/sdcard" or "/usb" when both mounts exist, "" when root IS the // storage. Files live under /; special subtrees like // `timelapse/` and `ipcam/` hang off / too. std::string ftp_prefix; // CTRL request inbox (Bambu_SendMessage pushes here) and reply // outbox (Bambu_ReadSample pops from here). Both guarded by ctrl_mu. std::mutex ctrl_mu; std::condition_variable ctrl_cv; std::deque ctrl_in; std::deque ctrl_out; // Sequence numbers the caller asked to cancel. The worker checks // this set between multi-chunk responses (e.g. long downloads) and // aborts cleanly if membership appears mid-request. std::unordered_set ctrl_cancelled; std::atomic ctrl_stop{false}; std::thread ctrl_worker; // Scratch storage for the CtrlReply currently exposed via // Bambu_ReadSample's `sample->buffer`. We keep it alive until the // NEXT ReadSample call (same contract as frame_buf above). std::string ctrl_current_reply; }; void tunnel_close(Tunnel* t) { if (!t) return; t->closing.store(true, std::memory_order_release); log_at(LL_DEBUG, t->logger, t->log_ctx, "tunnel_close: shutting down (fd=%lld ssl=%p frames=%llu)", static_cast(t->fd), static_cast(t->ssl), static_cast(t->frame_count)); // Step 1 (no lock): wake the reader. SSL_read on the streaming // thread sits in recv() up to SO_RCVTIMEO (5 s); shutting down // the socket from under it makes recv() return immediately so it // can drop mjpg_io_mu and let us free the SSL object below. This // is intentionally done WITHOUT mjpg_io_mu -- we'd deadlock against // the in-flight SSL_read otherwise. if (obn::os::socket_valid(t->fd)) obn::os::shutdown_both(t->fd); // Step 2: serialise with the reader. Once we hold mjpg_io_mu nobody // can be inside SSL_read on this tunnel, so SSL_free is safe. { std::lock_guard lk(t->mjpg_io_mu); if (t->ssl) { // Best-effort close_notify; the printer doesn't care and // the socket is already half-shut so SSL_shutdown will // return quickly even if the write fails. SSL_shutdown(t->ssl); SSL_free(t->ssl); t->ssl = nullptr; } if (obn::os::socket_valid(t->fd)) { obn::os::close_socket(t->fd); t->fd = obn::os::kInvalidSocket; } } if (t->rtsp_pass) { // stop() joins the worker thread and tears the RTSP client // down. Reset the unique_ptr afterwards so a half-destroyed // passthrough cannot be reached again on a Bambu_Open retry. t->rtsp_pass->stop(); t->rtsp_pass.reset(); } } // Writes `len` bytes via SSL, handling short writes. Returns 0 on OK. int ssl_write_all(SSL* ssl, const void* buf, size_t len) { const auto* p = static_cast(buf); size_t sent = 0; while (sent < len) { int n = SSL_write(ssl, p + sent, static_cast(len - sent)); if (n <= 0) { int err = SSL_get_error(ssl, n); if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) continue; return -1; } sent += static_cast(n); } return 0; } // Reads exactly `len` bytes. Returns 0 on OK, 1 on EOF, -1 on error. // // The SSL object and SSL_get_error access are taken under // `t->mjpg_io_mu` so a concurrent tunnel_close() cannot pull SSL out // from under us. tunnel_close() shuts the socket down first, which // causes the in-flight SSL_read to return promptly with an error so // we drop the lock and let the closer make progress. int ssl_read_all(Tunnel* t, void* buf, size_t len) { auto* p = static_cast(buf); size_t got = 0; while (got < len) { if (t->closing.load(std::memory_order_acquire)) return -1; int n; int err = SSL_ERROR_NONE; { std::lock_guard lk(t->mjpg_io_mu); if (!t->ssl || t->closing.load(std::memory_order_acquire)) return -1; n = SSL_read(t->ssl, p + got, static_cast(len - got)); if (n <= 0) err = SSL_get_error(t->ssl, n); } if (n <= 0) { if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) continue; if (err == SSL_ERROR_ZERO_RETURN) { log_at(LL_DEBUG, t->logger, t->log_ctx, "ssl_read_all: clean EOF after %zu/%zu bytes", got, len); return 1; } log_at(LL_DEBUG, t->logger, t->log_ctx, "ssl_read_all: SSL_read failed n=%d err=%d errno=%d " "after %zu/%zu bytes", n, err, errno, got, len); return -1; } got += static_cast(n); } return 0; } // ----------------------------------------------------------------------- // RTSP(S) video. We do not transcode: the printer sends H.264 over // RTP and gstbambusrc.c (vendored verbatim by both Bambu Studio and // Orca Slicer on Linux) feeds whatever Bambu_ReadSample returns into // `h264parse ! avdec_h264 / openh264dec / vaapih264dec`. So this side // only has to do RTSP/RTSPS handshake + RTP depacketisation + Annex-B // framing. All of that lives in stubs/rtsp_client.cpp and // stubs/rtsp_passthrough.cpp; here we just glue them onto the C ABI. // ----------------------------------------------------------------------- [[maybe_unused]] int open_rtsp(Tunnel* t) { auto pass = std::make_unique(t->logger, t->log_ctx); log_fmt(t->logger, t->log_ctx, "open_rtsp: dialing %s://%s:%d (user=%s)", t->url.scheme == Scheme::Rtsps ? "rtsps" : "rtsp", t->url.host.c_str(), t->url.port, t->url.user.c_str()); if (pass->start(t->url.host, t->url.port, t->url.user, t->url.passwd, t->url.path, t->url.scheme == Scheme::Rtsps, t->url.device) != 0) { return -1; } t->rtsp_pass = std::move(pass); // gstbambusrc looks at sub_type via Bambu_GetStreamInfo; AVC1 + // video_avc_byte_stream is the format gstbambusrc's downstream // pipeline already speaks (h264parse copes with any framing // h264parse can detect, and Annex-B is the simplest one). t->sub_type = AVC1; // Width/height/frame_rate are advisory until h264parse pulls them // out of SPS; surface the firmware's well-known 1280x720@30 default // so Studio's UI shows reasonable numbers from the start. t->width = 1280; t->height = 720; t->frame_rate = 30; t->t0 = std::chrono::steady_clock::now(); t->started = true; log_fmt(t->logger, t->log_ctx, "open_rtsp: passthrough ready (avc1 %dx%d, gstbambusrc decodes)", t->width, t->height); return Bambu_success; } int read_rtsp(Tunnel* t, Bambu_Sample* sample) { if (!t->rtsp_pass) return -1; const std::uint8_t* buf = nullptr; std::size_t size = 0; std::uint64_t dt_100ns = 0; int flags = 0; auto rc = t->rtsp_pass->try_pull(&buf, &size, &dt_100ns, &flags); switch (rc) { case obn::rtsp::Passthrough::Pull_Ok: break; case obn::rtsp::Passthrough::Pull_WouldBlock: return Bambu_would_block; case obn::rtsp::Passthrough::Pull_StreamEnd: return Bambu_stream_end; case obn::rtsp::Passthrough::Pull_Error: default: return -1; } sample->itrack = 0; sample->size = static_cast(size); sample->flags = flags; sample->buffer = buf; sample->decode_time = static_cast(dt_100ns); return Bambu_success; } // ----------------------------------------------------------------------- // Build the 80-byte auth packet per OpenBambuAPI/video.md. // ----------------------------------------------------------------------- void build_auth_packet(const TunnelUrl& url, uint8_t out[80]) { std::memset(out, 0, 80); auto put_u32_le = [&](size_t off, uint32_t v) { out[off + 0] = static_cast( v & 0xff); out[off + 1] = static_cast((v >> 8) & 0xff); out[off + 2] = static_cast((v >> 16) & 0xff); out[off + 3] = static_cast((v >> 24) & 0xff); }; put_u32_le(0, 0x40); // payload size (always 0x40 for auth) put_u32_le(4, 0x3000); // packet type (auth) put_u32_le(8, 0); // flags put_u32_le(12, 0); // Username / password into 32-byte fixed-size fields, NUL-padded. std::memcpy(out + 16, url.user.data(), std::min(url.user.size(), 32)); std::memcpy(out + 48, url.passwd.data(), std::min(url.passwd.size(), 32)); } // Thin aliases onto the shared zip reader (include/obn/zip_reader.hpp). // PKZip is the container format for .3mf; we extract the plate PNG // preview out of it on the fly to feed Studio's MediaFilePanel. using ZipEntry = obn::zip::Entry; static inline bool zip_read_central(const std::vector& z, std::vector* o) { return obn::zip::read_central(z, o); } static inline bool zip_extract(const std::vector& z, const ZipEntry& e, std::vector* o) { return obn::zip::extract(z, e, o); } static inline const ZipEntry* zip_find(const std::vector& d, const std::string& name) { return obn::zip::find(d, name); } // ImageGrid.cpp renders each tile's thumbnail via // dc.SetUserScale(content_w / img_w, content_h / img_h) // i.e. two independent scale factors, so any thumbnail whose aspect // doesn't already match the tile comes out visibly squashed/stretched. // The images we serve (3mf plate previews and timelapse sidecars) are // mostly square, whereas the tiles are 4:3-ish (models) or ~16:9 // (timelapse/video), so the stock UI looks wrong without intervention. // // We pre-letterbox at the plugin side: decode the image, paste it on // a transparent (PNG) canvas with the tile aspect, re-encode as PNG. // Studio then ends up with hs==vs, the content stays undistorted, and // the padding is invisible. // // Decoder accepts PNG or JPEG (vendored stb_image; see image_io.hpp). // Output is always PNG so we only need one encoder and we get a clean // alpha channel for the padding. Caller updates its `mime` accordingly. // // We deliberately do not link against libpng / libjpeg-turbo here: // libBambuSource is dlopen'd inside Bambu Studio's process where // versions of those libraries are routinely mismatched with what // Studio's own GStreamer plugins (gstjpeg, etc.) and wxWidgets pull // in. The libjpeg-turbo ABI tag check ("Wrong JPEG library version: // library is 80, caller expects 62") would then abort() the whole // process the moment any party touches libjpeg through our globally // loaded copy. stb_image side-steps that entire family of bugs. // // Never fails destructively: any decode/encode error just returns the // original bytes unchanged and the caller keeps its original mime. using DecodedRGBA = obn::image::DecodedRGBA; // Fit modes for reshape_image_to_aspect: // // Pad - add transparent bars so the source content is fully // visible. Good when the source is already "cropped" to // something meaningful and losing edges would be bad // (e.g. the plate render inside a .3mf already has its // own framing around the model). // Crop - center-crop the minor axis so the result fills the // tile edge-to-edge. Good when the source has natural // margin that can be sacrificed. Loses content on the // cropped axis but avoids visible bars. // Stretch - bilinearly resample along the minor axis so the // result fills the tile edge-to-edge with no padding // and no content loss. This is the right choice when // the source is already anamorphically compressed by // the producer - e.g. P2S timelapse sidecars, which are // a 16:9 camera frame squashed into a 480x480 JPEG on // the printer's FTPS storage. Re-stretching restores // the original geometry. enum class FitMode { Pad, Crop, Stretch }; // Reshape `in` (PNG or JPEG) to `target_aspect` and re-encode as PNG. // On any failure - unknown format, decode error, aspect already close // enough - returns the original bytes and leaves `*mime` alone. On // success, writes the new PNG bytes and sets `*mime` to "image/png". std::vector reshape_image_to_aspect(const std::vector& in, double target_aspect, FitMode mode, std::string* mime) { if (in.empty() || !(target_aspect > 0.0)) return in; DecodedRGBA img; if (!obn::image::decode_rgba(in, &img)) return in; const double src_aspect = static_cast(img.w) / static_cast(img.h); // Within ~1% of target: not worth re-encoding. if (std::fabs(src_aspect - target_aspect) / target_aspect < 0.01) return in; DecodedRGBA canvas; if (mode == FitMode::Pad) { // Add transparent bars on the minor axis. std::uint32_t nw = img.w; std::uint32_t nh = img.h; std::uint32_t ox = 0; std::uint32_t oy = 0; if (src_aspect < target_aspect) { nw = static_cast(std::llround(img.h * target_aspect)); if (nw <= img.w) return in; ox = (nw - img.w) / 2; } else { nh = static_cast(std::llround(img.w / target_aspect)); if (nh <= img.h) return in; oy = (nh - img.h) / 2; } canvas.w = nw; canvas.h = nh; canvas.pixels.assign(static_cast(nw) * nh * 4, 0); for (std::uint32_t y = 0; y < img.h; ++y) { std::memcpy(canvas.pixels.data() + ((static_cast(oy + y) * nw) + ox) * 4, img.pixels.data() + static_cast(y) * img.w * 4, static_cast(img.w) * 4); } } else if (mode == FitMode::Crop) { // Center-crop the minor axis. std::uint32_t nw = img.w; std::uint32_t nh = img.h; std::uint32_t sx = 0; std::uint32_t sy = 0; if (src_aspect < target_aspect) { // Too tall: crop top/bottom. nh = static_cast(std::llround(img.w / target_aspect)); if (nh == 0 || nh >= img.h) return in; sy = (img.h - nh) / 2; } else { // Too wide: crop sides. nw = static_cast(std::llround(img.h * target_aspect)); if (nw == 0 || nw >= img.w) return in; sx = (img.w - nw) / 2; } canvas.w = nw; canvas.h = nh; canvas.pixels.assign(static_cast(nw) * nh * 4, 0); for (std::uint32_t y = 0; y < nh; ++y) { std::memcpy(canvas.pixels.data() + static_cast(y) * nw * 4, img.pixels.data() + ((static_cast(sy + y) * img.w) + sx) * 4, static_cast(nw) * 4); } } else { // Stretch: bilinearly resample the minor axis so the result // hits target_aspect with no padding or cropping. For P2S // timelapse sidecars this undoes the 16:9 -> 1:1 squashing // the printer firmware applies before writing the JPEG to // /timelapse/thumbnail/*.jpg. std::uint32_t nw = img.w; std::uint32_t nh = img.h; if (src_aspect < target_aspect) { nw = static_cast(std::llround(img.h * target_aspect)); } else { nh = static_cast(std::llround(img.w / target_aspect)); } if (nw == 0 || nh == 0) return in; canvas.w = nw; canvas.h = nh; canvas.pixels.assign(static_cast(nw) * nh * 4, 0); const double x_ratio = nw > 1 ? static_cast(img.w - 1) / (nw - 1) : 0.0; const double y_ratio = nh > 1 ? static_cast(img.h - 1) / (nh - 1) : 0.0; for (std::uint32_t y = 0; y < nh; ++y) { const double sy = y * y_ratio; const std::uint32_t y0 = static_cast(sy); const std::uint32_t y1 = std::min(y0 + 1, img.h - 1); const double fy = sy - y0; for (std::uint32_t x = 0; x < nw; ++x) { const double sx = x * x_ratio; const std::uint32_t x0 = static_cast(sx); const std::uint32_t x1 = std::min(x0 + 1, img.w - 1); const double fx = sx - x0; const auto* p00 = img.pixels.data() + (static_cast(y0) * img.w + x0) * 4; const auto* p01 = img.pixels.data() + (static_cast(y0) * img.w + x1) * 4; const auto* p10 = img.pixels.data() + (static_cast(y1) * img.w + x0) * 4; const auto* p11 = img.pixels.data() + (static_cast(y1) * img.w + x1) * 4; auto* dst = canvas.pixels.data() + (static_cast(y) * nw + x) * 4; for (int c = 0; c < 4; ++c) { const double top = p00[c] + (p01[c] - p00[c]) * fx; const double bottom = p10[c] + (p11[c] - p10[c]) * fx; const double v = top + (bottom - top) * fy; dst[c] = static_cast(std::lround( std::max(0.0, std::min(255.0, v)))); } } } } std::vector out; if (!obn::image::encode_png(canvas, &out)) return in; if (mime) *mime = "image/png"; return out; } // Target aspects for the tile types ImageGrid renders. Derived from // ImageGrid.cpp::UpdateFileSystem / renderContent2: // // F_MODEL: content 266 x (264 - 64) = 266 x 200 -> 1.33:1 (4:3) // F_TIMELAPSE: content 384 x 216 -> 1.78:1 (16:9) // F_VIDEO: same as timelapse // // (Group mode uses 480x270 which is also 16:9, so one constant covers // both list and group views.) constexpr double kModelTileAspect = 266.0 / 200.0; constexpr double kTimelapseTileAspect = 384.0 / 216.0; // -------------------------------------------------------------------- // CTRL helpers: JSON assembly, FTPS connect, path handling. // -------------------------------------------------------------------- // Builds the on-wire reply bytes Studio expects. `reply_json` is the // `{cmdtype, sequence, result, reply}` envelope as a Value. Optional // blob is appended after "\n\n". std::string make_wire_reply(const obn::json::Value& env, const std::uint8_t* blob, std::size_t blob_len) { std::string s = env.dump(); if (blob_len > 0) { s += "\n\n"; s.append(reinterpret_cast(blob), blob_len); } return s; } obn::json::Value make_reply_envelope(int cmdtype, int sequence, int result, obn::json::Value reply) { obn::json::Object o; o["cmdtype"] = obn::json::Value(static_cast(cmdtype)); o["sequence"] = obn::json::Value(static_cast(sequence)); o["result"] = obn::json::Value(static_cast(result)); o["reply"] = std::move(reply); return obn::json::Value(std::move(o)); } // Parses the inbound "\n\n" wire format Studio sends. bool parse_ctrl_request(const std::string& wire, int* cmdtype, int* sequence, obn::json::Value* req) { // Body is either just JSON, or JSON + "\n\n" + blob. Blobs on the // request side only appear for FILE_UPLOAD (not implemented). std::size_t j_end = wire.find("\n\n"); std::string j = (j_end == std::string::npos) ? wire : wire.substr(0, j_end); std::string perr; auto v = obn::json::parse(j, &perr); if (!v) return false; auto ct = v->find("cmdtype"); auto sq = v->find("sequence"); if (!ct.is_number() || !sq.is_number()) return false; *cmdtype = static_cast(ct.as_number()); *sequence = static_cast(sq.as_number()); *req = v->find("req"); return true; } // Connects a fresh FTPS client on demand (the first CTRL request). // Kept on the tunnel so multiple requests reuse the control channel. std::string ensure_ftp(Tunnel* t) { if (t->ftp) return {}; t->ftp = std::make_unique(); obn::ftps::ConnectConfig cfg; cfg.host = t->url.host; cfg.port = 990; cfg.username = t->url.user.empty() ? "bblp" : t->url.user; cfg.password = t->url.passwd; if (!obn::lan_tls::skip_verify_from_env()) { if (const char* ca = obn::lan_tls::resolve_lan_ca_file()) { cfg.ca_file = ca; } if (const char* serial = obn::lan_tls::wait_env_serial( cfg.host.c_str(), obn::lan_tls::serial_env_wait_ms())) { cfg.tls_verify_hostname = serial; } } log_fmt(t->logger, t->log_ctx, "ctrl: FTPS connect host=%s user=%s", cfg.host.c_str(), cfg.username.c_str()); std::string err = t->ftp->connect(cfg); if (!err.empty()) { log_fmt(t->logger, t->log_ctx, "ctrl: FTPS connect failed: %s", err.c_str()); t->ftp.reset(); return err; } // Probe storage roots to figure out our prefix. A Bambu P1/X1 with // an SD card exposes `/sdcard`; newer P2S/A-series with a USB stick // exposes `/usb`. Some firmwares expose both; some P2S units expose // neither because the root IS the storage mount. if (t->ftp->cwd("/sdcard").empty()) { t->storage_label = "sdcard"; t->ftp_prefix = "/sdcard"; t->root_is_storage = false; } else if (t->ftp->cwd("/usb").empty()) { t->storage_label = "usb"; t->ftp_prefix = "/usb"; t->root_is_storage = false; } else if (t->ftp->cwd("/").empty()) { // P2S-style: root IS the storage. Label it "sdcard" because // Studio's MediaFilePanel doesn't care but its storage dropdown // displays whatever string we hand it. t->storage_label = "sdcard"; t->ftp_prefix = ""; t->root_is_storage = true; } else { log_fmt(t->logger, t->log_ctx, "ctrl: no accessible storage mount (neither /sdcard, /usb, nor /)"); return "no storage mount"; } log_fmt(t->logger, t->log_ctx, "ctrl: storage=%s prefix='%s' root_is_storage=%d", t->storage_label.c_str(), t->ftp_prefix.c_str(), t->root_is_storage ? 1 : 0); return {}; } // Tears down the current FTPS client and reconnects from scratch. // // Bambu firmware closes idle control connections after roughly five // minutes (we've seen "221 Service closing" come back on a PASV that // followed a long UI pause). Without recovery the next CTRL request // reports an empty directory / file-not-found and the user has to // reopen the file panel. Reconnect-once on first error masks the // timeout transparently. std::string reconnect_ftp(Tunnel* t) { if (t->ftp) { log_at(LL_DEBUG, t->logger, t->log_ctx, "ctrl: dropping stale FTPS session"); t->ftp->quit(); t->ftp.reset(); } return ensure_ftp(t); } // Subtree on the printer where Studio's `type` argument routes. // Studio asks for { "timelapse" | "video" | "model" }: // * timelapse -> /timelapse/ (mp4 + matching .jpg thumb) // * video -> /ipcam/ (.mp4 manual recordings; may be empty) // * model -> / (.gcode.3mf at the top level) // For P2S where prefix is "", we drop the leading slash off of // subpaths so FTPS doesn't resolve to the drive's root twice. std::string resolve_subtree(const Tunnel* t, const std::string& type) { if (type == "timelapse") return t->ftp_prefix + "/timelapse"; if (type == "video") return t->ftp_prefix + "/ipcam"; // "model" and anything else we don't recognise map to the root. return t->ftp_prefix.empty() ? "/" : t->ftp_prefix; } // Chooses what extensions a listing should surface to Studio for a // given file-type argument. Studio filters client-side too (by file // extension in FileObject::format), but filtering here means we don't // send 40 KB of irrelevant names over the CTRL channel. bool keep_for_type(const std::string& type, const std::string& name) { auto ends_with = [&](const char* suf) { std::size_t n = std::strlen(suf); return name.size() >= n && std::equal(name.end() - n, name.end(), suf, [](char a, char b) { return std::tolower(static_cast(a)) == std::tolower(static_cast(b)); }); }; if (type == "timelapse") return ends_with(".mp4") || ends_with(".avi"); if (type == "video") return ends_with(".mp4"); if (type == "model") return ends_with(".3mf") || ends_with(".gcode") || ends_with(".gcode.3mf"); return true; } // Time formatter Studio expects in LIST_INFO entries: "YYYY-MM-DD HH:MM:SS". std::string format_time_studio(std::uint64_t epoch) { if (epoch == 0) return ""; std::time_t t = static_cast(epoch); std::tm tm{}; obn::os::gmtime_safe(t, &tm); char buf[32]; std::strftime(buf, sizeof(buf), "%F %T", &tm); return buf; } // -------------------------------------------------------------------- // CTRL command handlers. Each one is synchronous and invoked from the // worker thread context; they push `CtrlReply`s onto `t->ctrl_out` // under `t->ctrl_mu`. // -------------------------------------------------------------------- void push_reply(Tunnel* t, CtrlReply r) { std::lock_guard lk(t->ctrl_mu); t->ctrl_out.emplace_back(std::move(r)); t->ctrl_cv.notify_all(); } bool is_cancelled(Tunnel* t, int sequence) { std::lock_guard lk(t->ctrl_mu); return t->ctrl_cancelled.count(sequence) > 0; } void handle_media_ability(Tunnel* t, int sequence, const obn::json::Value& /*req*/) { std::string err = ensure_ftp(t); obn::json::Object reply; obn::json::Array storage; if (err.empty()) { storage.emplace_back(t->storage_label); } reply["storage"] = obn::json::Value(std::move(storage)); // Studio's MediaFilePanel is satisfied with just storage; some // firmwares also advertise `ability.file_list` etc. - we can add // them later once something in Studio starts consulting them. auto env = make_reply_envelope(kCmdRequestMediaAbility, sequence, err.empty() ? kResOK : kResStorUnavail, obn::json::Value(std::move(reply))); push_reply(t, {make_wire_reply(env, nullptr, 0)}); } void handle_list_info(Tunnel* t, int sequence, const obn::json::Value& req) { std::string type = req.find("type").as_string(); // Storage routing: Studio's dropdown uses whatever labels we sent // in REQUEST_MEDIA_ABILITY, so we can safely ignore req.storage. std::string err = ensure_ftp(t); if (!err.empty()) { auto env = make_reply_envelope(kCmdListInfo, sequence, kResStorUnavail, obn::json::Value(obn::json::Object{})); push_reply(t, {make_wire_reply(env, nullptr, 0)}); return; } std::string path = resolve_subtree(t, type); std::vector entries; err = t->ftp->list_entries(path, &entries); if (!err.empty()) { // Could be a real "directory missing" or a stale control // connection (Bambu firmware drops idle FTPS after ~5 min). // Try once with a fresh session to disambiguate before // surfacing an empty list to Studio. log_at(LL_DEBUG, t->logger, t->log_ctx, "ctrl: LIST_INFO type=%s path=%s: %s — reconnecting once", type.c_str(), path.c_str(), err.c_str()); if (reconnect_ftp(t).empty()) { // Storage prefix may have changed across the reconnect; // re-resolve to be safe. path = resolve_subtree(t, type); entries.clear(); err = t->ftp->list_entries(path, &entries); } } if (!err.empty()) { // Directory missing on this printer (e.g. no timelapses yet). // Return an empty list rather than an error - Studio treats // that as "nothing to show". log_fmt(t->logger, t->log_ctx, "ctrl: LIST_INFO type=%s path=%s: %s (returning empty)", type.c_str(), path.c_str(), err.c_str()); obn::json::Object reply; reply["file_lists"] = obn::json::Value(obn::json::Array{}); auto env = make_reply_envelope(kCmdListInfo, sequence, kResOK, obn::json::Value(std::move(reply))); push_reply(t, {make_wire_reply(env, nullptr, 0)}); return; } obn::json::Array files; for (const auto& e : entries) { if (e.is_dir) continue; if (!keep_for_type(type, e.name)) continue; obn::json::Object f; f["name"] = obn::json::Value(e.name); // `path` on the Studio side is the FTPS absolute path we'll // receive back in FILE_DOWNLOAD / SUB_FILE. Keep it fully // qualified for that reason. // // On printers where the FTPS root IS the storage volume // (P2S/USB), resolve_subtree("model") returns "/", so a naive // concat would give "//name". vsftpd tolerates that, but // Studio does its own path string compares against the // request paths it sent, so it's cleaner to emit just a // single leading slash. std::string full = (path == "/") ? ("/" + e.name) : (path + "/" + e.name); f["path"] = obn::json::Value(full); f["size"] = obn::json::Value(static_cast(e.size)); if (e.mtime != 0) { f["time"] = obn::json::Value(static_cast(e.mtime)); f["date"] = obn::json::Value(format_time_studio(e.mtime)); } files.emplace_back(obn::json::Value(std::move(f))); } obn::json::Object reply; reply["file_lists"] = obn::json::Value(std::move(files)); auto env = make_reply_envelope(kCmdListInfo, sequence, kResOK, obn::json::Value(std::move(reply))); push_reply(t, {make_wire_reply(env, nullptr, 0)}); } // Downloads a file over FTPS into memory (for SUB_FILE thumbnails or // small 3mf previews). Caller decides if it's worth doing based on // size - we cap at 64 MB to avoid OOM on a pathological input. // // Retries once after a stale-session reconnect: the printer drops idle // FTPS control after ~5 minutes, and thumbnail fetches hit that window // often when the user is browsing the file panel. std::string download_blob(Tunnel* t, const std::string& full_path, std::vector* out) { constexpr std::size_t kMax = 64u * 1024u * 1024u; auto do_retr = [&]() { out->clear(); // Capture kMax explicitly: MSVC requires it inside the inner // lambda even though it is constexpr at namespace-adjacent // scope (gcc accepts the unnamed reference, MSVC does not). return t->ftp->retr(full_path, [out, kMax](const void* data, std::size_t len) { if (out->size() + len > kMax) return false; out->insert(out->end(), static_cast(data), static_cast(data) + len); return true; }); }; std::string err = do_retr(); if (err.empty()) return {}; log_at(LL_DEBUG, t->logger, t->log_ctx, "ctrl: download_blob %s failed (%s) — reconnecting once", full_path.c_str(), err.c_str()); if (!reconnect_ftp(t).empty()) return err; return do_retr(); } // SUB_FILE covers several unrelated Studio operations; the only // discriminator is the shape of the `req` JSON: // // * req.files: ["name1", "name2"] -- legacy thumbnail fetch. Each // entry is served as a separate reply chunk, result=CONTINUE // until the last, which gets result=SUCCESS. // // * req.paths: ["path#thumbnail", "path#_rels/.rels", ...] -- modern // thumbnail / metadata fetch. The part after '#' names either // "thumbnail" (meaning the sidecar jpg for timelapses, or // Metadata/plate_1.png inside the .3mf) or a literal entry path // inside a zip archive. // // * req.zip=true -- caller wants the whole archive in one blob // instead of per-file chunks. Used by the fetch-model path. void handle_sub_file(Tunnel* t, int sequence, const obn::json::Value& req) { std::string err = ensure_ftp(t); if (!err.empty()) { auto env = make_reply_envelope(kCmdSubFile, sequence, kResStorUnavail, obn::json::Value(obn::json::Object{})); push_reply(t, {make_wire_reply(env, nullptr, 0)}); return; } // Keep the owning Values alive for the entire handler - as_array() // returns a reference into the Value, so we can't chain it off a // temporary. obn::json::Value paths_v = req.find("paths"); obn::json::Value files_v = req.find("files"); const auto& paths = paths_v.as_array(); const auto& files = files_v.as_array(); bool zip_mode = req.find("zip").as_bool(false); { std::string first_path; if (!paths.empty()) first_path = paths[0].as_string(); else if (!files.empty()) first_path = files[0].as_string(); log_fmt(t->logger, t->log_ctx, "ctrl: SUB_FILE seq=%d zip=%d paths=%zu files=%zu first=%s", sequence, zip_mode ? 1 : 0, paths.size(), files.size(), first_path.c_str()); } // Helper that pushes one blob-bearing reply. auto push_chunk = [&](int result, const obn::json::Object& extra, const std::uint8_t* blob, std::size_t blen) { obn::json::Object r = extra; auto env = make_reply_envelope(kCmdSubFile, sequence, result, obn::json::Value(std::move(r))); // Dump envelope JSON for every chunk so we can see exactly // what Studio parses. Blob bytes are omitted for brevity. if (const char* dbg = std::getenv("OBN_DEBUG_SUBFILE")) { if (dbg && dbg[0] == '1') { log_fmt(t->logger, t->log_ctx, "ctrl: SUB_FILE reply seq=%d blen=%zu env=%s", sequence, blen, env.dump().c_str()); } } push_reply(t, {make_wire_reply(env, blob, blen)}); }; if (!paths.empty() && zip_mode) { // zip=true means Studio wants to receive a real ZIP archive it // will parse itself (FetchModel -> load_gcode_3mf_from_stream). // The `paths` list enumerates the individual entries it'll // later read out of that zip (_rels/.rels, 3D/3dmodel.model, // Metadata/*, plate_thumbnail_*), all under one base file. It // is NOT asking us to synthesize a new archive - handing back // the source .3mf bytes unchanged is the simplest thing that // satisfies Studio's parser. // // Studio's FetchModel callback just keeps concatenating chunk // bytes into one string, so we can stream the archive in 256- // KB slices directly off FTPS without pre-buffering the whole // thing in memory. std::vector bases; bases.reserve(paths.size()); for (std::size_t i = 0; i < paths.size(); ++i) { std::string full = paths[i].as_string(); auto hash = full.find('#'); if (hash != std::string::npos) full = full.substr(0, hash); if (std::find(bases.begin(), bases.end(), full) == bases.end()) bases.push_back(full); } // Buffer the whole archive in memory and split it into chunks // only after the FTPS transfer finishes. This lets us mark the // LAST non-empty chunk of each base file with `continue=false` // reliably even when the file size happens to be an exact // multiple of our chunk boundary - which UpdateFocusThumbnail2 // interprets as FILE_SIZE_ERR and drops the whole thumbnail. // Typical .3mf sizes are a few MB; buffering isn't an issue. constexpr std::size_t kChunkSize = 256 * 1024; for (std::size_t bi = 0; bi < bases.size(); ++bi) { const std::string& full = bases[bi]; if (is_cancelled(t, sequence)) { push_chunk(kResErrCancel, {}, nullptr, 0); return; } std::uint64_t total_size = 0; t->ftp->size(full, &total_size); std::vector archive; archive.reserve(static_cast(total_size ? total_size : kChunkSize)); bool cancelled = false; auto retr_into_archive = [&]() { archive.clear(); cancelled = false; return t->ftp->retr(full, [&](const void* data, std::size_t len) { if (is_cancelled(t, sequence)) { cancelled = true; return false; } const std::uint8_t* p = static_cast(data); archive.insert(archive.end(), p, p + len); return true; }); }; std::string err = retr_into_archive(); if (!err.empty() && !cancelled) { // Stale control connection? Reconnect once and retry. // Safe because we haven't pushed any chunk to Studio // for this base file yet. log_at(LL_DEBUG, t->logger, t->log_ctx, "ctrl: SUB_FILE(zip) %s failed (%s) — reconnecting once", full.c_str(), err.c_str()); if (reconnect_ftp(t).empty()) { t->ftp->size(full, &total_size); err = retr_into_archive(); } } if (cancelled) { push_chunk(kResErrCancel, {}, nullptr, 0); return; } if (!err.empty()) { log_fmt(t->logger, t->log_ctx, "ctrl: SUB_FILE(zip) %s failed: %s", full.c_str(), err.c_str()); auto env = make_reply_envelope(kCmdSubFile, sequence, kResFileNoExist, obn::json::Value(obn::json::Object{})); push_reply(t, {make_wire_reply(env, nullptr, 0)}); return; } const bool is_last_file = (bi + 1 == bases.size()); const std::size_t total = archive.size(); log_fmt(t->logger, t->log_ctx, "ctrl: SUB_FILE(zip) seq=%d %s bytes=%zu chunks=%zu last_file=%d", sequence, full.c_str(), total, (total + kChunkSize - 1) / kChunkSize, is_last_file ? 1 : 0); // Debug helper: dump assembled archive to /tmp so we can // verify it parses cleanly with an external unzipper. if (const char* dump = std::getenv("OBN_DUMP_SUBFILE_ZIP")) { if (dump && dump[0] == '1' && total > 0) { std::string name = full; for (auto& c : name) if (c == '/') c = '_'; std::string path = "/tmp/obn-" + name; if (FILE* fp = std::fopen(path.c_str(), "wb")) { std::fwrite(archive.data(), 1, total, fp); std::fclose(fp); log_fmt(t->logger, t->log_ctx, "ctrl: SUB_FILE(zip) dumped %s (%zu bytes)", path.c_str(), total); } } } if (total == 0) { // Truly empty file - Studio's translator would emit // FILE_SIZE_ERR regardless of continue/size flags. Send // a terminal response to unstick the sequence. obn::json::Object extra; extra["path"] = obn::json::Value(full); extra["mimetype"] = obn::json::Value("application/zip"); extra["size"] = obn::json::Value(static_cast(0)); extra["offset"] = obn::json::Value(static_cast(0)); extra["total"] = obn::json::Value(static_cast(0)); extra["continue"] = obn::json::Value(false); int result = is_last_file ? kResOK : kResContinue; push_chunk(result, extra, nullptr, 0); continue; } std::size_t offset = 0; while (offset < total) { std::size_t chunk = std::min(kChunkSize, total - offset); bool last_chunk = (offset + chunk == total); obn::json::Object extra; extra["path"] = obn::json::Value(full); extra["mimetype"] = obn::json::Value("application/zip"); extra["size"] = obn::json::Value(static_cast(chunk)); extra["offset"] = obn::json::Value(static_cast(offset)); extra["total"] = obn::json::Value(static_cast(total)); // Studio's ModelMetadata translator accumulates slices // into iter->local_path as long as `continue` is true; // on the first `continue=false` it assembles the full // buffer and runs load_gcode_3mf_from_stream on it. // So we MUST set continue=true for every non-terminal // slice of every base file, including cross-file // boundaries when the SUB_FILE request covers several. extra["continue"] = obn::json::Value(!last_chunk); bool is_final = last_chunk && is_last_file; int result = is_final ? kResOK : kResContinue; push_chunk(result, extra, archive.data() + offset, chunk); offset += chunk; } } return; } if (!paths.empty()) { // Gather candidates first so we know when to send SUCCESS vs CONTINUE. std::size_t total = paths.size(); for (std::size_t i = 0; i < total; ++i) { if (is_cancelled(t, sequence)) { push_chunk(kResErrCancel, {}, nullptr, 0); return; } std::string full = paths[i].as_string(); std::string sub_entry; auto hash = full.find('#'); if (hash != std::string::npos) { sub_entry = full.substr(hash + 1); full = full.substr(0, hash); } std::vector blob; std::string mime = "image/jpeg"; std::string thumb_path; if (sub_entry == "thumbnail") { // Timelapse/video path: printer stores sidecar jpegs in //

/thumbnail/.jpg (full, ~480x480) and // _mini.jpg (~240x194). Only the full one is // guaranteed to exist on older firmware; minis showed // up later. Full is still only ~15-20 KB so we always // fetch the full version and letterbox it to 16:9 for // the timelapse tile. // // Fall back to /.jpg and then to the 3mf // archive path (plate_*.png) so this keeps working on // printers that stash sidecars differently. auto ends_with = [&](const std::string& s, const char* suf) { std::size_t ls = std::strlen(suf); return s.size() >= ls && s.compare(s.size() - ls, ls, suf) == 0; }; // Split full path into dir / base / stem. std::string dir_part, base_part, stem; auto slash = full.rfind('/'); if (slash != std::string::npos) { dir_part = full.substr(0, slash); base_part = full.substr(slash + 1); } else { base_part = full; } stem = base_part; auto dot = stem.rfind('.'); if (dot != std::string::npos) stem = stem.substr(0, dot); std::vector candidates; if (ends_with(base_part, ".mp4")) { // Official firmware location for timelapse/video // thumbnails. candidates.push_back(dir_part + "/thumbnail/" + stem + ".jpg"); candidates.push_back(dir_part + "/thumbnail/" + stem + "_mini.jpg"); } // Legacy / generic sidecar next to the source file. candidates.push_back(dir_part.empty() ? ("/" + stem + ".jpg") : (dir_part + "/" + stem + ".jpg")); for (const auto& c : candidates) { blob.clear(); if (download_blob(t, c, &blob).empty() && !blob.empty()) { // Echo back the original request path so // Studio's callback in PrinterFileSystem.cpp // can match the reply to the right File: it // does `path.substr(0, path.find('#'))` and // compares to `f.path`. If we returned the // real sidecar location ("/timelapse/thumbnail // /foo.jpg") that match would fail silently // and the tile would stay empty even though // the bytes arrived fine. thumb_path = full + "#thumbnail"; mime = "image/jpeg"; // P2S firmware stores timelapse sidecars as 480x480 // JPEGs - a 16:9 camera frame anamorphically squashed // into a square. Stretch horizontally to restore the // original geometry before handing it to Studio. blob = reshape_image_to_aspect(blob, kTimelapseTileAspect, FitMode::Stretch, &mime); break; } } if (blob.empty()) { if (ends_with(full, ".3mf") || ends_with(full, ".gcode.3mf")) { // .3mf path: read whole archive, extract plate_1.png. std::vector zipbuf; if (download_blob(t, full, &zipbuf).empty()) { std::vector dir; if (zip_read_central(zipbuf, &dir)) { const ZipEntry* e = zip_find(dir, "Metadata/plate_1.png"); if (!e) e = zip_find(dir, "Metadata/plate_no_light_1.png"); if (e && zip_extract(zipbuf, *e, &blob)) { mime = "image/png"; thumb_path = full + "#" + e->name; blob = reshape_image_to_aspect(blob, kModelTileAspect, FitMode::Pad, &mime); } } } } // Anything still without a thumbnail: reply size=0 // and let Studio fall back to its default icon. if (blob.empty() && thumb_path.empty()) thumb_path = full; } } else if (!sub_entry.empty()) { // Arbitrary entry inside a .3mf ZIP (Metadata/plate_1.png, // Metadata/model_settings.config, etc.) std::vector zipbuf; if (download_blob(t, full, &zipbuf).empty()) { std::vector dir; if (zip_read_central(zipbuf, &dir)) { const ZipEntry* e = zip_find(dir, sub_entry); if (e) zip_extract(zipbuf, *e, &blob); } } // Studio feeds `mimetype` straight into wxImage() for // thumbnail entries, so it MUST be "image/" for PNG/ // JPEG/etc. A generic octet-stream makes wxImage fail // silently and the tile stays blank. auto dot = sub_entry.rfind('.'); if (dot != std::string::npos) { std::string ext = sub_entry.substr(dot + 1); for (auto& c : ext) c = std::tolower(static_cast(c)); if (ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "gif" || ext == "bmp" || ext == "webp") { mime = "image/" + ext; } else { mime = "application/octet-stream"; } // Letterbox the F_MODEL plate previews: Studio stretches // them independently on X and Y to fill the tile, so // native 512x512 plate_*.png comes out squashed. if ((ext == "png" || ext == "jpg" || ext == "jpeg") && !blob.empty()) { blob = reshape_image_to_aspect(blob, kModelTileAspect, FitMode::Pad, &mime); } } else { mime = "application/octet-stream"; } thumb_path = full + "#" + sub_entry; } else { // Raw file fetch, no zip extraction. Studio uses this for // small auxiliary files; we cap at 64 MB (download_blob). download_blob(t, full, &blob); mime = "application/octet-stream"; thumb_path = full; } obn::json::Object extra; extra["path"] = obn::json::Value(thumb_path); extra["mimetype"] = obn::json::Value(mime); extra["size"] = obn::json::Value(static_cast(blob.size())); extra["offset"] = obn::json::Value(static_cast(0)); extra["total"] = obn::json::Value(static_cast(blob.size())); int result = (i + 1 == total) ? kResOK : kResContinue; if (blob.empty()) { // Studio uses `thumbnail` (base64 or raw) as the mimetype- // driven fallback when there's no blob; omit it and just // signal "no data" for this index. extra["size"] = obn::json::Value(static_cast(0)); push_chunk(result, extra, nullptr, 0); } else { push_chunk(result, extra, blob.data(), blob.size()); } } return; } if (!files.empty()) { // Legacy per-name thumbnail: we map every entry to its sidecar // .jpg under the timelapse subtree and reply in order. std::string subtree = resolve_subtree(t, "timelapse"); std::size_t total = files.size(); for (std::size_t i = 0; i < total; ++i) { if (is_cancelled(t, sequence)) { push_chunk(kResErrCancel, {}, nullptr, 0); return; } std::string name = files[i].as_string(); std::string stem = name; auto dot = stem.rfind('.'); if (dot != std::string::npos) stem = stem.substr(0, dot); std::string sidecar = subtree + "/" + stem + ".jpg"; std::vector blob; download_blob(t, sidecar, &blob); obn::json::Object extra; extra["name"] = obn::json::Value(name); extra["mimetype"] = obn::json::Value("image/jpeg"); extra["size"] = obn::json::Value(static_cast(blob.size())); int result = (i + 1 == total) ? kResOK : kResContinue; push_chunk(result, extra, blob.data(), blob.size()); } return; } push_chunk(kResApiUnsupport, {}, nullptr, 0); } void handle_file_download(Tunnel* t, int sequence, const obn::json::Value& req) { std::string err = ensure_ftp(t); if (!err.empty()) { auto env = make_reply_envelope(kCmdFileDownload, sequence, kResStorUnavail, obn::json::Value(obn::json::Object{})); push_reply(t, {make_wire_reply(env, nullptr, 0)}); return; } std::string path = req.find("path").as_string(); if (path.empty()) path = req.find("file").as_string(); if (path.empty()) { auto env = make_reply_envelope(kCmdFileDownload, sequence, kResFileNoExist, obn::json::Value(obn::json::Object{})); push_reply(t, {make_wire_reply(env, nullptr, 0)}); return; } // Need total size up front so Studio's progress bar can render; // SIZE is cheap on vsftpd. SIZE also doubles as a probe of the // control connection — if it failed, reconnect once and retry, // because the printer closes idle FTPS after ~5 min. std::uint64_t total = 0; { std::string size_err = t->ftp->size(path, &total); if (!size_err.empty()) { log_at(LL_DEBUG, t->logger, t->log_ctx, "ctrl: FILE_DOWNLOAD SIZE %s failed (%s) — reconnecting once", path.c_str(), size_err.c_str()); if (reconnect_ftp(t).empty()) { t->ftp->size(path, &total); } } } // Studio's download callback unconditionally reads `resp["file_md5"]` // once `offset + size == total`. If the field is missing nlohmann // throws type_error, the recv thread aborts, and the UI surfaces // "error 2". We accumulate MD5 incrementally via EVP so we can hand // it back on the final chunk without a second pass over the file. std::unique_ptr md_ctx( EVP_MD_CTX_new(), [](EVP_MD_CTX* c){ if (c) EVP_MD_CTX_free(c); }); if (!md_ctx || EVP_DigestInit_ex(md_ctx.get(), EVP_md5(), nullptr) != 1) { auto env = make_reply_envelope(kCmdFileDownload, sequence, kResFileNoExist, obn::json::Value(obn::json::Object{})); push_reply(t, {make_wire_reply(env, nullptr, 0)}); return; } // Stream from FTPS through `retr`, flushing reasonably-sized // chunks to Studio. Studio's HandleResponse concatenates chunks by // `offset`, so the chunk size just affects UI update cadence. 256 // KB gives ~smooth 1-Hz percent updates on a 20 MB timelapse. constexpr std::size_t kChunkSize = 256 * 1024; std::vector buf; buf.reserve(kChunkSize); std::uint64_t sent = 0; auto finalize_md5 = [&]() -> std::string { unsigned char digest[EVP_MAX_MD_SIZE] = {0}; unsigned digest_len = 0; EVP_DigestFinal_ex(md_ctx.get(), digest, &digest_len); static const char kHex[] = "0123456789abcdef"; std::string hex; hex.resize(digest_len * 2); for (unsigned i = 0; i < digest_len; ++i) { hex[2*i ] = kHex[(digest[i] >> 4) & 0xF]; hex[2*i + 1] = kHex[ digest[i] & 0xF]; } return hex; }; auto flush_chunk = [&](bool last) { obn::json::Object extra; extra["path"] = obn::json::Value(path); extra["offset"] = obn::json::Value(static_cast(sent)); extra["total"] = obn::json::Value(static_cast(total)); extra["size"] = obn::json::Value(static_cast(buf.size())); if (last) extra["file_md5"] = obn::json::Value(finalize_md5()); int result = last ? kResOK : kResContinue; auto env = make_reply_envelope(kCmdFileDownload, sequence, result, obn::json::Value(std::move(extra))); push_reply(t, {make_wire_reply(env, buf.data(), buf.size())}); sent += buf.size(); buf.clear(); }; bool cancelled = false; auto run_retr = [&]() { return t->ftp->retr(path, [&](const void* data, std::size_t len) { if (is_cancelled(t, sequence)) { cancelled = true; return false; } // Hash the whole stream (Studio MD5s the bytes it wrote, // which match what FTPS handed us modulo the chunk-size // boundary). EVP_DigestUpdate(md_ctx.get(), data, len); const std::uint8_t* p = static_cast(data); while (len > 0) { std::size_t take = std::min(len, kChunkSize - buf.size()); buf.insert(buf.end(), p, p + take); p += take; len -= take; if (buf.size() == kChunkSize) flush_chunk(/*last=*/false); } return true; }); }; err = run_retr(); if (!err.empty() && !cancelled && sent == 0 && buf.empty()) { // No chunks pushed yet — safe to drop and reconnect. Studio // hasn't started accumulating offsets so a clean restart is // transparent. log_at(LL_DEBUG, t->logger, t->log_ctx, "ctrl: FILE_DOWNLOAD %s failed (%s) — reconnecting once", path.c_str(), err.c_str()); if (reconnect_ftp(t).empty()) { // MD5 context already accumulated nothing; reset anyway so // a partial RETR can't poison the digest. EVP_MD_CTX_reset(md_ctx.get()); EVP_DigestInit_ex(md_ctx.get(), EVP_md5(), nullptr); err = run_retr(); } } if (cancelled) { auto env = make_reply_envelope(kCmdFileDownload, sequence, kResErrCancel, obn::json::Value(obn::json::Object{})); push_reply(t, {make_wire_reply(env, nullptr, 0)}); return; } if (!err.empty()) { log_fmt(t->logger, t->log_ctx, "ctrl: FILE_DOWNLOAD %s failed: %s", path.c_str(), err.c_str()); auto env = make_reply_envelope(kCmdFileDownload, sequence, kResFileNoExist, obn::json::Value(obn::json::Object{})); push_reply(t, {make_wire_reply(env, nullptr, 0)}); return; } flush_chunk(/*last=*/true); } void handle_file_del(Tunnel* t, int sequence, const obn::json::Value& req) { std::string err = ensure_ftp(t); if (!err.empty()) { auto env = make_reply_envelope(kCmdFileDel, sequence, kResStorUnavail, obn::json::Value(obn::json::Object{})); push_reply(t, {make_wire_reply(env, nullptr, 0)}); return; } // Studio hands us either { "delete": [name1,...] } (legacy, // filenames only - we need to prefix with current subtree, but we // don't know which) or { "paths": [fqpath1, ...] } (modern, fully // qualified). We only implement the modern form: `paths`. obn::json::Value paths_v = req.find("paths"); const auto& paths = paths_v.as_array(); obn::json::Array ok_names; int result = kResOK; bool reconnected = false; for (const auto& v : paths) { std::string p = v.as_string(); if (p.empty()) continue; std::string e = t->ftp->dele(p); if (!e.empty() && !reconnected) { // Stale control connection? Try once with a fresh session. // We only reconnect on the first error to avoid storming // the printer with retries when the file is genuinely // gone. log_at(LL_DEBUG, t->logger, t->log_ctx, "ctrl: DELE %s failed (%s) — reconnecting once", p.c_str(), e.c_str()); reconnected = true; if (reconnect_ftp(t).empty()) { e = t->ftp->dele(p); } } if (!e.empty()) { log_fmt(t->logger, t->log_ctx, "ctrl: DELE %s failed: %s", p.c_str(), e.c_str()); result = kResFileNoExist; } else { ok_names.emplace_back(obn::json::Value(p)); } } obn::json::Object reply; reply["paths"] = obn::json::Value(std::move(ok_names)); auto env = make_reply_envelope(kCmdFileDel, sequence, result, obn::json::Value(std::move(reply))); push_reply(t, {make_wire_reply(env, nullptr, 0)}); } void handle_task_cancel(Tunnel* t, int sequence, const obn::json::Value& req) { obn::json::Value tasks_v = req.find("tasks"); const auto& tasks = tasks_v.as_array(); obn::json::Array cancelled; std::string cancelled_log; { std::lock_guard lk(t->ctrl_mu); for (const auto& v : tasks) { int seq = static_cast(v.as_number()); t->ctrl_cancelled.insert(seq); cancelled.emplace_back(obn::json::Value(static_cast(seq))); if (!cancelled_log.empty()) cancelled_log += ","; cancelled_log += std::to_string(seq); } } log_fmt(t->logger, t->log_ctx, "ctrl: TASK_CANCEL seq=%d cancels=[%s]", sequence, cancelled_log.c_str()); obn::json::Object reply; reply["tasks"] = obn::json::Value(std::move(cancelled)); auto env = make_reply_envelope(kCmdTaskCancel, sequence, kResOK, obn::json::Value(std::move(reply))); push_reply(t, {make_wire_reply(env, nullptr, 0)}); } // -------------------------------------------------------------------- // Worker thread entry point. // -------------------------------------------------------------------- void ctrl_worker_main(Tunnel* t) { log_fmt(t->logger, t->log_ctx, "ctrl: worker started"); while (!t->ctrl_stop.load(std::memory_order_acquire)) { CtrlRequest req; { std::unique_lock lk(t->ctrl_mu); t->ctrl_cv.wait_for(lk, std::chrono::milliseconds(200), [&] { return t->ctrl_stop.load(std::memory_order_acquire) || !t->ctrl_in.empty(); }); if (t->ctrl_stop.load(std::memory_order_acquire)) break; if (t->ctrl_in.empty()) continue; req = std::move(t->ctrl_in.front()); t->ctrl_in.pop_front(); } int cmdtype = 0, sequence = 0; obn::json::Value body; if (!parse_ctrl_request(req.body, &cmdtype, &sequence, &body)) { log_fmt(t->logger, t->log_ctx, "ctrl: failed to parse request, ignoring"); continue; } log_fmt(t->logger, t->log_ctx, "ctrl: dispatch cmd=0x%04x seq=%d", cmdtype, sequence); switch (cmdtype) { case kCmdRequestMediaAbility: handle_media_ability(t, sequence, body); break; case kCmdListInfo: handle_list_info(t, sequence, body); break; case kCmdSubFile: handle_sub_file(t, sequence, body); break; case kCmdFileDownload: handle_file_download(t, sequence, body); break; case kCmdFileDel: handle_file_del(t, sequence, body); break; case kCmdTaskCancel: handle_task_cancel(t, sequence, body); break; default: { // FILE_UPLOAD and anything else we haven't implemented: // reply API_UNSUPPORT instead of hanging Studio on a // missing callback. auto env = make_reply_envelope(cmdtype, sequence, kResApiUnsupport, obn::json::Value(obn::json::Object{})); push_reply(t, {make_wire_reply(env, nullptr, 0)}); break; } } } if (t->ftp) { t->ftp->quit(); t->ftp.reset(); } log_fmt(t->logger, t->log_ctx, "ctrl: worker exited"); } // Kicks off the worker. Tears down any MJPG socket we opened in // Bambu_Open, because CTRL mode doesn't use it. int start_ctrl_mode(Tunnel* t) { if (t->ctrl_mode) return Bambu_success; // Close the MJPG socket - CTRL multiplexes over FTPS instead. if (t->ssl) { SSL_shutdown(t->ssl); SSL_free(t->ssl); t->ssl = nullptr; } if (obn::os::socket_valid(t->fd)) { obn::os::shutdown_both(t->fd); obn::os::close_socket(t->fd); t->fd = obn::os::kInvalidSocket; } t->ctrl_mode = true; t->ctrl_stop.store(false); t->ctrl_worker = std::thread(ctrl_worker_main, t); log_fmt(t->logger, t->log_ctx, "ctrl: switched to CTRL mode"); return Bambu_success; } // Shuts the worker down. Idempotent. void stop_ctrl_mode(Tunnel* t) { if (!t->ctrl_mode) return; { std::lock_guard lk(t->ctrl_mu); t->ctrl_stop.store(true, std::memory_order_release); t->ctrl_cv.notify_all(); } if (t->ctrl_worker.joinable()) t->ctrl_worker.join(); t->ctrl_mode = false; } } // namespace // ======================================================================= // Exported BambuLib API // ======================================================================= OBN_EXPORT int Bambu_Init() { ssl_init_once(); return Bambu_success; } OBN_EXPORT void Bambu_Deinit() { // No-op: we intentionally leak the global SSL_CTX until process exit. // Tearing it down while other tunnels might still be alive on a // different GstElement is not worth the race risk. } OBN_EXPORT int Bambu_Create(Bambu_Tunnel* tunnel, char const* path) { if (!tunnel || !path) return -1; ssl_init_once(); auto* t = new Tunnel(); // Hide the password from the mirror log but keep the host/port/user // portion so we know what the caller actually asked for. log_fmt(t->logger, t->log_ctx, "Bambu_Create: url=%.160s%s", path, std::strlen(path) > 160 ? "..." : ""); if (!parse_url(path, &t->url)) { log_fmt(t->logger, t->log_ctx, "Bambu_Create: bad URL"); delete t; set_last_error("bad URL"); return -1; } const char* scheme_name = (t->url.scheme == Scheme::Rtsps) ? "rtsps" : (t->url.scheme == Scheme::Rtsp) ? "rtsp" : "local"; log_fmt(t->logger, t->log_ctx, "Bambu_Create: parsed scheme=%s host=%s port=%d path=%s " "user=%s passwd=%s", scheme_name, t->url.host.c_str(), t->url.port, t->url.path.c_str(), t->url.user.c_str(), t->url.passwd.empty() ? "(empty!)" : "***"); if (!t->url.device.empty() && !t->url.host.empty()) { obn::lan_tls::registry_put_ip_serial(t->url.host, t->url.device); } *tunnel = t; return Bambu_success; } OBN_EXPORT void Bambu_SetLogger(Bambu_Tunnel tunnel, Logger logger, void* context) { auto* t = static_cast(tunnel); if (!t) return; t->logger = logger ? logger : noop_logger; t->log_ctx = context; } OBN_EXPORT int Bambu_Open(Bambu_Tunnel tunnel) { auto* t = static_cast(tunnel); if (!t) return -1; // RTSP(S) is so different from MJPG that it gets its own code path // (passthrough worker + RTSP handshake); MJPG stays as manual // TLS + auth packet below. Both the stock plugin and our passthrough // hand raw H.264 byte-stream back to gstbambusrc, so this path is // gated only on the URL scheme - not on OBN_ENABLE_WORKAROUNDS. if (t->url.scheme == Scheme::Rtsps || t->url.scheme == Scheme::Rtsp) { return open_rtsp(t); } log_fmt(t->logger, t->log_ctx, "Bambu_Open: dialing tls://%s:%d", t->url.host.c_str(), t->url.port); const char* serial = t->url.device.empty() ? nullptr : t->url.device.c_str(); if (obn::tls::dial_tls(t->url.host, t->url.port, /*timeout_ms=*/5000, &t->fd, &t->ssl, serial) != 0) { log_fmt(t->logger, t->log_ctx, "Bambu_Open: TLS dial failed: %s", obn::source::get_last_error()); return -1; } log_fmt(t->logger, t->log_ctx, "Bambu_Open: TLS established (cipher=%s)", SSL_get_cipher(t->ssl)); // Auth packet. uint8_t auth[80]; build_auth_packet(t->url, auth); if (ssl_write_all(t->ssl, auth, sizeof(auth)) != 0) { log_fmt(t->logger, t->log_ctx, "Bambu_Open: auth write failed"); tunnel_close(t); return -1; } t->t0 = std::chrono::steady_clock::now(); t->started = true; log_fmt(t->logger, t->log_ctx, "Bambu_Open: sent %zu-byte auth packet (user=%s pw_len=%zu)", sizeof(auth), t->url.user.c_str(), t->url.passwd.size()); return Bambu_success; } OBN_EXPORT int Bambu_StartStream(Bambu_Tunnel tunnel, bool /*video*/) { // Both protocols start streaming implicitly: // * MJPG: printer begins pushing frames right after auth. // * RTSP: Bambu_Open already started the passthrough worker. auto* t = static_cast(tunnel); if (!t) return -1; if (t->url.scheme == Scheme::Local && !t->ssl) return -1; if ((t->url.scheme == Scheme::Rtsps || t->url.scheme == Scheme::Rtsp) && !t->rtsp_pass) return -1; return Bambu_success; } OBN_EXPORT int Bambu_StartStreamEx(Bambu_Tunnel tunnel, int type) { auto* t = static_cast(tunnel); if (!t) return -1; // CTRL_TYPE (0x3001) means "open the PrinterFileSystem channel // instead of video". Studio's MediaFilePanel calls this right // after Bambu_Open to multiplex JSON requests over the same // tunnel. We tear down the MJPG TCP connection (which Bambu_Open // already brought up) and spin up the FTPS-backed worker. if (type == kCtrlType) { #if OBN_ENABLE_WORKAROUNDS return start_ctrl_mode(t); #else // Stock plugin speaks a proprietary CTRL wire we don't // implement. Without the workaround the MediaFilePanel stays // empty / "Browsing file in storage is not supported". set_last_error("PrinterFileSystem bridge disabled (OBN_ENABLE_WORKAROUNDS=OFF)"); log_fmt(t->logger, t->log_ctx, "Bambu_StartStreamEx(CTRL): refused, workarounds disabled"); return -1; #endif } return Bambu_StartStream(tunnel, true); } OBN_EXPORT int Bambu_GetStreamCount(Bambu_Tunnel tunnel) { auto* t = static_cast(tunnel); if (!t) return 0; if (t->url.scheme == Scheme::Local && !t->ssl) return 0; if ((t->url.scheme == Scheme::Rtsps || t->url.scheme == Scheme::Rtsp) && !t->rtsp_pass) return 0; return 1; // one video track (MJPEG for local-scheme, AVC1 for RTSP). } OBN_EXPORT int Bambu_GetStreamInfo(Bambu_Tunnel tunnel, int index, Bambu_StreamInfo* info) { auto* t = static_cast(tunnel); if (!t || !info || index != 0) return -1; std::memset(info, 0, sizeof(*info)); info->type = VIDE; info->sub_type = t->sub_type; info->format.video.width = t->width; info->format.video.height = t->height; info->format.video.frame_rate = t->frame_rate; // For H.264, gstbambusrc feeds the buffer into a decoder bin which // sniffs the byte stream; video_avc_byte_stream mirrors what the // proprietary plugin advertises in RTSP mode. info->format_type = (t->sub_type == MJPG) ? video_jpeg : video_avc_byte_stream; info->format_size = 0; info->max_frame_size = static_cast(kMaxFrameSize); info->format_buffer = nullptr; return Bambu_success; } OBN_EXPORT unsigned long Bambu_GetDuration(Bambu_Tunnel /*tunnel*/) { return 0; // live stream, no duration } OBN_EXPORT int Bambu_Seek(Bambu_Tunnel /*tunnel*/, unsigned long /*time*/) { return Bambu_success; // meaningless for a live stream } OBN_EXPORT int Bambu_ReadSample(Bambu_Tunnel tunnel, Bambu_Sample* sample) { auto* t = static_cast(tunnel); if (!t || !sample) return -1; // CTRL channel: drain one reply from the outbox. PrinterFileSystem // on Studio's side will loop calling us until would_block or until // it sees the terminal reply it's waiting on. if (t->ctrl_mode) { std::unique_lock lk(t->ctrl_mu); if (t->ctrl_out.empty()) return Bambu_would_block; CtrlReply r = std::move(t->ctrl_out.front()); t->ctrl_out.pop_front(); lk.unlock(); // Stash on the tunnel so the pointer stays valid until next // ReadSample (matches the contract with gstbambusrc). PrinterFileSystem // treats decode_time as irrelevant; we set it to 0. t->ctrl_current_reply = std::move(r.data); sample->itrack = 0; sample->size = static_cast(t->ctrl_current_reply.size()); sample->flags = 0; sample->buffer = reinterpret_cast( t->ctrl_current_reply.data()); sample->decode_time = 0; return Bambu_success; } // RTSP: pull from the IVideoPipeline. MJPG path continues below. if (t->url.scheme == Scheme::Rtsps || t->url.scheme == Scheme::Rtsp) { return read_rtsp(t, sample); } if (!t->ssl) return -1; // Read 16-byte frame header. uint8_t hdr[16]; int rc = ssl_read_all(t, hdr, sizeof(hdr)); if (rc < 0) { set_last_error("header read failed"); return -1; } if (rc > 0) return Bambu_stream_end; auto u32 = [&](size_t off) -> uint32_t { return (static_cast(hdr[off + 0])) | (static_cast(hdr[off + 1]) << 8) | (static_cast(hdr[off + 2]) << 16) | (static_cast(hdr[off + 3]) << 24); }; uint32_t payload_size = u32(0); uint32_t itrack = u32(4); uint32_t flags = u32(8); if (payload_size == 0 || payload_size > kMaxFrameSize) { log_fmt(t->logger, t->log_ctx, "Bambu_ReadSample: bogus payload size %u", payload_size); set_last_error("bogus payload size"); return -1; } t->frame_buf.resize(payload_size); rc = ssl_read_all(t, t->frame_buf.data(), payload_size); if (rc < 0) { set_last_error("payload read failed"); return -1; } if (rc > 0) return Bambu_stream_end; // Sanity check: MJPG frames start with 0xFF 0xD8 and end with // 0xFF 0xD9. If the magic is wrong we probably lost sync; bailing // out lets gstbambusrc tear the pipeline down and reconnect. if (payload_size < 4 || t->frame_buf[0] != 0xFF || t->frame_buf[1] != 0xD8 || t->frame_buf[payload_size - 2] != 0xFF || t->frame_buf[payload_size - 1] != 0xD9) { log_fmt(t->logger, t->log_ctx, "Bambu_ReadSample: JPEG magic mismatch size=%u", payload_size); set_last_error("JPEG magic mismatch"); return -1; } auto now = std::chrono::steady_clock::now(); auto ns = std::chrono::duration_cast(now - t->t0).count(); if (++t->frame_count == 1 || (t->frame_count % 60) == 0) { log_fmt(t->logger, t->log_ctx, "Bambu_ReadSample: frame #%llu size=%u itrack=%u flags=%u", static_cast(t->frame_count), payload_size, itrack, flags); } sample->itrack = static_cast(itrack); sample->size = static_cast(payload_size); sample->flags = static_cast(flags); sample->buffer = t->frame_buf.data(); // gstbambusrc multiplies decode_time by 100 to get ns, so we divide. sample->decode_time = static_cast(ns / 100); return Bambu_success; } OBN_EXPORT int Bambu_SendMessage(Bambu_Tunnel tunnel, int ctrl, char const* data, int len) { auto* t = static_cast(tunnel); if (!t || !data || len <= 0) return -1; // Only CTRL_TYPE is known to us. Any other ctrl channel is a // no-op (returns success so the caller doesn't interpret it as // pipe-dead); stock firmware uses the same channel number. if (ctrl != kCtrlType) return Bambu_success; if (!t->ctrl_mode) { // Auto-switch into CTRL if Studio skipped StartStreamEx. This // shouldn't happen, but it's cheap insurance. start_ctrl_mode(t); } CtrlRequest req; req.body.assign(data, static_cast(len)); { std::lock_guard lk(t->ctrl_mu); t->ctrl_in.emplace_back(std::move(req)); t->ctrl_cv.notify_all(); } return Bambu_success; } OBN_EXPORT int Bambu_RecvMessage(Bambu_Tunnel /*tunnel*/, int* /*ctrl*/, char* /*data*/, int* /*len*/) { // Studio uses Bambu_ReadSample for CTRL replies, not RecvMessage. // Keep this as a polite "no data". return Bambu_would_block; } OBN_EXPORT void Bambu_Close(Bambu_Tunnel tunnel) { auto* t = static_cast(tunnel); if (!t) return; // CTRL worker must be joined before the rest of the tunnel is // dismantled (it holds references to SSL / ftp that tunnel_close // would invalidate). stop_ctrl_mode(t); tunnel_close(t); } OBN_EXPORT void Bambu_Destroy(Bambu_Tunnel tunnel) { auto* t = static_cast(tunnel); if (!t) return; stop_ctrl_mode(t); tunnel_close(t); delete t; } OBN_EXPORT char const* Bambu_GetLastErrorMsg() { // Stock plugin returns a static string; we return a thread-local // pointer that remains valid until the next set_last_error on // the same thread. That matches how Studio actually uses it // (printed immediately, not stored). return obn::source::get_last_error(); } OBN_EXPORT void Bambu_FreeLogMsg(tchar const* msg) { // We allocated with strdup_for_logger() in log_fmt(): strdup() on // POSIX, malloc(wcslen+1) on Windows. Both come back to free(). if (msg) std::free(const_cast(msg)); } // Legacy probe: the older stub exported this so callers could tell at a // glance that they loaded our build. Keep it around (no-op). OBN_EXPORT int bambu_source_is_stub() { return 0; // now a real implementation }