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,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