Files
2026-09-29 17:51:07 +02:00

2720 lines
106 KiB
C++

// 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 `<data_dir>/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/<ip>?port=6000&user=<u>&passwd=<p>&...
// bambu:///local/<ip>.?port=6000&user=<u>&passwd=<p>&... (legacy)
// -> TCP/TLS MJPG on port 6000 (P1/A1 firmware protocol)
//
// bambu:///rtsps___<user>:<passwd>@<ip>/streaming/live/1?proto=rtsps
// bambu:///rtsp___<user>:<passwd>@<ip>/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) and the TUTK fallback; when Studio omits it the serial is
// looked up by IP (fill_device_from_ip). 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 <ip>:<port>; 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 <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/ssl.h>
#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 <winsock2.h>
# include <ws2tcpip.h>
# include <windows.h>
#else
# include <netdb.h>
# include <netinet/in.h>
# include <netinet/tcp.h>
# include <sys/socket.h>
# include <sys/types.h>
# include <unistd.h>
#endif
#include <algorithm>
#include <atomic>
#include <cctype>
#include <cerrno>
#include <chrono>
#include <condition_variable>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <deque>
#include <memory>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <unordered_set>
#include <vector>
#include <filesystem>
#if !defined(_WIN32)
# include <dlfcn.h>
# if defined(__linux__)
# include <link.h>
# endif
# if defined(__APPLE__)
# include <mach-o/dyld.h>
# endif
#endif
#include "obn/config.hpp"
#include "obn/ftps.hpp"
#include "obn/json_lite.hpp"
#include "obn/lan_tls.hpp"
#include "obn/lan_tls_env.hpp"
#include "obn/log.hpp"
#include "obn/tunnel_local.hpp"
#include "source_log.hpp"
#include "rtsp_passthrough.hpp"
#include "tls_socket.hpp"
#include "camera/TutkCameraSource.hpp"
#include "camera/ICameraSource.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 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/<ip>?port=6000&user=<u>&passwd=<p>&...
// bambu:///local/<ip>.?port=6000&... (note the trailing dot)
// -----------------------------------------------------------------------
enum class Scheme {
Local, // MJPG over TCP/TLS on <port> (default 6000)
Rtsps, // RTSPS on <port> (default 322)
Rtsp, // plain RTSP on <port> (default 554)
Tutk, // TUTK off-LAN / relay camera
};
struct TunnelUrl {
Scheme scheme = Scheme::Local;
std::string raw_url;
std::string host;
int port = 6000;
std::string user = "bblp";
std::string passwd;
std::string authkey;
std::string region;
std::string tutk_uid;
std::string channel;
std::string device;
std::string cli_id;
std::string cli_ver;
std::string net_ver;
std::string path = "/streaming/live/1"; // RTSP(S) only
// LAN liveview hint appended by the plugin's get_camera_url fallback
// ("rtsps"/"rtsp"). On a local-scheme tunnel it redirects the VIDEO
// stream (Bambu_StartStream) to RTSP(S) :322/:554 while the CTRL
// channel (file browser) keeps using TLS :6000. Empty for URLs minted
// by Studio itself.
std::string lv;
};
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<char>((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]".
//
// wxURI (macOS wxMediaCtrl2 / Windows DShow path) may collapse
// authority-less `bambu:///rtsps___…` into `bambu://rtsps___…`
// before Load/open. Accept any run of `/` after `bambu:` — same
// normaliser as stubs/dshow_filter.cpp.
static const char kBambu[] = "bambu:";
std::string body;
if (url.compare(0, sizeof(kBambu) - 1, kBambu) == 0) {
size_t p = sizeof(kBambu) - 1;
while (p < url.size() && url[p] == '/') ++p;
body = url.substr(p);
} else {
body = url;
}
static const std::string p_tutk = "tutk?";
static const std::string p_local = "local/";
static const std::string p_rtsps = "rtsps___";
static const std::string p_rtsp = "rtsp___";
std::string rest;
if (body.compare(0, p_tutk.size(), p_tutk) == 0 || body.find("tutk?") != std::string::npos) {
out->scheme = Scheme::Tutk;
out->raw_url = url;
auto q_pos = url.find('?');
std::string query = (q_pos == std::string::npos) ? "" : url.substr(q_pos + 1);
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 == "uid") { out->tutk_uid = val; }
else if (key == "passwd") { out->passwd = val; }
else if (key == "authkey") { out->authkey = val; }
else if (key == "region") { out->region = val; }
else if (key == "device") { out->device = val; }
else if (key == "channel") { out->channel = val; }
else if (key == "cli_id") { out->cli_id = val; }
else if (key == "cli_ver") { out->cli_ver = val; }
else if (key == "net_ver") { out->net_ver = val; }
i = amp + 1;
}
if (out->host.empty()) out->host = "tutk-relay";
return !out->tutk_uid.empty();
} else if (body.compare(0, p_local.size(), p_local) == 0) {
out->scheme = Scheme::Local;
out->port = 6000;
rest = body.substr(p_local.size());
} else if (body.compare(0, p_rtsps.size(), p_rtsps) == 0) {
out->scheme = Scheme::Rtsps;
out->port = 322;
rest = body.substr(p_rtsps.size());
} else if (body.compare(0, p_rtsp.size(), p_rtsp) == 0) {
out->scheme = Scheme::Rtsp;
out->port = 554;
rest = body.substr(p_rtsp.size());
} else {
// Bare "<ip>:<port>/..." fallback.
out->scheme = Scheme::Local;
out->port = 6000;
rest = body;
}
// 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) {
// "<user>:<passwd>@<host>[:port]/<path>" (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 ":<port>". Fall through to the colon
// handling below.
} else {
// Legacy MJPG URL: "<ip>.?port=..." -> trim trailing . and /
while (!host_part.empty() &&
(host_part.back() == '/' || host_part.back() == '.'))
host_part.pop_back();
}
// Optional ":<port>" 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; }
else if (key == "cli_id") { out->cli_id = val; }
else if (key == "cli_ver") { out->cli_ver = val; }
else if (key == "net_ver") { out->net_ver = val; }
else if (key == "lv") { out->lv = 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/<ip>.?port=6000&user=bblp&passwd=<code>&...`) 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`. We forward those frames to
// printer firmware over the same TLS :6000 session (native passthrough).
// --------------------------------------------------------------------
constexpr int kCtrlType = 0x3001;
// PrinterFileSystem command types (req.cmdtype). Only the ones the
// FTPS bridge serves locally are listed; everything else is forwarded
// to firmware verbatim.
constexpr int kCmdListInfo = 0x0001;
constexpr int kCmdSubFile = 0x0002;
constexpr int kCmdFileDel = 0x0003;
constexpr int kCmdFileDownload = 0x0004;
constexpr int kCmdRequestMediaAbility = 0x0007;
constexpr int kCmdTaskCancel = 0x1000;
// PrinterFileSystem result codes (reply.result).
constexpr int kResOK = 0;
constexpr int kResContinue = 1;
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<blob>" if present
};
struct CtrlReply {
// "<json>\n\n<optional blob>" in PrinterFileSystem wire format.
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<obn::rtsp::Passthrough> 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<uint8_t> 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<bool> 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;
// Local MJPEG auth is deferred until Bambu_StartStream (file browser
// uses Bambu_StartStreamEx without the 80-byte 0x3000 auth packet).
bool mjpg_authed = false;
// ---- PrinterFileSystem CTRL state (Scheme::Local + CTRL_TYPE) ----
// When Studio calls Bambu_StartStreamEx(CTRL_TYPE), we keep the TLS
// :6000 socket open and forward CTRL JSON to printer firmware.
bool ctrl_mode = false;
std::unique_ptr<obn::tunnel_local::Session> tl_session;
std::chrono::steady_clock::time_point tl_t0;
// Debug logging of the polled Bambu_* calls: consecutive would_block
// results of StartStream(Ex) and the last ReadSample result.
unsigned poll_blocks = 0;
std::chrono::steady_clock::time_point poll_t0;
int read_last_rc = 0;
// ---- FTPS bridge state (force_ftps=1) ----
// When force_ftps is enabled we serve LIST_INFO / FILE_DOWNLOAD /
// SUB_FILE / REQUEST_MEDIA_ABILITY locally over FTPS (port 990)
// instead of forwarding them over the native :6000 CTRL channel.
std::unique_ptr<obn::ftps::Client> ftp;
// True if the FTPS root == storage mount (P2S / USB-only printers).
bool root_is_storage = false;
// Studio-facing label: "sdcard" (legacy SD mount) or "udisk" (USB).
std::string storage_label;
// FTPS path prefix: "/sdcard", "/usb", or "" when root IS the storage.
std::string ftp_prefix;
// CTRL request inbox (Bambu_SendMessage) and reply outbox
// (Bambu_ReadSample). Both guarded by ctrl_mu.
std::mutex ctrl_mu;
std::condition_variable ctrl_cv;
std::deque<CtrlRequest> ctrl_in;
std::deque<CtrlReply> ctrl_out;
// Sequence numbers the caller asked to cancel. The FTPS handlers
// check this set between multi-chunk responses (long downloads).
std::unordered_set<int> ctrl_cancelled;
std::atomic<bool> 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;
// ---- TUTK off-LAN / relay camera state (Scheme::Tutk) ----
std::unique_ptr<obn::camera::TutkCameraSource> tutk_source;
std::vector<uint8_t> tutk_current_frame;
// File browser over TUTK (StartStreamEx CTRL on a tutk URL). Replies
// land in ctrl_out from the session's worker thread.
std::unique_ptr<obn::camera::tutk::TutkSession> tutk_ctrl;
};
void tunnel_close(Tunnel* t)
{
if (!t) return;
t->closing.store(true, std::memory_order_release);
if (t->tutk_source) {
t->tutk_source->close();
t->tutk_source.reset();
}
log_at(LL_DEBUG, t->logger, t->log_ctx,
"tunnel_close: shutting down (fd=%lld ssl=%p frames=%llu)",
static_cast<long long>(t->fd), static_cast<void*>(t->ssl),
static_cast<unsigned long long>(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<std::mutex> 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();
}
}
// 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<uint8_t*>(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<std::mutex> 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<int>(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<size_t>(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.
// -----------------------------------------------------------------------
int open_rtsp(Tunnel* t)
{
auto pass = std::make_unique<obn::rtsp::Passthrough>(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;
// Prefer what the SDP's SPS actually says: the coded size and frame
// rate differ per model (a P2S serves 1168x720 @ ~24.7 fps), and a
// wrong frame rate makes the slicer's sink judge live frames late.
// The 1280x720@30 fallback only applies when the SPS did not parse.
{
const auto p = t->rtsp_pass->params();
t->width = (p.width > 0) ? p.width : 1280;
t->height = (p.height > 0) ? p.height : 720;
t->frame_rate = (p.fps > 0) ? p.fps : 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 @ %d fps, "
"gstbambusrc decodes)",
t->width, t->height, t->frame_rate);
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<int>(size);
sample->flags = flags;
sample->buffer = buf;
sample->decode_time = static_cast<unsigned long long>(dt_100ns);
return Bambu_success;
}
// -----------------------------------------------------------------------
// TUTK off-LAN / relay video source.
// Uses obn::camera::TutkCameraSource to connect to ThroughTek
// master/relay server and stream H.264 / MJPEG frames.
// -----------------------------------------------------------------------
int open_tutk(Tunnel* t)
{
log_fmt(t->logger, t->log_ctx, "open_tutk: starting TUTK session url=%.160s", t->url.raw_url.c_str());
t->tutk_source = std::make_unique<obn::camera::TutkCameraSource>(t->url.raw_url);
if (!t->tutk_source->open()) {
log_fmt(t->logger, t->log_ctx, "open_tutk: failed to open TUTK stream");
set_last_error("TUTK stream open failed");
t->tutk_source.reset();
return -1;
}
auto si = t->tutk_source->info();
t->width = si.width;
t->height = si.height;
t->frame_rate = si.fps;
t->sub_type = (si.codec == obn::camera::ICameraSource::Codec::MotionJpeg) ? MJPG : AVC1;
t->t0 = std::chrono::steady_clock::now();
t->started = true;
log_fmt(t->logger, t->log_ctx, "open_tutk: stream ready (%dx%d @ %d fps, subtype=%d)",
t->width, t->height, t->frame_rate, t->sub_type);
return Bambu_success;
}
int read_tutk(Tunnel* t, Bambu_Sample* sample)
{
if (!t->tutk_source || !t->tutk_source->is_open()) return -1;
auto frame_opt = t->tutk_source->next_frame(100);
if (!frame_opt) {
return Bambu_would_block;
}
if (t->frame_count == 0) {
auto si = t->tutk_source->info();
t->width = si.width;
t->height = si.height;
t->sub_type = (si.codec == obn::camera::ICameraSource::Codec::MotionJpeg) ? MJPG : AVC1;
log_fmt(t->logger, t->log_ctx, "read_tutk: first frame received (%dx%d, subtype=%d)",
t->width, t->height, t->sub_type);
}
auto now = std::chrono::steady_clock::now();
auto ns = std::chrono::duration_cast<std::chrono::nanoseconds>(now - t->t0).count();
t->tutk_current_frame = std::move(frame_opt->nal_data);
sample->itrack = 0;
sample->size = static_cast<int>(t->tutk_current_frame.size());
sample->flags = frame_opt->is_keyframe ? 1 : 0;
sample->buffer = t->tutk_current_frame.data();
sample->decode_time = static_cast<unsigned long long>(ns / 100);
++t->frame_count;
if (t->frame_count <= 10 || (t->frame_count & 0x3F) == 1) {
log_fmt(t->logger, t->log_ctx,
"read_tutk: frame %llu size=%d key=%d pts=%llu",
static_cast<unsigned long long>(t->frame_count),
sample->size, sample->flags, sample->decode_time);
}
return Bambu_success;
}
// The plugin's private obn_get_* exports (see src/abi_camera.cpp). Resolved from the already-loaded plugin module; never
// loads it, so a stock plugin (no such symbols) simply disables the fallbacks.
using GetUrlFn = const char* (*)(const char*);
#if !defined(_WIN32)
static GetUrlFn lookup_in_handle(void* handle, const char* name)
{
if (!handle) return nullptr;
return reinterpret_cast<GetUrlFn>(dlsym(handle, name));
}
#if defined(__linux__)
struct PluginLookup {
const char* name;
GetUrlFn fn;
};
static int visit_loaded_plugin(struct dl_phdr_info* info, size_t, void* data)
{
auto* want = static_cast<PluginLookup*>(data);
if (!info->dlpi_name || !std::strstr(info->dlpi_name, "bambu_networking"))
return 0;
void* h = dlopen(info->dlpi_name, RTLD_NOLOAD | RTLD_LAZY);
if (!h) return 0;
want->fn = lookup_in_handle(h, want->name);
dlclose(h);
return want->fn ? 1 : 0;
}
#endif
#endif
static GetUrlFn resolve_plugin_url_fn(const char* name)
{
#if defined(_WIN32)
static const char* const kModNames[] = {
"bambu_networking.dll",
"libbambu_networking.dll",
nullptr
};
for (int i = 0; kModNames[i]; ++i) {
if (HMODULE h = GetModuleHandleA(kModNames[i])) {
if (auto fn = reinterpret_cast<GetUrlFn>(GetProcAddress(h, name)))
return fn;
}
}
// Studio may load the plugin under a versioned file name; look for any
// loaded bambu_networking*.dll next to this module.
HMODULE self = nullptr;
if (!GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCSTR>(&resolve_plugin_url_fn), &self) ||
!self)
return nullptr;
char self_path[1024];
if (GetModuleFileNameA(self, self_path, sizeof(self_path)) == 0) return nullptr;
std::error_code ec;
const auto dir = std::filesystem::path(self_path).parent_path();
for (const auto& entry : std::filesystem::directory_iterator(dir, ec)) {
if (!entry.is_regular_file() || entry.path().extension() != ".dll") continue;
const auto file = entry.path().filename().string();
if (file.find("bambu_networking") == std::string::npos) continue;
if (HMODULE h = GetModuleHandleA(file.c_str())) {
if (auto fn = reinterpret_cast<GetUrlFn>(GetProcAddress(h, name)))
return fn;
}
}
return nullptr;
#else
// Studio dlopens the plugin without RTLD_GLOBAL, so RTLD_DEFAULT often
// cannot see obn_get_* — look up the already-loaded module next.
if (auto fn = lookup_in_handle(RTLD_DEFAULT, name)) return fn;
static const char* const kNames[] = {
"libbambu_networking.so",
"bambu_networking.so",
"libbambu_networking.dylib",
nullptr
};
for (int i = 0; kNames[i]; ++i) {
if (void* h = dlopen(kNames[i], RTLD_NOLOAD | RTLD_LAZY)) {
auto fn = lookup_in_handle(h, name);
dlclose(h);
if (fn) return fn;
}
}
#if defined(__linux__)
PluginLookup look{name, nullptr};
dl_iterate_phdr(visit_loaded_plugin, &look);
if (look.fn) return look.fn;
#endif
#if defined(__APPLE__)
const uint32_t n = _dyld_image_count();
for (uint32_t i = 0; i < n; ++i) {
const char* img = _dyld_get_image_name(i);
if (!img || !std::strstr(img, "bambu_networking")) continue;
if (void* h = dlopen(img, RTLD_NOLOAD | RTLD_LAZY)) {
auto fn = lookup_in_handle(h, name);
dlclose(h);
if (fn) return fn;
}
}
#endif
return nullptr;
#endif
}
static std::string query_plugin(const char* export_name, const std::string& arg)
{
if (arg.empty()) return {};
GetUrlFn fn = resolve_plugin_url_fn(export_name);
if (!fn) {
log_fmt(nullptr, nullptr,
"query_plugin: %s is not exported by the loaded networking plugin",
export_name);
return {};
}
const char* u = fn(arg.c_str());
return u ? std::string(u) : std::string{};
}
// Studio omits device= from upload / part-skip tunnel URLs; recover the
// serial from the IP so TLS verification and the transport fallbacks work.
static void fill_device_from_ip(TunnelUrl* url)
{
if (!url->device.empty() || url->host.empty() || url->scheme == Scheme::Tutk) return;
if (auto serial = obn::lan_tls::registry_lookup_serial(url->host))
url->device = *serial;
else
url->device = query_plugin("obn_get_lan_serial", url->host);
}
// Replaces t->url with a freshly resolved URL, keeping the Studio-appended
// identity fields (device / cli_id / cli_ver / net_ver) the new URL lacks.
static bool adopt_url(Tunnel* t, const std::string& url)
{
TunnelUrl new_url;
if (!parse_url(url, &new_url)) return false;
if (new_url.device.empty()) new_url.device = t->url.device;
if (new_url.cli_id.empty()) new_url.cli_id = t->url.cli_id;
if (new_url.cli_ver.empty()) new_url.cli_ver = t->url.cli_ver;
if (new_url.net_ver.empty()) new_url.net_ver = t->url.net_ver;
t->url = std::move(new_url);
return true;
}
static bool fallback_to_tutk(Tunnel* t)
{
if (!t) return false;
std::string dev_id = t->url.device;
if (dev_id.empty()) return false;
log_fmt(t->logger, t->log_ctx,
"fallback_to_tutk: querying TUTK URL for dev=%s",
dev_id.c_str());
std::string tutk_url = query_plugin("obn_get_tutk_camera_url", dev_id);
if (tutk_url.empty()) {
log_fmt(t->logger, t->log_ctx,
"fallback_to_tutk: failed to resolve TUTK URL for dev=%s",
dev_id.c_str());
set_last_error("LAN camera is unreachable and TUTK fallback failed");
return false;
}
// Clean up LAN/TLS resources before switching to TUTK
{
std::lock_guard<std::mutex> lk(t->mjpg_io_mu);
if (t->ssl) {
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) {
t->rtsp_pass->stop();
t->rtsp_pass.reset();
}
if (!adopt_url(t, tutk_url)) {
log_fmt(t->logger, t->log_ctx,
"fallback_to_tutk: failed to parse resolved TUTK URL");
return false;
}
log_fmt(t->logger, t->log_ctx, "fallback_to_tutk: switched to TUTK for dev=%s",
dev_id.c_str());
return true;
}
// Dials the Local-scheme :6000 TLS endpoint into t->fd / t->ssl.
static bool dial_local_tls(Tunnel* t, const char* who)
{
log_fmt(t->logger, t->log_ctx, "%s: dialing tls://%s:%d",
who, 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, "%s: TLS dial failed: %s",
who, obn::source::get_last_error());
return false;
}
log_fmt(t->logger, t->log_ctx, "%s: TLS established (cipher=%s)",
who, SSL_get_cipher(t->ssl));
t->t0 = std::chrono::steady_clock::now();
t->started = true;
return true;
}
// TUTK tunnel opened for the file browser whose TUTK session did not come
// up (legacy transport, unreachable) or with force_ftps set: switch the
// CTRL channel to the printer's LAN :6000 route.
static bool fallback_to_lan(Tunnel* t)
{
const std::string dev_id = t->url.device;
std::string lan_url = query_plugin("obn_get_lan_camera_url", dev_id);
if (lan_url.empty() || !adopt_url(t, lan_url) || t->url.scheme != Scheme::Local) {
log_fmt(t->logger, t->log_ctx,
"fallback_to_lan: no LAN route for dev=%s", dev_id.c_str());
set_last_error("file browser: no TUTK session and no known LAN route to the printer");
return false;
}
obn::lan_tls::registry_put_ip_serial(t->url.host, t->url.device);
return dial_local_tls(t, "fallback_to_lan");
}
// -----------------------------------------------------------------------
// 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<uint8_t>( v & 0xff);
out[off + 1] = static_cast<uint8_t>((v >> 8) & 0xff);
out[off + 2] = static_cast<uint8_t>((v >> 16) & 0xff);
out[off + 3] = static_cast<uint8_t>((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<size_t>(url.user.size(), 32));
std::memcpy(out + 48, url.passwd.data(),
std::min<size_t>(url.passwd.size(), 32));
}
void push_reply(Tunnel* t, CtrlReply r)
{
std::lock_guard<std::mutex> lk(t->ctrl_mu);
t->ctrl_out.emplace_back(std::move(r));
t->ctrl_cv.notify_all();
}
// --------------------------------------------------------------------
// FTPS bridge (force_ftps=1): serve the file browser over FTPS (990)
// instead of the native TLS :6000 CTRL protocol. Used as a workaround
// for printers whose :6000 file browser is broken (some A1 firmware).
// Thumbnails are NOT served in this mode (stubbed with empty blobs);
// only file listing, download and whole-archive (zip) fetches work.
// --------------------------------------------------------------------
// True when the user enabled the FTPS file-transfer workaround.
// BambuSource compiles its own copy of config.cpp (self-contained .so),
// so config::current() here returns defaults — the main plugin's
// load_or_create never touches our static. The main plugin's
// Main plugin writes OBN_FORCE_FTPS into <config_dir>/obn.env;
// env_var_get hydrates that file on miss (required on Windows across DLLs).
bool ftps_bridge_enabled()
{
const char* env = obn::lan_tls::env_var_get(obn::lan_tls::kEnvForceFtps);
return env && env[0] == '1';
}
bool camera_preview_disabled()
{
const char* env =
obn::lan_tls::env_var_get(obn::lan_tls::kEnvDisableCameraPreview);
return env && env[0] == '1';
}
// Builds the on-wire reply bytes Studio expects: the
// `{cmdtype, sequence, result, reply}` envelope, optionally followed by
// "\n\n" and a binary blob.
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<const char*>(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<double>(cmdtype));
o["sequence"] = obn::json::Value(static_cast<double>(sequence));
o["result"] = obn::json::Value(static_cast<double>(result));
o["reply"] = std::move(reply);
return obn::json::Value(std::move(o));
}
void stub_blocked_mem_download(Tunnel* t, int sequence, const std::string& path)
{
obn::json::Object extra;
extra["path"] = obn::json::Value(path);
extra["offset"] = obn::json::Value(0.0);
extra["total"] = obn::json::Value(0.0);
extra["size"] = obn::json::Value(0.0);
auto env = make_reply_envelope(kCmdFileDownload, sequence, kResFileNoExist,
obn::json::Value(std::move(extra)));
push_reply(t, {make_wire_reply(env, nullptr, 0)});
}
bool ctrl_is_cancelled(Tunnel* t, int sequence)
{
std::lock_guard<std::mutex> lk(t->ctrl_mu);
return t->ctrl_cancelled.count(sequence) > 0;
}
// Removes the sequence from the cancelled set on scope exit so the set
// does not grow unbounded during a long-lived CTRL session.
struct CancelledGuard {
Tunnel* t;
int seq;
~CancelledGuard() {
std::lock_guard<std::mutex> lk(t->ctrl_mu);
t->ctrl_cancelled.erase(seq);
}
};
// Connects a fresh FTPS client on demand (first CTRL request) and probes
// the storage mount. Kept on the tunnel so requests reuse the channel.
std::string ensure_ftp(Tunnel* t)
{
if (t->ftp) return {};
t->ftp = std::make_unique<obn::ftps::Client>();
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. P1/X1 with an SD
// card exposes `/sdcard`; P2S/A-series with a USB stick exposes
// `/usb`. Some P2S units expose neither because the root IS storage.
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 = "udisk";
t->ftp_prefix = "/usb";
t->root_is_storage = false;
} else if (t->ftp->cwd("/").empty()) {
// USB-only printers: FTPS root is the stick; Studio expects "udisk".
t->storage_label = "udisk";
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 {};
}
// Bambu firmware drops idle FTPS control connections after ~5 minutes.
// 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 where Studio's `type` argument routes:
// timelapse -> <prefix>/timelapse, video -> <prefix>/ipcam,
// model / other -> <prefix>/ (root).
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";
return t->ftp_prefix.empty() ? "/" : t->ftp_prefix;
}
// True when req.storage (from LIST_INFO) targets the volume we probed on
// FTPS. Studio uses "" / absent for External, "internal" for Internal
// timelapse tab, and on P2S also "emmc" / "udisk". The FTPS bridge only
// serves one mount — requests for internal/emmc when we probed udisk must
// not return the external listing (empty list instead).
bool ftps_storage_matches_request(const Tunnel* t, const std::string& req_storage)
{
if (req_storage.empty()) {
// External tab: match udisk/sdcard mounts, not a hypothetical emmc-only probe.
return t->storage_label != "emmc";
}
if (req_storage == "internal" || req_storage == "emmc")
return t->storage_label == "emmc";
if (req_storage == "udisk" || req_storage == "usb")
return t->storage_label == "udisk";
if (req_storage == "sdcard")
return t->storage_label == "sdcard";
return req_storage == t->storage_label;
}
// Extensions a listing should surface to Studio for a given file type.
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<unsigned char>(a)) ==
std::tolower(static_cast<unsigned char>(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.
std::string format_time_studio(std::uint64_t epoch)
{
if (epoch == 0) return "";
std::time_t tt = static_cast<std::time_t>(epoch);
std::tm tm{};
obn::os::gmtime_safe(tt, &tm);
char buf[32];
std::strftime(buf, sizeof(buf), "%F %T", &tm);
return buf;
}
void ftps_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));
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 ftps_handle_list_info(Tunnel* t, int sequence, const obn::json::Value& req)
{
std::string type = req.find("type").as_string();
std::string storage = req.find("storage").as_string();
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;
}
if (!ftps_storage_matches_request(t, storage)) {
log_fmt(t->logger, t->log_ctx,
"ctrl: LIST_INFO type=%s storage=%s not served by FTPS mount %s",
type.c_str(), storage.c_str(), t->storage_label.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;
}
std::string path = resolve_subtree(t, type);
std::vector<obn::ftps::Entry> entries;
err = t->ftp->list_entries(path, &entries);
if (!err.empty()) {
// Could be a missing directory or a stale FTPS session. Retry
// once with a fresh session before surfacing an empty list.
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()) {
path = resolve_subtree(t, type);
entries.clear();
err = t->ftp->list_entries(path, &entries);
}
}
if (!err.empty()) {
// Directory missing (e.g. no timelapses yet): return empty list.
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);
std::string full = (path == "/") ? ("/" + e.name)
: (path + "/" + e.name);
f["path"] = obn::json::Value(full);
f["size"] = obn::json::Value(static_cast<double>(e.size));
if (e.mtime != 0) {
f["time"] = obn::json::Value(static_cast<double>(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)});
}
// SUB_FILE in FTPS mode:
// * zip=true (FetchModel): stream the whole .3mf archive back in
// 256 KB chunks so Studio's load_gcode_3mf_from_stream succeeds.
// * everything else (thumbnails / metadata): stubbed -- reply size=0
// for each requested entry so Studio falls back to its default icon
// instead of hanging. Thumbnails are not available over FTPS.
void ftps_handle_sub_file(Tunnel* t, int sequence, const obn::json::Value& req)
{
CancelledGuard cg{t, sequence};
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;
}
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);
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)));
push_reply(t, {make_wire_reply(env, blob, blen)});
};
if (!paths.empty() && zip_mode) {
// Studio wants a real ZIP archive (FetchModel). Hand back the
// source .3mf bytes unchanged, streamed in chunks. Buffer the
// whole archive first so the LAST non-empty chunk is reliably
// flagged continue=false even when the size is an exact multiple
// of the chunk boundary. .3mf files on the printer are typically
// single-digit MB; the worst case is ~50-100 MB for complex
// multi-plate projects, which is acceptable for a transient buffer.
std::vector<std::string> 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);
}
constexpr std::size_t kChunkSize = 256 * 1024;
for (std::size_t bi = 0; bi < bases.size(); ++bi) {
const std::string& full = bases[bi];
if (ctrl_is_cancelled(t, sequence)) {
push_chunk(kResErrCancel, {}, nullptr, 0);
return;
}
std::uint64_t total_size = 0;
t->ftp->size(full, &total_size);
std::vector<std::uint8_t> archive;
archive.reserve(static_cast<std::size_t>(
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 (ctrl_is_cancelled(t, sequence)) { cancelled = true; return false; }
const std::uint8_t* p = static_cast<const std::uint8_t*>(data);
archive.insert(archive.end(), p, p + len);
return true;
});
};
std::string rerr = retr_into_archive();
if (!rerr.empty() && !cancelled) {
log_at(LL_DEBUG, t->logger, t->log_ctx,
"ctrl: SUB_FILE(zip) %s failed (%s) -- reconnecting once",
full.c_str(), rerr.c_str());
if (reconnect_ftp(t).empty()) {
t->ftp->size(full, &total_size);
rerr = retr_into_archive();
}
}
if (cancelled) {
push_chunk(kResErrCancel, {}, nullptr, 0);
return;
}
if (!rerr.empty()) {
log_fmt(t->logger, t->log_ctx,
"ctrl: SUB_FILE(zip) %s failed: %s", full.c_str(),
rerr.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 last_file=%d",
sequence, full.c_str(), total, is_last_file ? 1 : 0);
if (total == 0) {
obn::json::Object extra;
extra["path"] = obn::json::Value(full);
extra["mimetype"] = obn::json::Value("application/zip");
extra["size"] = obn::json::Value(static_cast<double>(0));
extra["offset"] = obn::json::Value(static_cast<double>(0));
extra["total"] = obn::json::Value(static_cast<double>(0));
extra["continue"] = obn::json::Value(false);
push_chunk(is_last_file ? kResOK : kResContinue, 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<double>(chunk));
extra["offset"] = obn::json::Value(static_cast<double>(offset));
extra["total"] = obn::json::Value(static_cast<double>(total));
extra["continue"] = obn::json::Value(!last_chunk);
bool is_final = last_chunk && is_last_file;
push_chunk(is_final ? kResOK : kResContinue, extra,
archive.data() + offset, chunk);
offset += chunk;
}
}
return;
}
if (!paths.empty()) {
// Thumbnails / metadata: not available over FTPS. Reply size=0
// for each entry, echoing the request path so Studio matches the
// reply to the right File and shows its default icon.
std::size_t total = paths.size();
for (std::size_t i = 0; i < total; ++i) {
if (ctrl_is_cancelled(t, sequence)) {
push_chunk(kResErrCancel, {}, nullptr, 0);
return;
}
obn::json::Object extra;
extra["path"] = obn::json::Value(paths[i].as_string());
extra["mimetype"] = obn::json::Value("application/octet-stream");
extra["size"] = obn::json::Value(static_cast<double>(0));
extra["offset"] = obn::json::Value(static_cast<double>(0));
extra["total"] = obn::json::Value(static_cast<double>(0));
push_chunk((i + 1 == total) ? kResOK : kResContinue, extra, nullptr, 0);
}
return;
}
if (!files.empty()) {
// Legacy per-name thumbnail fetch: also stubbed.
std::size_t total = files.size();
for (std::size_t i = 0; i < total; ++i) {
if (ctrl_is_cancelled(t, sequence)) {
push_chunk(kResErrCancel, {}, nullptr, 0);
return;
}
obn::json::Object extra;
extra["name"] = obn::json::Value(files[i].as_string());
extra["mimetype"] = obn::json::Value("image/jpeg");
extra["size"] = obn::json::Value(static_cast<double>(0));
push_chunk((i + 1 == total) ? kResOK : kResContinue, extra, nullptr, 0);
}
return;
}
push_chunk(kResApiUnsupport, {}, nullptr, 0);
}
void ftps_handle_file_download(Tunnel* t, int sequence,
const obn::json::Value& req)
{
CancelledGuard cg{t, sequence};
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;
}
// Total size up front so Studio's progress bar can render; SIZE also
// probes the control connection (reconnect once if it timed out).
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 reads resp["file_md5"] once
// offset+size == total; a missing field aborts the recv thread.
std::unique_ptr<EVP_MD_CTX, void(*)(EVP_MD_CTX*)> 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;
}
constexpr std::size_t kChunkSize = 256 * 1024;
std::vector<std::uint8_t> 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<double>(sent));
extra["total"] = obn::json::Value(static_cast<double>(total));
extra["size"] = obn::json::Value(static_cast<double>(buf.size()));
if (last) extra["file_md5"] = obn::json::Value(finalize_md5());
auto env = make_reply_envelope(kCmdFileDownload, sequence,
last ? kResOK : kResContinue,
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 (ctrl_is_cancelled(t, sequence)) { cancelled = true; return false; }
EVP_DigestUpdate(md_ctx.get(), data, len);
const std::uint8_t* p = static_cast<const std::uint8_t*>(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()) {
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()) {
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;
}
// If SIZE failed (or returned 0 for a non-empty file), correct total
// from the actual bytes transferred so Studio's offset+size==total
// check fires on the final chunk and it reads file_md5.
std::uint64_t actual = sent + buf.size();
if (total == 0 || total != actual) total = actual;
flush_chunk(/*last=*/true);
}
void ftps_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;
}
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) {
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 ftps_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::lock_guard<std::mutex> lk(t->ctrl_mu);
// Cap the set so late/stale cancels from one-shot commands
// (LIST_INFO, FILE_DEL, etc.) that don't use CancelledGuard
// cannot leak memory over a very long CTRL session.
if (t->ctrl_cancelled.size() > 256) t->ctrl_cancelled.clear();
for (const auto& v : tasks) {
int seq = static_cast<int>(v.as_number());
t->ctrl_cancelled.insert(seq);
cancelled.emplace_back(obn::json::Value(static_cast<double>(seq)));
}
}
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)});
}
// Returns true if the request was served locally over FTPS (caller must
// then skip the native :6000 forward). Only the file-browser cmdtypes
// are intercepted; everything else falls through to native passthrough.
bool ftps_dispatch(Tunnel* t, int cmdtype, int sequence,
const obn::json::Value& body)
{
switch (cmdtype) {
case kCmdRequestMediaAbility: ftps_handle_media_ability(t, sequence, body); return true;
case kCmdListInfo: ftps_handle_list_info(t, sequence, body); return true;
case kCmdSubFile: ftps_handle_sub_file(t, sequence, body); return true;
case kCmdFileDownload: ftps_handle_file_download(t, sequence, body); return true;
case kCmdFileDel: ftps_handle_file_del(t, sequence, body); return true;
case kCmdTaskCancel: ftps_handle_task_cancel(t, sequence, body); return true;
default: return false;
}
}
bool parse_ctrl_request(const std::string& wire, int* cmdtype, int* sequence,
obn::json::Value* req)
{
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<int>(ct.as_number());
*sequence = static_cast<int>(sq.as_number());
*req = v->find("req");
return true;
}
static int parse_wire_result(const std::string& wire)
{
const std::size_t j_end = wire.find("\n\n");
const std::string j =
(j_end == std::string::npos) ? wire : wire.substr(0, j_end);
std::string perr;
const auto v = obn::json::parse(j, &perr);
if (!v) return -1;
return static_cast<int>(v->find("result").as_int(-1));
}
static void native_ctrl_send_worker(Tunnel* t)
{
log_fmt(t->logger, t->log_ctx, "ctrl: native send worker started");
while (!t->ctrl_stop.load(std::memory_order_acquire)) {
CtrlRequest req;
{
std::unique_lock<std::mutex> 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();
}
if (!t->ssl || !t->tl_session) continue;
int cmdtype = 0, sequence = 0;
obn::json::Value body;
bool parsed = parse_ctrl_request(req.body, &cmdtype, &sequence, &body);
if (parsed && cmdtype == kCmdFileDownload && camera_preview_disabled()) {
std::string path = body.find("path").as_string();
if (path.empty()) path = body.find("file").as_string();
if (path == obn::config::kCameraPreviewMemPath) {
log_fmt(t->logger, t->log_ctx,
"ctrl: mem preview blocked (disable_camera_preview) %s",
path.c_str());
stub_blocked_mem_download(t, sequence, path);
continue;
}
}
// force_ftps: serve the file browser over FTPS (990) instead of
// forwarding to the native :6000 CTRL channel. Only the
// file-browser cmdtypes are intercepted; everything else still
// goes to firmware verbatim.
if (parsed && ftps_bridge_enabled() &&
ftps_dispatch(t, cmdtype, sequence, body)) {
log_at(LL_DEBUG, t->logger, t->log_ctx,
"ctrl: FTPS bridge served cmd=0x%04x seq=%d",
cmdtype, sequence);
continue;
}
if (parsed) {
log_at(LL_DEBUG, t->logger, t->log_ctx,
"ctrl: native forward cmd=0x%04x seq=%d (%zu bytes)",
cmdtype, sequence, req.body.size());
} else {
log_at(LL_DEBUG, t->logger, t->log_ctx,
"ctrl: native forward %zu bytes (unparsed)", req.body.size());
}
if (t->tl_session->send_abi_json(t->ssl, req.body, &t->mjpg_io_mu) != 0) {
log_fmt(t->logger, t->log_ctx, "ctrl: native send failed");
set_last_error("BambuTunnelLocal send failed");
continue;
}
for (;;) {
std::vector<std::uint8_t> wire;
if (t->tl_session->recv_payload(t->ssl, &wire, &t->mjpg_io_mu) != 0) {
log_fmt(t->logger, t->log_ctx, "ctrl: native recv failed");
set_last_error("BambuTunnelLocal recv failed");
break;
}
CtrlReply reply;
reply.data.assign(reinterpret_cast<const char*>(wire.data()),
wire.size());
log_at(LL_DEBUG, t->logger, t->log_ctx,
"ctrl: native recv %zu bytes", reply.data.size());
const int result = parse_wire_result(reply.data);
push_reply(t, std::move(reply));
if (result != kResContinue) break;
}
}
log_fmt(t->logger, t->log_ctx, "ctrl: native send worker exited");
}
static long long tl_elapsed_ms(const Tunnel* t)
{
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t->tl_t0).count();
}
// Studio may give up on a polled StartStreamEx and close the tunnel while
// the :6000 handshake is still waiting for the printer.
static void log_abandoned_handshake(Tunnel* t)
{
if (!t->tl_session ||
t->tl_session->phase() == obn::tunnel_local::HandshakePhase::Ready)
return;
log_fmt(t->logger, t->log_ctx,
"ctrl: handshake abandoned in phase %d after %lld ms (%s)",
static_cast<int>(t->tl_session->phase()), tl_elapsed_ms(t),
t->tl_session->handshake_note().c_str());
}
static int start_native_ctrl_handshake(Tunnel* t)
{
if (!t->ssl) {
log_fmt(t->logger, t->log_ctx, "ctrl: TLS session not open");
set_last_error("CTRL: TLS session not open");
return -1;
}
// StartStreamEx is polled (Bambu_would_block between steps). Keep one
// Session across polls — recreating it here re-sent login on every tick.
if (!t->tl_session) {
t->tl_session = std::make_unique<obn::tunnel_local::Session>(
static_cast<std::uint32_t>(std::rand()));
t->tl_t0 = std::chrono::steady_clock::now();
}
obn::tunnel_local::Config cfg;
cfg.username = t->url.user;
cfg.access_code = t->url.passwd;
cfg.client_id = t->url.cli_id;
if (!t->url.cli_ver.empty()) {
cfg.client_ver = t->url.cli_ver;
} else if (!t->url.net_ver.empty()) {
cfg.client_ver = t->url.net_ver;
}
const auto prev = t->tl_session->phase();
const int hs = t->tl_session->handshake_step(t->ssl, cfg, &t->mjpg_io_mu);
const auto ph = t->tl_session->phase();
if (ph != prev) {
log_fmt(t->logger, t->log_ctx, "ctrl: handshake phase %d -> %d after %lld ms (%s)",
static_cast<int>(prev), static_cast<int>(ph), tl_elapsed_ms(t),
t->tl_session->handshake_note().c_str());
}
if (hs < 0) {
log_fmt(t->logger, t->log_ctx,
"ctrl: native handshake failed (phase=%d)",
static_cast<int>(prev));
t->tl_session.reset();
set_last_error("BambuTunnelLocal handshake failed");
return -1;
}
if (hs > 0) return Bambu_would_block;
if (!t->ctrl_mode) {
t->ctrl_mode = true;
t->ctrl_stop.store(false, std::memory_order_release);
t->ctrl_worker = std::thread(native_ctrl_send_worker, t);
log_fmt(t->logger, t->log_ctx,
"ctrl: native :6000 passthrough ready after %lld ms (pid=%s ver=%s)",
tl_elapsed_ms(t), cfg.client_id.c_str(), cfg.client_ver.c_str());
}
return Bambu_success;
}
// File browser over TUTK, as the stock plugin does off-LAN: the printer
// answers PrinterFileSystem JSON sent as IOCtrl 0x3001 on an AV session
// that never starts the video stream. Blocks until the AV login is
// accepted (the stock library connects inside Bambu_Open).
static bool start_tutk_ctrl(Tunnel* t)
{
obn::camera::tutk::TutkSessionParams p;
if (!obn::camera::parse_tutk_url(t->url.raw_url, p)) {
log_fmt(t->logger, t->log_ctx, "ctrl: malformed TUTK URL");
return false;
}
log_fmt(t->logger, t->log_ctx, "ctrl: starting file browser over TUTK");
auto s = std::make_unique<obn::camera::tutk::TutkSession>();
s->join(p, [t](const uint8_t* d, int n, int64_t, bool) {
CtrlReply r;
r.data.assign(reinterpret_cast<const char*>(d), static_cast<std::size_t>(n));
push_reply(t, std::move(r));
}, obn::camera::tutk::TutkSession::Mode::Ctrl);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(15);
while (!s->is_ready() && s->is_joined() && std::chrono::steady_clock::now() < deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(50));
if (!s->is_ready()) {
log_fmt(t->logger, t->log_ctx, "ctrl: TUTK file-browser session did not start");
s->leave();
return false;
}
t->tutk_ctrl = std::move(s);
t->ctrl_mode = true;
log_fmt(t->logger, t->log_ctx, "ctrl: file browser over TUTK ready");
return true;
}
static int send_tutk_ctrl(Tunnel* t, const std::string& body)
{
int cmdtype = 0, sequence = 0;
obn::json::Value req;
const bool parsed = parse_ctrl_request(body, &cmdtype, &sequence, &req);
if (parsed && cmdtype == kCmdFileDownload && camera_preview_disabled()) {
std::string path = req.find("path").as_string();
if (path.empty()) path = req.find("file").as_string();
if (path == obn::config::kCameraPreviewMemPath) {
stub_blocked_mem_download(t, sequence, path);
return Bambu_success;
}
}
log_at(LL_DEBUG, t->logger, t->log_ctx, "ctrl: TUTK forward cmd=0x%04x seq=%d (%zu bytes)",
cmdtype, sequence, body.size());
if (!t->tutk_ctrl->send_ctrl(body)) {
set_last_error("CTRL over TUTK: request too long or session lost");
return -1;
}
return Bambu_success;
}
// Shuts the worker down. Idempotent.
void stop_ctrl_mode(Tunnel* t)
{
if (!t->ctrl_mode) return;
{
std::lock_guard<std::mutex> 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();
if (t->tutk_ctrl) {
t->tutk_ctrl->leave();
t->tutk_ctrl.reset();
}
t->tl_session.reset();
if (t->ftp) {
t->ftp->quit();
t->ftp.reset();
}
t->ctrl_cancelled.clear();
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.
}
static int bambu_create_impl(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"
: (t->url.scheme == Scheme::Tutk) ? "tutk"
: "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!)" : "***");
fill_device_from_ip(&t->url);
if (t->url.scheme != Scheme::Tutk && !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*>(tunnel);
if (!t) return;
t->logger = logger ? logger : noop_logger;
t->log_ctx = context;
}
static int bambu_open_impl(Bambu_Tunnel tunnel)
{
auto* t = static_cast<Tunnel*>(tunnel);
if (!t) return -1;
// lv=rtsps/rtsp: video starts in Bambu_StartStream, not here. Skip the
// :6000 TLS dial so a blocked CTRL port does not fail an RTSP liveview
// (or the TUTK fallback that StartStream runs after RTSP fails).
if (t->url.scheme == Scheme::Local &&
(t->url.lv == "rtsps" || t->url.lv == "rtsp")) {
log_fmt(t->logger, t->log_ctx,
"Bambu_Open: lv=%s, deferring video to StartStream",
t->url.lv.c_str());
return Bambu_success;
}
// 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.
if (t->url.scheme == Scheme::Rtsps || t->url.scheme == Scheme::Rtsp) {
int rc = open_rtsp(t);
if (rc == Bambu_success) return rc;
if (!t->url.device.empty()) {
log_fmt(t->logger, t->log_ctx,
"Bambu_Open: RTSP open failed, attempting TUTK cloud fallback for dev=%s",
t->url.device.c_str());
if (fallback_to_tutk(t)) {
return open_tutk(t);
}
}
return rc;
}
// TUTK: the same tunnel may be opened for video (Bambu_StartStream) or
// for the file browser (Bambu_StartStreamEx CTRL), and only video goes
// over TUTK, so defer all network I/O until we know which.
if (t->url.scheme == Scheme::Tutk) {
log_fmt(t->logger, t->log_ctx, "Bambu_Open: TUTK tunnel, deferring connect");
return Bambu_success;
}
if (!dial_local_tls(t, "Bambu_Open")) {
if (!t->url.device.empty()) {
log_fmt(t->logger, t->log_ctx,
"Bambu_Open: attempting TUTK cloud fallback for dev=%s",
t->url.device.c_str());
if (fallback_to_tutk(t)) {
return open_tutk(t);
}
}
return -1;
}
log_fmt(t->logger, t->log_ctx,
"Bambu_Open: TLS ready (MJPEG auth deferred to StartStream)");
return Bambu_success;
}
static int bambu_start_stream_impl(Bambu_Tunnel tunnel, bool /*video*/)
{
auto* t = static_cast<Tunnel*>(tunnel);
if (!t) return -1;
// lv= hint from the plugin's get_camera_url LAN fallback: this local-
// scheme tunnel was minted for a printer whose LAN video is RTSP(S)
// (X1/P1S/P2S-class), not MJPEG :6000. The VIDEO stream starts here
// (the CTRL/file-browser channel goes through Bambu_StartStreamEx and
// must keep the :6000 socket), so drop the :6000 TLS session and open
// the RTSP(S) passthrough instead. One-shot: open_rtsp flips
// url.scheme, so repeated StartStream calls fall through below.
if (t->url.scheme == Scheme::Local && !t->ctrl_mode &&
(t->url.lv == "rtsps" || t->url.lv == "rtsp")) {
log_fmt(t->logger, t->log_ctx,
"Bambu_StartStream: lv=%s hint, redirecting video to RTSP(S)",
t->url.lv.c_str());
{
std::lock_guard<std::mutex> lk(t->mjpg_io_mu);
if (t->ssl) {
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;
}
}
t->url.scheme = (t->url.lv == "rtsps") ? Scheme::Rtsps : Scheme::Rtsp;
t->url.port = (t->url.lv == "rtsps") ? 322 : 554;
t->url.path = "/streaming/live/1";
int rc = open_rtsp(t);
if (rc != Bambu_success && !t->url.device.empty()) {
log_fmt(t->logger, t->log_ctx,
"Bambu_StartStream: RTSP redirect failed, attempting TUTK cloud fallback for dev=%s",
t->url.device.c_str());
if (fallback_to_tutk(t)) {
return open_tutk(t);
}
}
return rc;
}
// lv=off: the printer reported LAN Only Liveview off. :6000 only
// serves CTRL then; an MJPEG auth there reads a short non-JPEG reply.
if (t->url.scheme == Scheme::Local && !t->ctrl_mode && t->url.lv == "off") {
log_fmt(t->logger, t->log_ctx,
"Bambu_StartStream: LAN Only Liveview is off on the printer");
if (!t->url.device.empty() && fallback_to_tutk(t)) return open_tutk(t);
set_last_error("LAN Only Liveview is off on the printer");
return -1;
}
if (t->url.scheme == Scheme::Tutk)
return t->tutk_source ? Bambu_success : open_tutk(t);
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;
if (t->url.scheme == Scheme::Local && !t->ctrl_mode && !t->mjpg_authed) {
uint8_t auth[80];
build_auth_packet(t->url, auth);
bool write_failed = false;
{
std::lock_guard<std::mutex> lk(t->mjpg_io_mu);
if (obn::tls::ssl_write_all(t->ssl, auth, sizeof(auth)) != 0) {
write_failed = true;
}
}
if (write_failed) {
set_last_error("MJPEG auth write failed");
if (!t->url.device.empty()) {
log_fmt(t->logger, t->log_ctx,
"Bambu_StartStream: MJPEG auth failed, attempting TUTK cloud fallback for dev=%s",
t->url.device.c_str());
if (fallback_to_tutk(t)) {
return open_tutk(t);
}
}
return -1;
}
t->mjpg_authed = true;
log_fmt(t->logger, t->log_ctx,
"Bambu_StartStream: sent %zu-byte MJPEG auth", sizeof(auth));
}
return Bambu_success;
}
static int bambu_start_stream_ex_impl(Bambu_Tunnel tunnel, int type)
{
auto* t = static_cast<Tunnel*>(tunnel);
if (!t) return -1;
// CTRL_TYPE (0x3001) opens the PrinterFileSystem channel: keep TLS
// :6000 open and forward CTRL JSON to printer firmware.
if (type == kCtrlType) {
if (t->url.scheme == Scheme::Tutk) {
// The FTPS bridge talks to the printer's FTPS server, so
// force_ftps needs the LAN route.
if (ftps_bridge_enabled()) {
log_fmt(t->logger, t->log_ctx,
"ctrl: force_ftps set, file browser goes over LAN");
} else if (t->tutk_ctrl || start_tutk_ctrl(t)) {
return Bambu_success;
}
if (!fallback_to_lan(t)) return -1;
}
// Bambu_Open skips the :6000 dial for lv=rtsps/rtsp URLs, which
// Studio also hands to the file browser.
if (t->url.scheme == Scheme::Local && !t->ssl &&
!dial_local_tls(t, "Bambu_StartStreamEx")) {
return -1;
}
return start_native_ctrl_handshake(t);
}
return bambu_start_stream_impl(tunnel, true);
}
static int bambu_get_stream_count_impl(Bambu_Tunnel tunnel)
{
auto* t = static_cast<Tunnel*>(tunnel);
if (!t) return 0;
if (t->url.scheme == Scheme::Tutk) {
return (t->tutk_source && t->tutk_source->is_open()) ? 1 : 0;
}
if (t->url.scheme == Scheme::Local && !t->ssl) {
// Open deferred the :6000 dial for lv= RTSP(S) video.
if (t->url.lv == "rtsps" || t->url.lv == "rtsp") return 1;
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).
}
static int bambu_get_stream_info_impl(Bambu_Tunnel tunnel, int index,
Bambu_StreamInfo* info)
{
auto* t = static_cast<Tunnel*>(tunnel);
if (!t || !info || index != 0) return -1;
if (t->url.scheme == Scheme::Tutk) {
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;
info->format_type = (t->sub_type == MJPG)
? video_jpeg
: video_avc_byte_stream;
info->format_size = 0;
info->max_frame_size = static_cast<int>(kMaxFrameSize);
info->format_buffer = nullptr;
return Bambu_success;
}
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<int>(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
}
static int bambu_read_sample_impl(Bambu_Tunnel tunnel, Bambu_Sample* sample)
{
auto* t = static_cast<Tunnel*>(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<std::mutex> lk(t->ctrl_mu);
if (t->ctrl_out.empty()) {
if (t->tutk_ctrl && !t->tutk_ctrl->is_joined()) {
set_last_error("TUTK file-browser session lost");
return -1;
}
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<int>(t->ctrl_current_reply.size());
sample->flags = 0;
sample->buffer = reinterpret_cast<const unsigned char*>(
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);
}
// TUTK: pull from TutkCameraSource.
if (t->url.scheme == Scheme::Tutk) {
return read_tutk(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<uint32_t>(hdr[off + 0]))
| (static_cast<uint32_t>(hdr[off + 1]) << 8)
| (static_cast<uint32_t>(hdr[off + 2]) << 16)
| (static_cast<uint32_t>(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<std::chrono::nanoseconds>(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<unsigned long long>(t->frame_count),
payload_size, itrack, flags);
}
sample->itrack = static_cast<int>(itrack);
sample->size = static_cast<int>(payload_size);
sample->flags = static_cast<int>(flags);
sample->buffer = t->frame_buf.data();
// gstbambusrc multiplies decode_time by 100 to get ns, so we divide.
sample->decode_time = static_cast<unsigned long long>(ns / 100);
return Bambu_success;
}
static int bambu_send_message_impl(Bambu_Tunnel tunnel, int ctrl,
char const* data, int len)
{
auto* t = static_cast<Tunnel*>(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) {
set_last_error("CTRL channel not ready");
return -1;
}
if (t->tutk_ctrl) return send_tutk_ctrl(t, std::string(data, static_cast<std::size_t>(len)));
CtrlRequest req;
req.body.assign(data, static_cast<std::size_t>(len));
{
std::lock_guard<std::mutex> 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;
}
static void bambu_close_impl(Bambu_Tunnel tunnel)
{
auto* t = static_cast<Tunnel*>(tunnel);
if (!t) return;
// CTRL worker must be joined before the rest of the tunnel is
// dismantled (it holds references to SSL that tunnel_close would
// would invalidate).
log_abandoned_handshake(t);
stop_ctrl_mode(t);
tunnel_close(t);
}
static void bambu_destroy_impl(Bambu_Tunnel tunnel)
{
auto* t = static_cast<Tunnel*>(tunnel);
if (!t) return;
log_abandoned_handshake(t);
stop_ctrl_mode(t);
tunnel_close(t);
delete t;
}
// -----------------------------------------------------------------------
// Exported entry points: thin wrappers that log each call at DEBUG (and
// the per-sample detail at TRACE) around the implementations above.
// -----------------------------------------------------------------------
static const char* rc_name(int rc)
{
switch (rc) {
case Bambu_success: return "success";
case Bambu_stream_end: return "stream_end";
case Bambu_would_block: return "would_block";
case Bambu_buffer_limit: return "buffer_limit";
default: return "error";
}
}
static bool debug_on() { return obn::source::current_log_level() <= LL_DEBUG; }
static bool trace_on() { return obn::source::current_log_level() <= LL_TRACE; }
// One DEBUG line per CTRL message: cmdtype / sequence / result plus the
// JSON and binary sizes; TRACE adds the JSON itself.
static void log_ctrl_message(Tunnel* t, const char* dir, const char* data, std::size_t len)
{
if (!debug_on()) return;
const std::string wire(data, len);
const std::size_t split = wire.find("\n\n");
const std::string json = wire.substr(0, split);
const std::size_t bin = split == std::string::npos ? 0 : len - split - 2;
std::string perr;
const auto v = obn::json::parse(json, &perr);
if (!v) {
log_at(LL_DEBUG, t->logger, t->log_ctx, "ctrl %s %zu bytes (not JSON: %s)",
dir, len, obn::log::hexdump(data, len).c_str());
return;
}
const auto result = v->find("result");
char result_str[32] = "";
if (result.is_number())
std::snprintf(result_str, sizeof(result_str), " result=%lld",
static_cast<long long>(result.as_number()));
log_at(LL_DEBUG, t->logger, t->log_ctx,
"ctrl %s cmd=0x%04llx seq=%lld%s json=%zu bin=%zu", dir,
static_cast<unsigned long long>(v->find("cmdtype").as_number()),
static_cast<long long>(v->find("sequence").as_number()), result_str,
json.size(), bin);
if (trace_on())
log_at(LL_TRACE, t->logger, t->log_ctx, "ctrl %s json: %s", dir,
obn::log::hexdump(json.data(), json.size(), 2048).c_str());
}
// StartStream(Ex) is polled while it returns would_block: log the first
// would_block and then only the final result with the poll count.
static void log_polled(Tunnel* t, const char* call, int rc)
{
if (rc == Bambu_would_block) {
if (t->poll_blocks++ == 0) {
t->poll_t0 = std::chrono::steady_clock::now();
log_at(LL_DEBUG, t->logger, t->log_ctx, "%s -> would_block, polling", call);
}
return;
}
const long long ms = t->poll_blocks
? std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t->poll_t0).count()
: 0;
log_at(LL_DEBUG, t->logger, t->log_ctx,
"%s -> %d (%s) after %u would_block polls, %lld ms%s%s", call, rc,
rc_name(rc), t->poll_blocks, ms, rc < 0 ? ": " : "",
rc < 0 ? obn::source::get_last_error() : "");
t->poll_blocks = 0;
}
OBN_EXPORT int Bambu_Create(Bambu_Tunnel* tunnel, char const* path)
{
const int rc = bambu_create_impl(tunnel, path);
log_at(LL_DEBUG, nullptr, nullptr, "Bambu_Create -> %d tunnel=%p", rc,
rc == Bambu_success ? *tunnel : nullptr);
return rc;
}
OBN_EXPORT int Bambu_Open(Bambu_Tunnel tunnel)
{
const int rc = bambu_open_impl(tunnel);
if (auto* t = static_cast<Tunnel*>(tunnel))
log_at(LL_DEBUG, t->logger, t->log_ctx, "Bambu_Open(%p) -> %d (%s)%s%s", tunnel,
rc, rc_name(rc), rc < 0 ? ": " : "", rc < 0 ? obn::source::get_last_error() : "");
return rc;
}
OBN_EXPORT int Bambu_StartStream(Bambu_Tunnel tunnel, bool video)
{
const int rc = bambu_start_stream_impl(tunnel, video);
if (auto* t = static_cast<Tunnel*>(tunnel)) {
char call[64];
std::snprintf(call, sizeof(call), "Bambu_StartStream(%p, video=%d)", tunnel, video ? 1 : 0);
log_polled(t, call, rc);
}
return rc;
}
OBN_EXPORT int Bambu_StartStreamEx(Bambu_Tunnel tunnel, int type)
{
const int rc = bambu_start_stream_ex_impl(tunnel, type);
if (auto* t = static_cast<Tunnel*>(tunnel)) {
char call[64];
std::snprintf(call, sizeof(call), "Bambu_StartStreamEx(%p, type=0x%x)", tunnel, type);
log_polled(t, call, rc);
}
return rc;
}
OBN_EXPORT int Bambu_GetStreamCount(Bambu_Tunnel tunnel)
{
const int n = bambu_get_stream_count_impl(tunnel);
log_at(LL_DEBUG, nullptr, nullptr, "Bambu_GetStreamCount(%p) -> %d", tunnel, n);
return n;
}
OBN_EXPORT int Bambu_GetStreamInfo(Bambu_Tunnel tunnel, int index,
Bambu_StreamInfo* info)
{
const int rc = bambu_get_stream_info_impl(tunnel, index, info);
if (rc == Bambu_success)
log_at(LL_DEBUG, nullptr, nullptr,
"Bambu_GetStreamInfo(%p, %d) -> sub_type=%d %dx%d @ %d fps", tunnel, index,
static_cast<int>(info->sub_type), info->format.video.width,
info->format.video.height, info->format.video.frame_rate);
else
log_at(LL_DEBUG, nullptr, nullptr, "Bambu_GetStreamInfo(%p, %d) -> %d", tunnel,
index, rc);
return rc;
}
OBN_EXPORT int Bambu_ReadSample(Bambu_Tunnel tunnel, Bambu_Sample* sample)
{
const int rc = bambu_read_sample_impl(tunnel, sample);
auto* t = static_cast<Tunnel*>(tunnel);
if (!t) return rc;
if (rc == Bambu_success) {
if (t->ctrl_mode) {
log_ctrl_message(t, "<-", reinterpret_cast<const char*>(sample->buffer),
static_cast<std::size_t>(sample->size));
} else if (trace_on()) {
log_at(LL_TRACE, t->logger, t->log_ctx,
"Bambu_ReadSample(%p) track=%d size=%d flags=0x%x time=%llu", tunnel,
sample->itrack, sample->size, sample->flags, sample->decode_time);
}
} else if (rc != Bambu_would_block && rc != t->read_last_rc) {
log_at(LL_DEBUG, t->logger, t->log_ctx, "Bambu_ReadSample(%p) -> %d (%s)%s%s",
tunnel, rc, rc_name(rc), rc < 0 ? ": " : "",
rc < 0 ? obn::source::get_last_error() : "");
}
if (rc != Bambu_would_block) t->read_last_rc = rc;
return rc;
}
OBN_EXPORT int Bambu_SendMessage(Bambu_Tunnel tunnel, int ctrl,
char const* data, int len)
{
const int rc = bambu_send_message_impl(tunnel, ctrl, data, len);
auto* t = static_cast<Tunnel*>(tunnel);
if (t && data && len > 0) {
if (ctrl == kCtrlType) log_ctrl_message(t, "->", data, static_cast<std::size_t>(len));
log_at(LL_DEBUG, t->logger, t->log_ctx, "Bambu_SendMessage(%p, ctrl=0x%x, %d bytes) -> %d%s%s",
tunnel, ctrl, len, rc, rc < 0 ? ": " : "",
rc < 0 ? obn::source::get_last_error() : "");
}
return rc;
}
OBN_EXPORT void Bambu_Close(Bambu_Tunnel tunnel)
{
log_at(LL_DEBUG, nullptr, nullptr, "Bambu_Close(%p)", tunnel);
bambu_close_impl(tunnel);
}
OBN_EXPORT void Bambu_Destroy(Bambu_Tunnel tunnel)
{
log_at(LL_DEBUG, nullptr, nullptr, "Bambu_Destroy(%p)", tunnel);
bambu_destroy_impl(tunnel);
}
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<tchar*>(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
}