forked from ClusterM/open-bamboo-networking
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBambuSource.cpp
More file actions
2442 lines (2267 loc) · 101 KB
/
BambuSource.cpp
File metadata and controls
2442 lines (2267 loc) · 101 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// 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).
//
// Any extra query parameters (device=, net_ver=, dev_ver=, cli_id=, ...)
// are ignored. 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>; printer cert is self-signed,
// we do NOT verify it (same as the stock plugin).
// 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 <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <memory>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <unordered_set>
#include <vector>
#include "image_io.hpp"
#include <zlib.h>
#include "obn/ftps.hpp"
#include "obn/json_lite.hpp"
#include "obn/zip_reader.hpp"
#include "source_log.hpp"
#include "rtsp_passthrough.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/<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)
};
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<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]".
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 "<ip>:<port>/..." 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) {
// "<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; }
i = amp + 1;
}
return !out->host.empty() && out->port > 0;
}
// -----------------------------------------------------------------------
// OpenSSL one-time init. Called lazily from the first Bambu_Create to
// avoid paying the cost in Studio processes that never touch the camera.
// -----------------------------------------------------------------------
std::once_flag g_ssl_init_flag;
SSL_CTX* g_ssl_ctx = nullptr;
void ssl_init_once()
{
std::call_once(g_ssl_init_flag, []() {
SSL_library_init();
OpenSSL_add_all_algorithms();
SSL_load_error_strings();
// Stock plugin accepts any TLS1.2+ handshake from the printer's
// self-signed cert. We mirror that.
g_ssl_ctx = SSL_CTX_new(TLS_client_method());
if (g_ssl_ctx) {
SSL_CTX_set_min_proto_version(g_ssl_ctx, TLS1_VERSION);
SSL_CTX_set_verify(g_ssl_ctx, SSL_VERIFY_NONE, nullptr);
}
});
}
// -----------------------------------------------------------------------
// 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`. 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<blob>" if present
};
struct CtrlReply {
// "wire bytes" already laid out in the PrinterFileSystem format:
// "<json>\n\n<optional blob>". 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<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;
// ---- 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<obn::ftps::Client> 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 <prefix>/<name>; special subtrees like
// `timelapse/` and `ipcam/` hang off <prefix>/ 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<CtrlRequest> ctrl_in;
std::deque<CtrlReply> 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<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;
};
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<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();
}
}
// Resolve-and-connect with a total deadline. Returns kInvalidSocket on
// error. Mirrors obn::tls::dial() so we share Winsock-vs-POSIX shape;
// kept local because the TLS-less MJPG path needs the raw fd before
// SSL_new is called.
obn::os::socket_t dial(const std::string& host, int port, int timeout_ms)
{
obn::os::winsock_init_once();
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
addrinfo* res = nullptr;
char port_s[16];
std::snprintf(port_s, sizeof(port_s), "%d", port);
int gai = ::getaddrinfo(host.c_str(), port_s, &hints, &res);
if (gai != 0 || !res) {
#if defined(_WIN32)
set_last_error(::gai_strerrorA(gai));
#else
set_last_error(::gai_strerror(gai));
#endif
return obn::os::kInvalidSocket;
}
obn::os::socket_t fd = obn::os::kInvalidSocket;
auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(timeout_ms);
for (auto* ai = res; ai; ai = ai->ai_next) {
auto raw = ::socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
fd = static_cast<obn::os::socket_t>(raw);
if (!obn::os::socket_valid(fd)) { fd = obn::os::kInvalidSocket; continue; }
// Keep connect() from blocking forever; 5 s matches what the
// stock plugin uses (observed via strace).
#if defined(_WIN32)
DWORD tv_ms = static_cast<DWORD>(timeout_ms);
::setsockopt(static_cast<SOCKET>(fd), SOL_SOCKET, SO_SNDTIMEO,
reinterpret_cast<const char*>(&tv_ms), sizeof(tv_ms));
::setsockopt(static_cast<SOCKET>(fd), SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<const char*>(&tv_ms), sizeof(tv_ms));
BOOL one_b = TRUE;
::setsockopt(static_cast<SOCKET>(fd), IPPROTO_TCP, TCP_NODELAY,
reinterpret_cast<const char*>(&one_b), sizeof(one_b));
if (::connect(static_cast<SOCKET>(fd), ai->ai_addr,
static_cast<int>(ai->ai_addrlen)) == 0) break;
#else
timeval tv{timeout_ms / 1000, (timeout_ms % 1000) * 1000};
::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
int one = 1;
::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
if (::connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) break;
#endif
obn::os::close_socket(fd);
fd = obn::os::kInvalidSocket;
if (std::chrono::steady_clock::now() > deadline) break;
}
freeaddrinfo(res);
if (!obn::os::socket_valid(fd)) set_last_error("connect failed");
return fd;
}
// 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<const uint8_t*>(buf);
size_t sent = 0;
while (sent < len) {
int n = SSL_write(ssl, p + sent, static_cast<int>(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<size_t>(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<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.
// -----------------------------------------------------------------------
[[maybe_unused]] 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) != 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<int>(size);
sample->flags = flags;
sample->buffer = buf;
sample->decode_time = static_cast<unsigned long long>(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<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));
}
// 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<std::uint8_t>& z,
std::vector<ZipEntry>* o)
{ return obn::zip::read_central(z, o); }
static inline bool zip_extract(const std::vector<std::uint8_t>& z,
const ZipEntry& e,
std::vector<std::uint8_t>* o)
{ return obn::zip::extract(z, e, o); }
static inline const ZipEntry* zip_find(const std::vector<ZipEntry>& 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<std::uint8_t>
reshape_image_to_aspect(const std::vector<std::uint8_t>& 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<double>(img.w) /
static_cast<double>(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::uint32_t>(std::llround(img.h * target_aspect));
if (nw <= img.w) return in;
ox = (nw - img.w) / 2;
} else {
nh = static_cast<std::uint32_t>(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<std::size_t>(nw) * nh * 4, 0);
for (std::uint32_t y = 0; y < img.h; ++y) {
std::memcpy(canvas.pixels.data() +
((static_cast<std::size_t>(oy + y) * nw) + ox) * 4,
img.pixels.data() + static_cast<std::size_t>(y) * img.w * 4,
static_cast<std::size_t>(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::uint32_t>(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::uint32_t>(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<std::size_t>(nw) * nh * 4, 0);
for (std::uint32_t y = 0; y < nh; ++y) {
std::memcpy(canvas.pixels.data() +
static_cast<std::size_t>(y) * nw * 4,
img.pixels.data() +
((static_cast<std::size_t>(sy + y) * img.w) + sx) * 4,
static_cast<std::size_t>(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::uint32_t>(std::llround(img.h * target_aspect));
} else {
nh = static_cast<std::uint32_t>(std::llround(img.w / target_aspect));
}
if (nw == 0 || nh == 0) return in;
canvas.w = nw;
canvas.h = nh;
canvas.pixels.assign(static_cast<std::size_t>(nw) * nh * 4, 0);
const double x_ratio = nw > 1
? static_cast<double>(img.w - 1) / (nw - 1) : 0.0;
const double y_ratio = nh > 1
? static_cast<double>(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<std::uint32_t>(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<std::uint32_t>(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<std::size_t>(y0) * img.w + x0) * 4;
const auto* p01 = img.pixels.data() + (static_cast<std::size_t>(y0) * img.w + x1) * 4;
const auto* p10 = img.pixels.data() + (static_cast<std::size_t>(y1) * img.w + x0) * 4;
const auto* p11 = img.pixels.data() + (static_cast<std::size_t>(y1) * img.w + x1) * 4;
auto* dst = canvas.pixels.data() + (static_cast<std::size_t>(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::uint8_t>(std::lround(
std::max(0.0, std::min(255.0, v))));
}
}
}
}