Add packaged Insta360 X4 integration and recover paired Node channels
Discover independent camera instances and prepare their versioned runtime from Node or remote Core. Add isolated SDK workers, camera controls, raw dual-fisheye WebRTC preview, and shared action/region loading states. Recover existing Node bindings over known Tailscale addresses after a Core LAN address change. Preserve identities and trust, pin both peers, migrate endpoints with revision checks, and require real heartbeats for online status. Fix the Python client certificate profile for Go X509 verification. Pin Design Guideline 8c53f73 and retain installer/build/acceptance history. Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending. Validation: qualified DG/Node builds and Go race tests; 31 fleet tests; Python-to-Go certificate interoperability and live tailnet recovery with five fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance. Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open.
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
#include "bridge.h"
|
||||
|
||||
#include <camera/camera.h>
|
||||
#include <camera/device_discovery.h>
|
||||
#include <stream/stream_delegate.h>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <iomanip>
|
||||
#include <locale>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace ins_camera;
|
||||
|
||||
namespace {
|
||||
constexpr size_t MAX_PACKET = 4 * 1024 * 1024;
|
||||
constexpr size_t MAX_QUEUE = 8 * 1024 * 1024;
|
||||
constexpr size_t MAX_RESULT = 60 * 1024;
|
||||
|
||||
std::string quote(const std::string& value) {
|
||||
if (value.size() > 1024) throw std::runtime_error("oversized SDK string");
|
||||
std::ostringstream out;
|
||||
out << '"';
|
||||
for (unsigned char c : value) {
|
||||
if (c == '"' || c == '\\') out << '\\' << c;
|
||||
else if (c < 32) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << int(c);
|
||||
else out << c;
|
||||
}
|
||||
return out.str() + '"';
|
||||
}
|
||||
|
||||
template <typename T> bool contains(const std::vector<T>& values, T value) {
|
||||
return std::find(values.begin(), values.end(), value) != values.end();
|
||||
}
|
||||
|
||||
template <typename T> std::string numbers(const std::vector<T>& values) {
|
||||
if (values.size() > 256) throw std::runtime_error("oversized capabilities");
|
||||
std::string out = "[";
|
||||
for (auto v : values) { if (out.size() > 1) out += ','; out += std::to_string(int(v)); }
|
||||
return out + ']';
|
||||
}
|
||||
|
||||
std::string strings(const std::vector<std::string>& values) {
|
||||
if (values.size() > 256) throw std::runtime_error("oversized SDK list");
|
||||
std::string out = "[";
|
||||
for (const auto& v : values) { if (out.size() > 1) out += ','; out += quote(v); }
|
||||
return out + ']';
|
||||
}
|
||||
|
||||
std::string complete(const std::string& result) {
|
||||
return "{\"state\":\"complete\",\"result\":" + result + '}';
|
||||
}
|
||||
const char* UNKNOWN = "{\"state\":\"unknown\",\"error\":\"camera_result_unconfirmed\"}";
|
||||
const char* INVALID = "{\"state\":\"error\",\"error\":\"unsupported_camera_parameter\"}";
|
||||
|
||||
struct Packet { mc_x4_video info; std::vector<uint8_t> bytes; };
|
||||
struct Frames final : StreamDelegate {
|
||||
std::mutex mutex;
|
||||
std::deque<Packet> queue;
|
||||
size_t bytes = 0;
|
||||
uint64_t generation = 0, sequence = 0;
|
||||
std::atomic<int> codec{0};
|
||||
std::atomic<bool> enabled{false};
|
||||
|
||||
void reset() {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
queue.clear(); bytes = 0; ++generation;
|
||||
}
|
||||
void OnVideoData(const uint8_t* data, size_t size, int64_t timestamp,
|
||||
uint8_t, int stream_index) override {
|
||||
if (!enabled || !data || !size || size > MAX_PACKET || stream_index < 0 || stream_index > 1) return;
|
||||
// SDK owns the callback bytes. Copy into a bounded queue; never block
|
||||
// a vendor callback on a browser, disk, decoder or Python interpreter.
|
||||
try {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
if (bytes + size > MAX_QUEUE || queue.size() >= 64) {
|
||||
queue.clear(); bytes = 0; ++generation;
|
||||
}
|
||||
queue.push_back({{++sequence, generation, timestamp, stream_index,
|
||||
codec.load(), uint32_t(size)}, {data, data + size}});
|
||||
bytes += size;
|
||||
} catch (...) {
|
||||
// A memory allocation failure drops data; it must not unwind into
|
||||
// the vendor's callback thread or terminate camera recording.
|
||||
}
|
||||
}
|
||||
void OnAudioData(const uint8_t*, size_t, int64_t) override {}
|
||||
void OnGyroData(const std::vector<GyroData>&) override {}
|
||||
void OnExposureData(const ExposureData&) override {}
|
||||
};
|
||||
|
||||
struct Notifications {
|
||||
std::mutex mutex;
|
||||
std::atomic<int> recording{-1};
|
||||
std::atomic<uint64_t> revision{0};
|
||||
std::atomic<int> stopped_reason{-1};
|
||||
std::atomic<bool> battery_low{false}, storage_full{false}, temperature_high{false};
|
||||
};
|
||||
}
|
||||
|
||||
struct mc_x4 {
|
||||
DeviceDiscovery discovery;
|
||||
std::vector<DeviceDescriptor> descriptors;
|
||||
std::shared_ptr<Camera> camera;
|
||||
std::shared_ptr<Frames> frames = std::make_shared<Frames>();
|
||||
std::shared_ptr<Notifications> notifications = std::make_shared<Notifications>();
|
||||
std::string firmware, result;
|
||||
bool opened = false;
|
||||
int preview = 0;
|
||||
// Only the isolated worker calls control methods; callbacks use separate
|
||||
// shared state that remains alive until the vendor releases its callbacks.
|
||||
~mc_x4() {
|
||||
frames->enabled = false;
|
||||
if (camera && opened) { try { camera->Close(); } catch (...) {} }
|
||||
camera.reset();
|
||||
try { discovery.FreeDeviceDescriptors(descriptors); } catch (...) {}
|
||||
}
|
||||
|
||||
bool mode_supported(CameraFunctionMode mode) {
|
||||
return contains(camera->GetSupportedVideoModes(), mode) || contains(camera->GetSupportedPhotoModes(), mode);
|
||||
}
|
||||
|
||||
std::string status() {
|
||||
BatteryStatus battery{};
|
||||
StorageStatus storage{};
|
||||
bool battery_ok = camera->GetBatteryStatus(battery);
|
||||
bool storage_ok = camera->GetStorageState(storage);
|
||||
// The SDK exposes a boolean capture poll, not a separate timeout code.
|
||||
// Report its value only while independent status reads succeed; never
|
||||
// replace a notification that arrived during the blocking poll.
|
||||
auto revision = notifications->revision.load();
|
||||
bool active = camera->CaptureCurrentStatus();
|
||||
bool connected = camera->IsConnected();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(notifications->mutex);
|
||||
if (revision == notifications->revision.load()) {
|
||||
notifications->recording = connected && battery_ok && storage_ok ? (active ? 1 : 0) : -1;
|
||||
}
|
||||
}
|
||||
std::string result = "{\"firmware\":" + quote(firmware) +
|
||||
",\"connected\":" + (connected ? "true" : "false") +
|
||||
",\"preview\":" + std::to_string(preview) +
|
||||
",\"recording\":" + std::to_string(notifications->recording) +
|
||||
",\"stopped_reason\":" + std::to_string(notifications->stopped_reason) +
|
||||
",\"function_mode\":" + std::to_string(int(camera->GetCurrentFunctionMode())) +
|
||||
",\"codec\":" + std::to_string(int(camera->GetVideoEncodeType())) +
|
||||
",\"battery\":";
|
||||
result += battery_ok ? "{\"level\":" + std::to_string(battery.battery_level) +
|
||||
",\"scale\":" + std::to_string(battery.battery_scale) +
|
||||
",\"power_type\":" + std::to_string(int(battery.power_type)) + "}" : "null";
|
||||
result += ",\"storage\":";
|
||||
result += storage_ok ? "{\"state\":" + std::to_string(int(storage.state)) +
|
||||
",\"free_bytes\":" + std::to_string(storage.free_space) +
|
||||
",\"total_bytes\":" + std::to_string(storage.total_space) + "}" : "null";
|
||||
result += ",\"alerts\":{\"battery_low\":" + std::string(notifications->battery_low ? "true" : "false") +
|
||||
",\"storage_full\":" + (notifications->storage_full ? "true" : "false") +
|
||||
",\"temperature_high\":" + (notifications->temperature_high ? "true" : "false") + "}}";
|
||||
return complete(result);
|
||||
}
|
||||
|
||||
std::string settings(CameraFunctionMode mode) {
|
||||
if (!mode_supported(mode)) return INVALID;
|
||||
if (!camera->SyncPhotographyOptions(mode)) return UNKNOWN;
|
||||
auto capture = camera->GetCaptureSettings(mode);
|
||||
auto exposure = camera->GetExposureSettings(mode);
|
||||
if (!capture || !exposure || !std::isfinite(exposure->ShutterSpeed())) return UNKNOWN;
|
||||
std::ostringstream out;
|
||||
out.imbue(std::locale::classic());
|
||||
out << "{\"mode\":" << int(mode)
|
||||
<< ",\"photo_modes\":" << numbers(camera->GetSupportedPhotoModes())
|
||||
<< ",\"video_modes\":" << numbers(camera->GetSupportedVideoModes())
|
||||
<< ",\"photo_sizes\":" << numbers(camera->GetSupportedPhotoSizes(mode))
|
||||
<< ",\"video_resolutions\":[";
|
||||
auto resolutions = camera->GetSupportedVideoResolutions(mode);
|
||||
if (resolutions.size() > 256) return UNKNOWN;
|
||||
bool first = true;
|
||||
for (auto resolution : resolutions) {
|
||||
if (!first) out << ',';
|
||||
first = false;
|
||||
out << "{\"id\":" << int(resolution) << ",\"name\":" << quote(camera->GetVideoResolutionName(resolution)) << '}';
|
||||
}
|
||||
out << "],\"values\":{\"iso\":" << exposure->Iso()
|
||||
<< ",\"shutter_seconds\":" << std::setprecision(17) << exposure->ShutterSpeed()
|
||||
<< ",\"exposure_mode\":" << int(exposure->ExposureMode())
|
||||
<< ",\"ev_twentieths\":" << exposure->EVBias()
|
||||
<< ",\"white_balance\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_WhiteBalance)
|
||||
<< ",\"video_resolution\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_RecordResolution)
|
||||
<< ",\"photo_size\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_PhotoResolution)
|
||||
<< "},\"attributes\":{";
|
||||
auto names = camera->GetSupportedAttrNames(mode);
|
||||
if (names.size() > 128) return UNKNOWN;
|
||||
first = true;
|
||||
for (const auto& name : names) {
|
||||
if (!first) out << ',';
|
||||
first = false;
|
||||
out << quote(name) << ":{\"values\":" << strings(camera->GetSupportedAttrValues(mode, name))
|
||||
<< ",\"depends_on\":" << strings(camera->GetAttrDependOn(mode, name)) << '}';
|
||||
}
|
||||
return complete(out.str() + "}}");
|
||||
}
|
||||
|
||||
std::string apply(CameraFunctionMode mode, const std::string& key, double value) {
|
||||
if (!mode_supported(mode) || !std::isfinite(value)) return INVALID;
|
||||
status();
|
||||
if (notifications->recording != 0 || camera->CaptureCurrentStatus()) return INVALID;
|
||||
// One parameter per operation: partial application of a multi-setting
|
||||
// request must never be presented as an atomic success.
|
||||
if (!camera->SyncPhotographyOptions(mode)) return UNKNOWN;
|
||||
bool acknowledged = false;
|
||||
if (key == "function_mode") {
|
||||
if (value == FUNCTION_MODE_NORMAL_VIDEO && contains(camera->GetSupportedVideoModes(), FUNCTION_MODE_NORMAL_VIDEO))
|
||||
acknowledged = camera->SetVideoSubMode(VIDEO_NORMAL);
|
||||
else if (value == FUNCTION_MODE_NORMAL_IMAGE && contains(camera->GetSupportedPhotoModes(), FUNCTION_MODE_NORMAL_IMAGE))
|
||||
acknowledged = camera->SetPhotoSubMode(PHOTO_SINGLE);
|
||||
else return INVALID;
|
||||
if (!acknowledged || int(camera->GetCurrentFunctionMode()) != int(value)) return UNKNOWN;
|
||||
return settings(static_cast<CameraFunctionMode>(int(value)));
|
||||
} else if (key == "video_resolution" && value == std::floor(value) && value >= 0 && value <= 4096) {
|
||||
auto resolution = static_cast<VideoResolution>(int(value));
|
||||
if (!contains(camera->GetSupportedVideoResolutions(mode), resolution)) return INVALID;
|
||||
RecordParams params{}; params.resolution = resolution;
|
||||
acknowledged = camera->SetVideoCaptureParams(params, mode);
|
||||
} else if (key == "photo_size" && value == std::floor(value) && value >= 0 && value <= 4096) {
|
||||
auto size = static_cast<PhotoSize>(int(value));
|
||||
if (!contains(camera->GetSupportedPhotoSizes(mode), size)) return INVALID;
|
||||
acknowledged = camera->SetPhotoSize(mode, size);
|
||||
} else {
|
||||
// Exposure/white-balance ranges must come from this camera's
|
||||
// capability table. No undocumented enum or guessed write.
|
||||
const std::string attr = key == "iso" ? "exposure_iso" : key == "white_balance" ? "white_balance" : key == "exposure_mode" ? "exposure_program" : "";
|
||||
if (attr.empty() || value != std::floor(value) || value < 0 || value > 65535) return INVALID;
|
||||
std::string context;
|
||||
for (const auto& dependency : camera->GetAttrDependOn(mode, attr)) {
|
||||
// Resolve the current camera state, never a client-provided
|
||||
// context that could widen a dependent capability branch.
|
||||
auto current = camera->GetCaptureSettings(mode);
|
||||
if (!current || dependency != "record_resolution") return INVALID;
|
||||
if (!context.empty()) context += '|';
|
||||
context += camera->GetVideoResolutionName(static_cast<VideoResolution>(current->GetIntValue(CaptureSettings::CaptureSettings_RecordResolution)));
|
||||
if (context.empty()) return INVALID;
|
||||
}
|
||||
auto values = camera->GetSupportedAttrValues(mode, attr, context);
|
||||
bool admitted = false;
|
||||
for (const auto& option : values) {
|
||||
if (option == std::to_string(int(value)) || camera->GetAttrValueByName(attr, option) == int(value)) admitted = true;
|
||||
}
|
||||
if (!admitted) return INVALID;
|
||||
if (key == "iso") {
|
||||
auto settings = camera->GetExposureSettings(mode);
|
||||
if (!settings || (settings->ExposureMode() != MANUAL && settings->ExposureMode() != ISO_PRIORITY)) return INVALID;
|
||||
settings->SetIso(int(value));
|
||||
acknowledged = camera->SetExposureSettings(mode, settings);
|
||||
} else if (key == "exposure_mode") {
|
||||
if (value > 5) return INVALID;
|
||||
auto settings = camera->GetExposureSettings(mode);
|
||||
if (!settings) return UNKNOWN;
|
||||
settings->SetExposureMode(static_cast<PhotographyOptions_ExposureMode>(int(value)));
|
||||
acknowledged = camera->SetExposureSettings(mode, settings);
|
||||
} else {
|
||||
auto settings = camera->GetCaptureSettings(mode);
|
||||
if (!settings) return UNKNOWN;
|
||||
settings->ResetSettingTypes();
|
||||
// X4 capabilities and the pinned SDK example use Kelvin (0
|
||||
// auto) via SetValue, not the legacy 0..5 WB enum.
|
||||
settings->SetValue(CaptureSettings::CaptureSettings_WhiteBalance, int(value));
|
||||
acknowledged = camera->SetCaptureSettings(mode, settings);
|
||||
}
|
||||
}
|
||||
// Returning the actual refreshed values lets the caller distinguish an
|
||||
// acknowledged request from a setting the camera declined to retain.
|
||||
return acknowledged ? settings(mode) : UNKNOWN;
|
||||
}
|
||||
|
||||
std::string call(int action, int mode_number, const std::string& key, double value) {
|
||||
if (mode_number < 0 || mode_number > 255 || !std::isfinite(value)) return INVALID;
|
||||
if (!camera->IsConnected()) return UNKNOWN;
|
||||
auto mode = static_cast<CameraFunctionMode>(mode_number);
|
||||
if (action == MC_X4_STATUS) return status();
|
||||
if (action == MC_X4_SETTINGS_READ) return settings(mode);
|
||||
if (action == MC_X4_SETTINGS_APPLY) return apply(mode, key, value);
|
||||
if (action == MC_X4_PREVIEW_START) {
|
||||
if (preview == 1) return complete("{\"ok\":true}");
|
||||
if (preview == -1) return UNKNOWN;
|
||||
LiveStreamParam params{};
|
||||
params.enable_audio = false; params.enable_gyro = false; params.using_lrv = false;
|
||||
params.video_resolution = RES_1920_960P30; params.lrv_video_resulution = RES_1920_960P30;
|
||||
params.video_bitrate = 4000000;
|
||||
frames->reset(); frames->codec = int(camera->GetVideoEncodeType()); frames->enabled = true;
|
||||
preview = -1;
|
||||
bool ok = camera->StartLiveStreaming(params);
|
||||
frames->codec = int(camera->GetVideoEncodeType());
|
||||
if (!ok) { frames->enabled = false; return UNKNOWN; }
|
||||
preview = 1;
|
||||
return complete("{\"ok\":true}");
|
||||
}
|
||||
if (action == MC_X4_PREVIEW_STOP) {
|
||||
if (preview == 0) return complete("{\"ok\":true}");
|
||||
preview = -1;
|
||||
if (!camera->StopLiveStreaming()) return UNKNOWN;
|
||||
preview = 0; frames->enabled = false; frames->reset();
|
||||
return complete("{\"ok\":true}");
|
||||
}
|
||||
if (action == MC_X4_RECORD_START) {
|
||||
if (camera->CaptureCurrentStatus()) {
|
||||
notifications->recording = 1;
|
||||
return complete("{\"recording\":true}");
|
||||
}
|
||||
if (camera->GetCurrentFunctionMode() != FUNCTION_MODE_NORMAL_VIDEO) return INVALID;
|
||||
uint64_t revision;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(notifications->mutex);
|
||||
revision = notifications->revision; notifications->recording = -1;
|
||||
}
|
||||
if (!camera->StartRecording()) return UNKNOWN;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(notifications->mutex);
|
||||
// A storage/temperature stop notification can precede the
|
||||
// START acknowledgement. Never overwrite that newer fact.
|
||||
if (notifications->revision == revision) notifications->recording = 1;
|
||||
if (notifications->recording != 1) return UNKNOWN;
|
||||
}
|
||||
return complete("{\"recording\":true}");
|
||||
}
|
||||
if (action == MC_X4_RECORD_STOP) {
|
||||
uint64_t revision;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(notifications->mutex);
|
||||
revision = notifications->revision; notifications->recording = -1;
|
||||
}
|
||||
auto media = camera->StopRecording();
|
||||
if (media.Empty()) return UNKNOWN;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(notifications->mutex);
|
||||
if (notifications->revision == revision) notifications->recording = 0;
|
||||
if (notifications->recording != 0) return UNKNOWN;
|
||||
}
|
||||
return complete("{\"recording\":false,\"files\":" + strings(media.OriginUrls()) + '}');
|
||||
}
|
||||
if (action == MC_X4_PHOTO) {
|
||||
status();
|
||||
if (notifications->recording != 0 || camera->CaptureCurrentStatus()) return INVALID;
|
||||
// Capture the operator-selected mode. Initialization never changes
|
||||
// it or takes a photo as a side effect of verifying live frames.
|
||||
if (camera->GetCurrentFunctionMode() != FUNCTION_MODE_NORMAL_IMAGE) return INVALID;
|
||||
auto media = camera->TakePhoto(RawCaptureType::PureShot, 15000);
|
||||
return media.Empty() ? UNKNOWN : complete("{\"files\":" + strings(media.OriginUrls()) + '}');
|
||||
}
|
||||
if (action == MC_X4_FILES) {
|
||||
if (value < 0 || value > 100000 || value != std::floor(value)) return INVALID;
|
||||
auto files = camera->GetCameraFilesList();
|
||||
size_t offset = size_t(value), stop = std::min(files.size(), offset + 32);
|
||||
if (files.size() > 100000 || offset > files.size()) return INVALID;
|
||||
std::vector<std::string> page(files.begin() + offset, files.begin() + stop);
|
||||
return complete("{\"items\":" + strings(page) + ",\"total\":" + std::to_string(files.size()) +
|
||||
",\"offset\":" + std::to_string(offset) + '}');
|
||||
}
|
||||
return INVALID;
|
||||
}
|
||||
};
|
||||
|
||||
extern "C" {
|
||||
int mc_x4_abi(void) { return 1; }
|
||||
mc_x4* mc_x4_open(const char* serial, const char* log_directory) {
|
||||
try {
|
||||
if (!serial || !*serial || std::strlen(serial) > 256 || !log_directory || !*log_directory) return nullptr;
|
||||
auto handle = std::make_unique<mc_x4>();
|
||||
SetLogPath(log_directory);
|
||||
SetLogLevel(LogLevel::ERR);
|
||||
handle->descriptors = handle->discovery.GetAvailableDevices();
|
||||
// The service namespace must expose exactly one physical camera.
|
||||
// Never take list[0] from a host-wide SDK enumeration.
|
||||
if (handle->descriptors.size() != 1) return nullptr;
|
||||
const auto& device = handle->descriptors.front();
|
||||
if (device.camera_type != CameraType::Insta360X4 || device.info.connection_type != ConnectionType::USB || device.serial_number != serial) return nullptr;
|
||||
handle->firmware = device.fw_version;
|
||||
handle->camera = std::make_shared<Camera>(device.info);
|
||||
handle->camera->SetServicePort(9099); // private network namespace per instance
|
||||
handle->camera->SetTimeout(10000);
|
||||
handle->opened = handle->camera->Open();
|
||||
if (!handle->opened) return nullptr;
|
||||
auto n = handle->notifications;
|
||||
handle->camera->SetCaptureStateNotification([n](bool active) {
|
||||
std::lock_guard<std::mutex> lock(n->mutex);
|
||||
n->recording = active ? 1 : 0; ++n->revision;
|
||||
});
|
||||
handle->camera->SetCaptureStoppedNotification([n](const std::string&, int reason) {
|
||||
std::lock_guard<std::mutex> lock(n->mutex);
|
||||
n->recording = 0; n->stopped_reason = reason; ++n->revision;
|
||||
});
|
||||
handle->camera->SetBatteryLowNotification([n](int) { n->battery_low = true; });
|
||||
handle->camera->SetStorageFullNotification([n]() { n->storage_full = true; });
|
||||
handle->camera->SetTemperatureHighNotification([n]() { n->temperature_high = true; });
|
||||
std::shared_ptr<StreamDelegate> delegate = handle->frames;
|
||||
handle->camera->SetStreamDelegate(delegate);
|
||||
return handle.release();
|
||||
} catch (...) { return nullptr; }
|
||||
}
|
||||
const char* mc_x4_call(mc_x4* handle, int action, int mode, const char* key, double value) {
|
||||
if (!handle) return UNKNOWN;
|
||||
try {
|
||||
if (key && std::strlen(key) > 64) return INVALID;
|
||||
handle->result = handle->call(action, mode, key ? key : "", value);
|
||||
if (handle->result.size() > MAX_RESULT) handle->result = UNKNOWN;
|
||||
return handle->result.c_str();
|
||||
} catch (...) { return UNKNOWN; }
|
||||
}
|
||||
int mc_x4_read_video(mc_x4* handle, mc_x4_video* info, uint8_t* buffer, size_t capacity) {
|
||||
if (!handle || !info || !buffer) return -1;
|
||||
try {
|
||||
std::lock_guard<std::mutex> lock(handle->frames->mutex);
|
||||
if (handle->frames->queue.empty()) return 0;
|
||||
auto& packet = handle->frames->queue.front();
|
||||
if (capacity < packet.bytes.size()) return -1;
|
||||
*info = packet.info;
|
||||
std::memcpy(buffer, packet.bytes.data(), packet.bytes.size());
|
||||
handle->frames->bytes -= packet.bytes.size();
|
||||
handle->frames->queue.pop_front();
|
||||
return 1;
|
||||
} catch (...) { return -1; }
|
||||
}
|
||||
void mc_x4_close(mc_x4* handle) { try { delete handle; } catch (...) {} }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#if defined(__GNUC__)
|
||||
#define MC_X4_API __attribute__((visibility("default")))
|
||||
#else
|
||||
#define MC_X4_API
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Private ABI v1. One handle in each physically isolated camera worker. The
|
||||
// caller must restrict USB access BEFORE loading this library or discovering.
|
||||
typedef struct mc_x4 mc_x4;
|
||||
enum mc_x4_action {
|
||||
MC_X4_STATUS = 1, MC_X4_SETTINGS_READ = 2, MC_X4_SETTINGS_APPLY = 3,
|
||||
MC_X4_PREVIEW_START = 4, MC_X4_PREVIEW_STOP = 5,
|
||||
MC_X4_RECORD_START = 6, MC_X4_RECORD_STOP = 7, MC_X4_PHOTO = 8,
|
||||
MC_X4_FILES = 9
|
||||
};
|
||||
typedef struct mc_x4_video {
|
||||
uint64_t sequence;
|
||||
uint64_t generation; // changes on queue overflow / preview restart
|
||||
int64_t timestamp; // camera clock; units are not assumed by the bridge
|
||||
int32_t stream_index;
|
||||
int32_t codec; // 0 = H264, 1 = H265
|
||||
uint32_t bytes;
|
||||
} mc_x4_video;
|
||||
|
||||
MC_X4_API int mc_x4_abi(void);
|
||||
MC_X4_API mc_x4* mc_x4_open(const char* expected_serial, const char* private_log_directory);
|
||||
// JSON result owned by the handle; copy before the next call. Calls are
|
||||
// serialized by the worker. No vendor exception or C++ object crosses the ABI.
|
||||
MC_X4_API const char* mc_x4_call(mc_x4*, int action, int mode, const char* key, double value);
|
||||
MC_X4_API int mc_x4_read_video(mc_x4*, mc_x4_video*, uint8_t* buffer, size_t capacity);
|
||||
// Does not call StopRecording or change auto-stop-on-disconnect policy.
|
||||
MC_X4_API void mc_x4_close(mc_x4*);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Execute OUR native adapter against synthetic SDK symbols, never vendor code.
|
||||
|
||||
Requires the separately acquired, locked SDK headers and a local C++ compiler.
|
||||
This does not substitute for Linux linking, USB isolation or hardware tests.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class Header(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("sequence", ctypes.c_uint64),
|
||||
("generation", ctypes.c_uint64),
|
||||
("timestamp", ctypes.c_int64),
|
||||
("stream_index", ctypes.c_int32),
|
||||
("codec", ctypes.c_int32),
|
||||
("bytes", ctypes.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
def library(folder):
|
||||
compiler = shutil.which("clang++") or shutil.which("g++")
|
||||
if not compiler or not (ROOT / "build/sdk/include/camera/camera.h").exists():
|
||||
raise unittest.SkipTest(
|
||||
"Locked SDK headers and a C++ compiler are required for the native simulator"
|
||||
)
|
||||
target = folder / ("adapter.dylib" if sys.platform == "darwin" else "adapter.so")
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-shared",
|
||||
"-fPIC",
|
||||
"-pthread",
|
||||
"-I",
|
||||
str(ROOT / "build/sdk/include"),
|
||||
str(ROOT / "native/bridge.cpp"),
|
||||
str(ROOT / "native/tests/fake_sdk.cpp"),
|
||||
"-o",
|
||||
str(target),
|
||||
],
|
||||
check=True,
|
||||
timeout=60,
|
||||
capture_output=True,
|
||||
)
|
||||
api = ctypes.CDLL(str(target))
|
||||
api.mc_x4_open.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
api.mc_x4_open.restype = ctypes.c_void_p
|
||||
api.mc_x4_call.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_double,
|
||||
]
|
||||
api.mc_x4_call.restype = ctypes.c_char_p
|
||||
api.mc_x4_read_video.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(Header),
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
api.mc_x4_read_video.restype = ctypes.c_int
|
||||
api.mc_x4_close.argtypes = [ctypes.c_void_p]
|
||||
api.mc_x4_close.restype = None
|
||||
return api
|
||||
|
||||
|
||||
def call(camera, action, mode=0, key="", value=0):
|
||||
api, handle = camera
|
||||
return json.loads(api.mc_x4_call(handle, action, mode, key.encode(), value))
|
||||
|
||||
|
||||
def test_discovery_never_selects_first_of_multiple_cameras(library):
|
||||
library.test_reset()
|
||||
library.test_discovered(2)
|
||||
assert not library.mc_x4_open(b"SYNTHETIC-X4", b"/unused")
|
||||
assert library.test_count(0) == 0 and library.test_count(2) == 1
|
||||
library.test_discovered(1)
|
||||
assert not library.mc_x4_open(b"WRONG-SERIAL", b"/unused")
|
||||
assert library.test_count(0) == 0 and library.test_count(2) == 2
|
||||
|
||||
|
||||
def test_preview_stop_and_close_do_not_stop_recording(camera):
|
||||
api, handle = camera
|
||||
assert call(camera, 6)["state"] == "complete"
|
||||
assert call(camera, 4)["state"] == "complete"
|
||||
assert call(camera, 5)["state"] == "complete"
|
||||
assert call(camera, 1)["result"]["recording"] == 1
|
||||
assert api.test_count(5) == 0 and api.test_count(7) == 1
|
||||
# Fixture closes the SDK handle after assertions; test the cleanup counter
|
||||
# independently by closing a second handle in the next dedicated test.
|
||||
|
||||
|
||||
def test_close_only_releases_sdk_session(library):
|
||||
library.test_reset()
|
||||
handle = library.mc_x4_open(b"SYNTHETIC-X4", b"/unused")
|
||||
library.test_recording(1)
|
||||
library.mc_x4_close(handle)
|
||||
assert library.test_count(1) == 1 and library.test_count(2) == 1
|
||||
assert library.test_count(5) == 0
|
||||
|
||||
|
||||
def test_lost_start_ack_is_unknown_and_stop_remains_possible(camera):
|
||||
api, _ = camera
|
||||
api.test_ack(0, 0)
|
||||
assert call(camera, 6)["state"] == "unknown"
|
||||
assert call(camera, 1)["result"]["recording"] == 1
|
||||
assert call(camera, 7)["state"] == "complete"
|
||||
assert api.test_count(4) == 1 and api.test_count(5) == 1
|
||||
api.test_ack(1, 0)
|
||||
assert call(camera, 4)["state"] == "unknown"
|
||||
assert call(camera, 4)["state"] == "unknown"
|
||||
assert api.test_count(6) == 1
|
||||
assert call(camera, 5)["state"] == "complete"
|
||||
assert api.test_count(7) == 1
|
||||
|
||||
|
||||
def test_capabilities_reject_unsupported_values_and_recording_mutations(camera):
|
||||
api, _ = camera
|
||||
assert call(camera, 3, 7, "video_resolution", 999)["state"] == "error"
|
||||
assert (
|
||||
call(camera, 3, 7, "iso", 6400)["state"] == "error"
|
||||
) # current resolution branch excludes it
|
||||
assert call(camera, 3, 7, "iso", 400)["state"] == "complete"
|
||||
result = call(camera, 3, 7, "white_balance", 5000)
|
||||
assert result["result"]["values"]["white_balance"] == 5000 # Kelvin, not legacy enum
|
||||
assert api.test_count(3) == 2
|
||||
api.test_recording(1)
|
||||
assert call(camera, 3, 7, "video_resolution", 2)["state"] == "error"
|
||||
assert api.test_count(3) == 2
|
||||
api.test_connected(0)
|
||||
assert call(camera, 3, 7, "white_balance", 0)["state"] == "unknown"
|
||||
assert api.test_count(3) == 2
|
||||
|
||||
|
||||
def test_frames_copy_vendor_memory_preserve_stream_and_bound_backlog(camera):
|
||||
api, handle = camera
|
||||
call(camera, 4)
|
||||
api.test_packet(1, 24, 91)
|
||||
info, buffer = Header(), (ctypes.c_uint8 * 24)()
|
||||
assert api.mc_x4_read_video(handle, ctypes.byref(info), buffer, 24) == 1
|
||||
assert (info.stream_index, info.timestamp, info.bytes) == (1, 12345, 24)
|
||||
assert bytes(buffer) == bytes([91]) * 24
|
||||
generation = info.generation
|
||||
for i in range(65):
|
||||
api.test_packet(0, 24, i)
|
||||
assert api.mc_x4_read_video(handle, ctypes.byref(info), buffer, 24) == 1
|
||||
assert info.generation > generation # decoder must reset after this loss
|
||||
assert bytes(buffer) == bytes([64]) * 24
|
||||
assert api.mc_x4_read_video(handle, ctypes.byref(info), buffer, 24) == 0
|
||||
|
||||
|
||||
def test_camera_files_are_bounded_pages(camera):
|
||||
first = call(camera, 9)["result"]
|
||||
last = call(camera, 9, value=64)["result"]
|
||||
assert len(first["items"]) == 32 and first["total"] == 65
|
||||
assert len(last["items"]) == 1 and last["offset"] == 64
|
||||
assert call(camera, 9, value=66)["state"] == "error"
|
||||
|
||||
|
||||
def test_early_camera_stop_notification_wins_over_start_acknowledgement(camera):
|
||||
api, _ = camera
|
||||
api.test_ack(2, 1)
|
||||
assert call(camera, 6)["state"] == "unknown"
|
||||
assert call(camera, 1)["result"]["recording"] == 0
|
||||
assert api.test_count(4) == 1
|
||||
|
||||
|
||||
class TestNativeBridge(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.folder = tempfile.TemporaryDirectory(prefix="x4-native-synthetic-", dir=ROOT / "build")
|
||||
cls.addClassCleanup(cls.folder.cleanup)
|
||||
cls.api = library(Path(cls.folder.name))
|
||||
|
||||
|
||||
def test_case(function):
|
||||
def execute(self):
|
||||
self.api.test_reset()
|
||||
if function.__code__.co_varnames[0] == "library":
|
||||
function(self.api)
|
||||
return
|
||||
handle = self.api.mc_x4_open(b"SYNTHETIC-X4", b"/unused")
|
||||
self.assertTrue(handle)
|
||||
try:
|
||||
function((self.api, handle))
|
||||
finally:
|
||||
self.api.mc_x4_close(handle)
|
||||
|
||||
return execute
|
||||
|
||||
|
||||
for test_name, test_function in list(globals().items()):
|
||||
if test_name.startswith("test_") and test_name != "test_case":
|
||||
setattr(TestNativeBridge, test_name, test_case(test_function))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,129 @@
|
||||
// Test double for the pinned PUBLIC HEADERS. No vendor .so is linked/loaded.
|
||||
// Deliberately models ambiguous acknowledgements and separate preview/recording.
|
||||
#include <camera/camera.h>
|
||||
#include <camera/device_discovery.h>
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
int discovered = 1, opens = 0, closes = 0, freed = 0, writes = 0;
|
||||
int record_starts = 0, record_stops = 0, previews = 0, preview_stops = 0;
|
||||
bool recording = false, start_ack = true, preview_ack = true, connected = true;
|
||||
bool stop_before_ack = false;
|
||||
int function_mode = 7;
|
||||
std::shared_ptr<ins_camera::StreamDelegate> delegate;
|
||||
ins_camera::CaptureStateCallBack capture_callback;
|
||||
std::shared_ptr<ins_camera::CaptureSettings> capture;
|
||||
std::shared_ptr<ins_camera::ExposureSettings> exposure;
|
||||
}
|
||||
|
||||
namespace ins_camera {
|
||||
class CameraImpl {};
|
||||
class ExposureSettingsPrivate {
|
||||
public:
|
||||
int iso = 100, ev = 0;
|
||||
double shutter = 1.0 / 60;
|
||||
PhotographyOptions_ExposureMode mode = MANUAL;
|
||||
};
|
||||
void SetLogPath(const std::string&) {}
|
||||
void SetLogLevel(LogLevel) {}
|
||||
Camera::Camera(const DeviceConnectionInfo&) {}
|
||||
bool Camera::Open() const { ++opens; return true; }
|
||||
void Camera::Close() const { ++closes; delegate.reset(); capture_callback = nullptr; }
|
||||
void Camera::SetServicePort(int) {}
|
||||
void Camera::SetTimeout(int) {}
|
||||
bool Camera::IsConnected() { return connected; }
|
||||
bool Camera::CaptureCurrentStatus() const { return recording; }
|
||||
bool Camera::GetBatteryStatus(BatteryStatus& out) { out = {ADAPTER, 80, 100}; return connected; }
|
||||
bool Camera::GetStorageState(StorageStatus& out, CardLocation) { out = {STOR_CS_PASS, 1000, 2000}; return connected; }
|
||||
CameraFunctionMode Camera::GetCurrentFunctionMode() const { return static_cast<CameraFunctionMode>(function_mode); }
|
||||
VideoEncodeType Camera::GetVideoEncodeType() const { return VideoEncodeType::H264; }
|
||||
void Camera::SetStreamDelegate(std::shared_ptr<StreamDelegate>& value) { delegate = value; }
|
||||
void Camera::SetCaptureStateNotification(CaptureStateCallBack cb) { capture_callback = cb; }
|
||||
void Camera::SetCaptureStoppedNotification(CaptureStoppedCallBack) {}
|
||||
void Camera::SetBatteryLowNotification(BatteryLowCallBack) {}
|
||||
void Camera::SetStorageFullNotification(StorageFullCallBack) {}
|
||||
void Camera::SetTemperatureHighNotification(TemperatureHighCallBack) {}
|
||||
bool Camera::StartRecording() {
|
||||
++record_starts; recording = true;
|
||||
if (stop_before_ack) { recording = false; if (capture_callback) capture_callback(false); }
|
||||
return start_ack;
|
||||
}
|
||||
MediaUrl Camera::StopRecording() { ++record_stops; recording = false; return MediaUrl({"/DCIM/synthetic.insv"}); }
|
||||
bool Camera::StartLiveStreaming(const LiveStreamParam& param) {
|
||||
if (param.enable_audio || param.enable_gyro || param.using_lrv || param.video_resolution != RES_1920_960P30) throw std::runtime_error("unexpected preview profile");
|
||||
++previews; return preview_ack;
|
||||
}
|
||||
bool Camera::StopLiveStreaming() { ++preview_stops; return true; }
|
||||
MediaUrl Camera::TakePhoto(RawCaptureType, int) const { return MediaUrl({"/DCIM/synthetic.insp"}); }
|
||||
std::vector<std::string> Camera::GetCameraFilesList() const {
|
||||
std::vector<std::string> out;
|
||||
for (int i = 0; i < 65; ++i) out.push_back("/DCIM/synthetic_" + std::to_string(i) + ".insv");
|
||||
return out;
|
||||
}
|
||||
std::vector<DeviceDescriptor> DeviceDiscovery::GetAvailableDevices() {
|
||||
std::vector<DeviceDescriptor> out;
|
||||
for (int i = 0; i < discovered; ++i) out.push_back({CameraType::Insta360X4, "SYNTHETIC-X4", "Insta360 X4", "TEST-FW", {ConnectionType::USB, "Insta360 X4", nullptr}});
|
||||
return out;
|
||||
}
|
||||
void DeviceDiscovery::FreeDeviceDescriptors(std::vector<DeviceDescriptor>) { ++freed; }
|
||||
MediaUrl::MediaUrl(const std::vector<std::string>& origins, const std::vector<std::string>& proxies): uris_(origins), lrv_uris_(proxies) {}
|
||||
bool MediaUrl::Empty() const { return uris_.empty(); }
|
||||
const std::vector<std::string>& MediaUrl::OriginUrls() const { return uris_; }
|
||||
|
||||
bool Camera::SyncPhotographyOptions(CameraFunctionMode) { return connected; }
|
||||
std::shared_ptr<CaptureSettings> Camera::GetCaptureSettings(CameraFunctionMode) const { return capture; }
|
||||
std::shared_ptr<ExposureSettings> Camera::GetExposureSettings(CameraFunctionMode) const { return exposure; }
|
||||
bool Camera::SetCaptureSettings(CameraFunctionMode, std::shared_ptr<CaptureSettings> value) { ++writes; capture = value; return true; }
|
||||
bool Camera::SetExposureSettings(CameraFunctionMode, const std::shared_ptr<ExposureSettings>& value) { ++writes; exposure = value; return true; }
|
||||
bool Camera::SetVideoCaptureParams(RecordParams value, CameraFunctionMode) { ++writes; capture->SetValue(CaptureSettings::CaptureSettings_RecordResolution, int(value.resolution)); return true; }
|
||||
bool Camera::SetPhotoSize(CameraFunctionMode, const PhotoSize& size) { ++writes; capture->SetValue(CaptureSettings::CaptureSettings_PhotoResolution, int(size)); return true; }
|
||||
bool Camera::SetVideoSubMode(SubVideoMode) { ++writes; function_mode = 7; return true; }
|
||||
bool Camera::SetPhotoSubMode(SubPhotoMode) { ++writes; function_mode = 6; return true; }
|
||||
std::vector<CameraFunctionMode> Camera::GetSupportedVideoModes() const { return {FUNCTION_MODE_NORMAL_VIDEO}; }
|
||||
std::vector<CameraFunctionMode> Camera::GetSupportedPhotoModes() const { return {FUNCTION_MODE_NORMAL_IMAGE}; }
|
||||
std::vector<PhotoSize> Camera::GetSupportedPhotoSizes(CameraFunctionMode mode) const { return mode == FUNCTION_MODE_NORMAL_IMAGE ? std::vector<PhotoSize>{Size_5952_2976} : std::vector<PhotoSize>{}; }
|
||||
std::vector<VideoResolution> Camera::GetSupportedVideoResolutions(CameraFunctionMode mode) const { return mode == FUNCTION_MODE_NORMAL_VIDEO ? std::vector<VideoResolution>{RES_3840_1920P30, RES_1920_960P30} : std::vector<VideoResolution>{}; }
|
||||
std::string Camera::GetVideoResolutionName(VideoResolution value) const { return value == RES_1920_960P30 ? "1920_960_30" : "3840_1920_30"; }
|
||||
std::vector<std::string> Camera::GetSupportedAttrNames(CameraFunctionMode) const { return {"white_balance", "exposure_iso", "exposure_program"}; }
|
||||
std::vector<std::string> Camera::GetAttrDependOn(CameraFunctionMode, const std::string& name) const { return name == "exposure_iso" ? std::vector<std::string>{"record_resolution"} : std::vector<std::string>{}; }
|
||||
std::vector<std::string> Camera::GetSupportedAttrValues(CameraFunctionMode, const std::string& name, const std::string& context) const {
|
||||
if (name == "white_balance") return {"0", "5000"};
|
||||
if (name == "exposure_program") return {"AUTO", "MANUAL"};
|
||||
if (name == "exposure_iso") return context.empty() ? std::vector<std::string>{"100", "6400"} : std::vector<std::string>{"100", "400"};
|
||||
return {};
|
||||
}
|
||||
int Camera::GetAttrValueByName(const std::string&, const std::string& value) { return value == "AUTO" ? 0 : value == "MANUAL" ? 3 : -1; }
|
||||
void CaptureSettings::ResetSettingTypes() { types_.clear(); }
|
||||
void CaptureSettings::SetValue(SettingsType type, int32_t value, bool) { int_values_[type] = value; }
|
||||
int32_t CaptureSettings::GetIntValue(SettingsType type) const { auto v = int_values_.find(type); return v == int_values_.end() ? 0 : v->second; }
|
||||
ExposureSettings::ExposureSettings(): private_impl_(std::make_shared<ExposureSettingsPrivate>()) {}
|
||||
int32_t ExposureSettings::Iso() const { return private_impl_->iso; }
|
||||
double ExposureSettings::ShutterSpeed() const { return private_impl_->shutter; }
|
||||
PhotographyOptions_ExposureMode ExposureSettings::ExposureMode() const { return private_impl_->mode; }
|
||||
int32_t ExposureSettings::EVBias() const { return private_impl_->ev; }
|
||||
void ExposureSettings::SetIso(int32_t value) { private_impl_->iso = value; }
|
||||
void ExposureSettings::SetExposureMode(PhotographyOptions_ExposureMode value) { private_impl_->mode = value; }
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
void test_reset() {
|
||||
discovered = 1; opens = closes = freed = writes = record_starts = record_stops = previews = preview_stops = 0;
|
||||
recording = stop_before_ack = false; connected = start_ack = preview_ack = true; function_mode = 7;
|
||||
delegate.reset(); capture_callback = nullptr;
|
||||
capture = std::make_shared<ins_camera::CaptureSettings>(); exposure = std::make_shared<ins_camera::ExposureSettings>();
|
||||
}
|
||||
int test_count(int which) {
|
||||
switch (which) { case 0: return opens; case 1: return closes; case 2: return freed; case 3: return writes; case 4: return record_starts; case 5: return record_stops; case 6: return previews; case 7: return preview_stops; default: return -1; }
|
||||
}
|
||||
void test_discovered(int count) { discovered = count; }
|
||||
void test_ack(int type, int value) { if (type == 0) start_ack = value; else if (type == 1) preview_ack = value; else stop_before_ack = value; }
|
||||
void test_connected(int value) { connected = value; }
|
||||
void test_recording(int value) { recording = value; if (capture_callback) capture_callback(recording); }
|
||||
void test_packet(int stream_index, int size, int marker) {
|
||||
if (!delegate) return;
|
||||
std::vector<uint8_t> packet(size, uint8_t(marker));
|
||||
delegate->OnVideoData(packet.data(), packet.size(), 12345, 0, stream_index);
|
||||
std::fill(packet.begin(), packet.end(), 0); // prove bridge owns copied bytes
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user