Initial commit: pure-Rust cross-platform Insta360 Link controller suite
This commit is contained in:
27 files changed
+22165
No files matched your search
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "insta360-link-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Cross-platform HTTP REST & WebSocket remote control server for Insta360 Link cameras"
|
||||
|
||||
[[bin]]
|
||||
name = "insta360-link-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
insta360-link-protocol = { path = "../insta360-link-protocol" }
|
||||
clap.workspace = true
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
tower-http.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,103 @@
|
||||
use clap::{Parser, ValueEnum};
|
||||
use insta360_link_protocol::{
|
||||
CameraModel, Insta360CameraController, NusbTransport, UvcControlTransport,
|
||||
};
|
||||
use insta360_link_server::{build_router, new_mock_state, AppState, ServerTransport};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "insta360-link-server",
|
||||
version,
|
||||
about = "Cross-platform HTTP REST & WebSocket remote control server for Insta360 Link cameras"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Bind address for HTTP and WebSocket server
|
||||
#[arg(long, default_value = "127.0.0.1:9360", value_name = "ADDR")]
|
||||
bind: SocketAddr,
|
||||
|
||||
/// Run against an in-memory simulated camera model instead of physical USB
|
||||
#[arg(long, value_enum, value_name = "MODEL")]
|
||||
mock: Option<MockModelArg>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub enum MockModelArg {
|
||||
#[value(name = "link", alias = "link1")]
|
||||
Link,
|
||||
#[value(name = "link2")]
|
||||
Link2,
|
||||
#[value(name = "link2c")]
|
||||
Link2c,
|
||||
}
|
||||
|
||||
impl From<MockModelArg> for CameraModel {
|
||||
fn from(val: MockModelArg) -> Self {
|
||||
match val {
|
||||
MockModelArg::Link => CameraModel::Link,
|
||||
MockModelArg::Link2 => CameraModel::Link2,
|
||||
MockModelArg::Link2c => CameraModel::Link2C,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "insta360_link_server=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let state: AppState = match cli.mock {
|
||||
Some(mock_arg) => {
|
||||
let model: CameraModel = mock_arg.into();
|
||||
tracing::info!(
|
||||
"Initializing in-memory simulated camera: {} (PID: 0x{:04x})",
|
||||
model.display_name(),
|
||||
model.pid()
|
||||
);
|
||||
new_mock_state(model)
|
||||
}
|
||||
None => {
|
||||
tracing::info!("Discovering and connecting to physical Insta360 Link camera via USB...");
|
||||
match NusbTransport::open_first() {
|
||||
Ok(usb) => {
|
||||
let info = usb.camera_info().clone();
|
||||
tracing::info!(
|
||||
"Connected to physical camera: {} (Bus {} Addr {} • PID 0x{:04x})",
|
||||
info.name,
|
||||
info.bus_number,
|
||||
info.device_address,
|
||||
info.product_id
|
||||
);
|
||||
let ctrl = Insta360CameraController::new(ServerTransport::Usb(usb));
|
||||
Arc::new(Mutex::new(ctrl))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Could not open physical Insta360 Link camera: {}. Falling back to simulated Link 2 camera. Pass '--mock <link|link2|link2c>' to select a specific model.",
|
||||
e
|
||||
);
|
||||
new_mock_state(CameraModel::Link2)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let router = build_router(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
|
||||
tracing::info!("Insta360 Link Server listening on http://{}", cli.bind);
|
||||
tracing::info!("Web Remote UI: http://{}", cli.bind);
|
||||
tracing::info!("WebSocket stream: ws://{}/ws", cli.bind);
|
||||
tracing::info!("REST State API: http://{}/api/state", cli.bind);
|
||||
|
||||
axum::serve(listener, router).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use insta360_link_protocol::{
|
||||
AudioCaptureMode, CameraModel, CompositionStyle, ExtendFunction, FilterMode, Gesture,
|
||||
GestureFunction, TrackSpeed, VideoMode,
|
||||
};
|
||||
use insta360_link_server::{
|
||||
build_router, new_mock_state, AudioRequest, CameraStateSnapshot, ImageRequest, ModeRequest,
|
||||
PresetRequest, PtzRequest, SystemRequest, TrackingRequest,
|
||||
};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
/// Binds an ephemeral TCP listener on 127.0.0.1:0, attaches the axum router
|
||||
/// powered by `new_mock_state(model)`, spawns the server onto the tokio runtime,
|
||||
/// and returns the bound socket address.
|
||||
async fn start_test_server(model: CameraModel) -> SocketAddr {
|
||||
let state = new_mock_state(model);
|
||||
let app = build_router(state);
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("Failed to bind ephemeral TCP listener");
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.expect("Failed to retrieve local socket address");
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
|
||||
addr
|
||||
}
|
||||
|
||||
/// Sends an HTTP/1.1 request directly over a raw TCP connection (`TcpStream`),
|
||||
/// eliminating external HTTP client crate requirements.
|
||||
///
|
||||
/// Handles chunked reading, `Content-Length` framing, and connection termination.
|
||||
/// Returns `(status_code, headers, body)`.
|
||||
async fn send_http_request(
|
||||
addr: SocketAddr,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<&str>,
|
||||
) -> (u16, HashMap<String, String>, String) {
|
||||
let mut stream = TcpStream::connect(addr)
|
||||
.await
|
||||
.expect("Failed to connect TCP stream to test server");
|
||||
|
||||
let mut req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n");
|
||||
if let Some(b) = body {
|
||||
req.push_str("Content-Type: application/json\r\n");
|
||||
req.push_str(&format!("Content-Length: {}\r\n", b.len()));
|
||||
}
|
||||
req.push_str("\r\n");
|
||||
if let Some(b) = body {
|
||||
req.push_str(b);
|
||||
}
|
||||
|
||||
stream
|
||||
.write_all(req.as_bytes())
|
||||
.await
|
||||
.expect("Failed to write HTTP request to TCP stream");
|
||||
stream.flush().await.expect("Failed to flush TCP stream");
|
||||
|
||||
let mut response_buf = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
loop {
|
||||
let n = stream
|
||||
.read(&mut chunk)
|
||||
.await
|
||||
.expect("Failed to read HTTP response chunk from TCP stream");
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
response_buf.extend_from_slice(&chunk[..n]);
|
||||
|
||||
if let Some(pos) = response_buf.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let header_str = String::from_utf8_lossy(&response_buf[..pos]);
|
||||
let mut content_len = None;
|
||||
for line in header_str.split("\r\n") {
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
if k.trim().eq_ignore_ascii_case("content-length") {
|
||||
if let Ok(cl) = v.trim().parse::<usize>() {
|
||||
content_len = Some(cl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(cl) = content_len {
|
||||
if response_buf.len() >= pos + 4 + cl {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let header_end = response_buf
|
||||
.windows(4)
|
||||
.position(|w| w == b"\r\n\r\n")
|
||||
.expect("Malformed HTTP response: missing header delimiter CRLF CRLF");
|
||||
let header_bytes = &response_buf[..header_end];
|
||||
let body_bytes = &response_buf[header_end + 4..];
|
||||
|
||||
let header_str = std::str::from_utf8(header_bytes).expect("Non-UTF8 HTTP headers");
|
||||
let mut lines = header_str.split("\r\n");
|
||||
let status_line = lines.next().expect("Missing HTTP status line");
|
||||
let status_code: u16 = status_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.expect("Missing status code in HTTP status line")
|
||||
.parse()
|
||||
.expect("Invalid integer status code in HTTP response");
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
for line in lines {
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let body_str = String::from_utf8_lossy(body_bytes).to_string();
|
||||
(status_code, headers, body_str)
|
||||
}
|
||||
|
||||
/// Helper for GET requests.
|
||||
async fn send_http_get(addr: SocketAddr, path: &str) -> (u16, HashMap<String, String>, String) {
|
||||
send_http_request(addr, "GET", path, None).await
|
||||
}
|
||||
|
||||
/// Helper for JSON POST requests.
|
||||
async fn send_http_json(
|
||||
addr: SocketAddr,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: &str,
|
||||
) -> (u16, HashMap<String, String>, String) {
|
||||
send_http_request(addr, method, path, Some(body)).await
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_structs_exports() {
|
||||
// Verify that all documented request structs deserialize properly
|
||||
let _ptz: PtzRequest = serde_json::from_str(
|
||||
r#"{"pan_arcsec": 36000, "tilt_arcsec": -18000, "zoom": 250}"#,
|
||||
)
|
||||
.expect("PtzRequest deserialization failed");
|
||||
|
||||
let _mode: ModeRequest = serde_json::from_str(
|
||||
r#"{"mode": "DeskView", "pitch": -450, "zoom": 150}"#,
|
||||
)
|
||||
.expect("ModeRequest deserialization failed");
|
||||
|
||||
let _preset: PresetRequest = serde_json::from_str(
|
||||
r#"{"action": "save", "index": 3}"#,
|
||||
)
|
||||
.expect("PresetRequest deserialization failed");
|
||||
|
||||
let _img: ImageRequest = serde_json::from_str(
|
||||
r#"{"brightness": 75, "contrast": 65, "wb_auto": false, "wb_kelvin": 5600, "hdr": true, "filter_mode": "Warm"}"#,
|
||||
)
|
||||
.expect("ImageRequest deserialization failed");
|
||||
|
||||
let _track: TrackingRequest = serde_json::from_str(
|
||||
r#"{"track_speed": "Fast", "composition_style": "WholeBody", "gesture_binding": ["Palm", "AutoFraming"]}"#,
|
||||
)
|
||||
.expect("TrackingRequest deserialization failed");
|
||||
|
||||
let _audio: AudioRequest = serde_json::from_str(
|
||||
r#"{"audio_mode": "VoiceSuppression"}"#,
|
||||
)
|
||||
.or_else(|_| serde_json::from_str(r#"{"mode": "VoiceSuppression"}"#))
|
||||
.expect("AudioRequest deserialization failed");
|
||||
|
||||
let _sys: SystemRequest = serde_json::from_str(
|
||||
r#"{"privacy_mode": true, "horizontal_correction": true}"#,
|
||||
)
|
||||
.expect("SystemRequest deserialization failed");
|
||||
}
|
||||
|
||||
/// 1. GET / returns 200 OK with text/html containing the embedded Web Remote Control UI.
|
||||
#[tokio::test]
|
||||
async fn test_web_remote_control_ui() {
|
||||
let addr = start_test_server(CameraModel::Link2).await;
|
||||
let (status, headers, body) = send_http_get(addr, "/").await;
|
||||
|
||||
assert_eq!(status, 200, "GET / did not return 200 OK");
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.expect("Missing Content-Type header on GET /");
|
||||
assert!(
|
||||
content_type.contains("text/html"),
|
||||
"Content-Type should be text/html, got '{content_type}'"
|
||||
);
|
||||
assert!(
|
||||
body.contains("<!DOCTYPE html>")
|
||||
|| body.contains("<!doctype html>")
|
||||
|| body.contains("<html")
|
||||
|| body.contains("Insta360"),
|
||||
"GET / body did not contain expected HTML UI content. Body preview: {}",
|
||||
&body[..body.len().min(300)]
|
||||
);
|
||||
}
|
||||
|
||||
/// 2. GET /api/state returns 200 OK and deserializes cleanly into CameraStateSnapshot
|
||||
/// with CameraModel::Link2 and zoom == 100.
|
||||
#[tokio::test]
|
||||
async fn test_get_api_state_initial() {
|
||||
let addr = start_test_server(CameraModel::Link2).await;
|
||||
let (status, headers, body) = send_http_get(addr, "/api/state").await;
|
||||
|
||||
assert_eq!(status, 200, "GET /api/state did not return 200 OK");
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.expect("Missing Content-Type header on GET /api/state");
|
||||
assert!(
|
||||
content_type.contains("application/json"),
|
||||
"Content-Type should be application/json, got '{content_type}'"
|
||||
);
|
||||
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||
.unwrap_or_else(|e| panic!("Failed to deserialize CameraStateSnapshot: {e}\nPayload: {body}"));
|
||||
|
||||
assert_eq!(snapshot.model, CameraModel::Link2);
|
||||
assert_eq!(snapshot.zoom, 100);
|
||||
}
|
||||
|
||||
/// 3. POST /api/ptz with {"pan_arcsec": 36000, "tilt_arcsec": -18000, "zoom": 250}
|
||||
/// updates pan_arcsec, tilt_arcsec, zoom, and real_zoom.
|
||||
#[tokio::test]
|
||||
async fn test_post_api_ptz() {
|
||||
let addr = start_test_server(CameraModel::Link2).await;
|
||||
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/ptz",
|
||||
r#"{"pan_arcsec": 36000, "tilt_arcsec": -18000, "zoom": 250}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "POST /api/ptz failed");
|
||||
|
||||
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||
.expect("Failed to deserialize CameraStateSnapshot after PTZ update");
|
||||
|
||||
assert_eq!(snapshot.pan_arcsec, 36000);
|
||||
assert_eq!(snapshot.tilt_arcsec, -18000);
|
||||
assert_eq!(snapshot.zoom, 250);
|
||||
assert_eq!(snapshot.real_zoom, 250);
|
||||
}
|
||||
|
||||
/// 4. POST /api/mode with {"mode": "DeskView", "pitch": -450, "zoom": 150}
|
||||
/// switches video_mode to VideoMode::DeskView and updates video_aux.pitch to -450.
|
||||
#[tokio::test]
|
||||
async fn test_post_api_mode() {
|
||||
let addr = start_test_server(CameraModel::Link2).await;
|
||||
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/mode",
|
||||
r#"{"mode": "DeskView", "pitch": -450, "zoom": 150}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "POST /api/mode failed");
|
||||
|
||||
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||
.expect("Failed to deserialize CameraStateSnapshot after mode update");
|
||||
|
||||
assert_eq!(snapshot.video_mode, VideoMode::DeskView);
|
||||
assert_eq!(snapshot.video_aux.pitch, -450);
|
||||
}
|
||||
|
||||
/// 5. POST /api/image with {"brightness": 75, "contrast": 65, "wb_auto": false, "wb_kelvin": 5600, "hdr": true, "filter_mode": "Warm"}
|
||||
/// updates all image parameters and sets ExtendFunction::Hdr to true.
|
||||
#[tokio::test]
|
||||
async fn test_post_api_image() {
|
||||
let addr = start_test_server(CameraModel::Link2).await;
|
||||
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/image",
|
||||
r#"{"brightness": 75, "contrast": 65, "wb_auto": false, "wb_kelvin": 5600, "hdr": true, "filter_mode": "Warm"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "POST /api/image failed");
|
||||
|
||||
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||
.expect("Failed to deserialize CameraStateSnapshot after image update");
|
||||
|
||||
assert_eq!(snapshot.brightness, 75);
|
||||
assert_eq!(snapshot.contrast, 65);
|
||||
assert_eq!(snapshot.wb_auto, false);
|
||||
assert_eq!(snapshot.wb_kelvin, 5600);
|
||||
assert_eq!(snapshot.filter_mode, FilterMode::Warm);
|
||||
|
||||
let hdr_enabled = snapshot
|
||||
.extend_functions
|
||||
.iter()
|
||||
.any(|(f, en)| *f == ExtendFunction::Hdr && *en);
|
||||
assert!(hdr_enabled, "ExtendFunction::Hdr was not set to true in snapshot.extend_functions");
|
||||
}
|
||||
|
||||
/// 6. POST /api/tracking with {"track_speed": "Fast", "composition_style": "WholeBody", "gesture_binding": ["Palm", "AutoFraming"]}
|
||||
/// updates tracking speed, composition style, and gesture bindings.
|
||||
#[tokio::test]
|
||||
async fn test_post_api_tracking() {
|
||||
let addr = start_test_server(CameraModel::Link2).await;
|
||||
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/tracking",
|
||||
r#"{"track_speed": "Fast", "composition_style": "WholeBody", "gesture_binding": ["Palm", "AutoFraming"]}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "POST /api/tracking failed");
|
||||
|
||||
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||
.expect("Failed to deserialize CameraStateSnapshot after tracking update");
|
||||
|
||||
assert_eq!(snapshot.track_speed, TrackSpeed::Fast);
|
||||
assert_eq!(snapshot.composition_style, CompositionStyle::WholeBody);
|
||||
|
||||
let palm_binding = snapshot
|
||||
.gesture_bindings
|
||||
.iter()
|
||||
.find(|(g, _)| *g == Gesture::Palm);
|
||||
assert_eq!(
|
||||
palm_binding.map(|(_, f)| *f),
|
||||
Some(GestureFunction::AutoFraming),
|
||||
"Expected Palm gesture binding to be AutoFraming"
|
||||
);
|
||||
}
|
||||
|
||||
/// 7. POST /api/preset with {"action": "save", "index": 3} sets bit 3 in preset_flags.slot_mask.
|
||||
#[tokio::test]
|
||||
async fn test_post_api_preset() {
|
||||
let addr = start_test_server(CameraModel::Link2).await;
|
||||
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/preset",
|
||||
r#"{"action": "save", "index": 3}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "POST /api/preset failed");
|
||||
|
||||
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||
.expect("Failed to deserialize CameraStateSnapshot after preset update");
|
||||
|
||||
assert_ne!(
|
||||
snapshot.preset_flags.slot_mask & (1 << 3),
|
||||
0,
|
||||
"Bit 3 in preset_flags.slot_mask should be set after saving preset slot 3"
|
||||
);
|
||||
}
|
||||
|
||||
/// 8. POST /api/audio and POST /api/system update audio_mode, privacy_mode, and horizontal_correction.
|
||||
#[tokio::test]
|
||||
async fn test_post_api_audio_and_system() {
|
||||
let addr = start_test_server(CameraModel::Link2).await;
|
||||
|
||||
// Audio update
|
||||
let mut audio_res = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/audio",
|
||||
r#"{"audio_mode": "VoiceSuppression"}"#,
|
||||
)
|
||||
.await;
|
||||
if audio_res.0 != 200 {
|
||||
audio_res = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/audio",
|
||||
r#"{"mode": "VoiceSuppression"}"#,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert_eq!(audio_res.0, 200, "POST /api/audio failed");
|
||||
|
||||
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||
assert_eq!(status, 200);
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||
.expect("Failed to deserialize CameraStateSnapshot after audio update");
|
||||
assert_eq!(snapshot.audio_mode, AudioCaptureMode::VoiceSuppression);
|
||||
|
||||
// System update
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/system",
|
||||
r#"{"privacy_mode": true, "horizontal_correction": true}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "POST /api/system failed");
|
||||
|
||||
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||
assert_eq!(status, 200);
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body)
|
||||
.expect("Failed to deserialize CameraStateSnapshot after system update");
|
||||
assert!(snapshot.privacy_mode, "privacy_mode should be true");
|
||||
assert!(
|
||||
snapshot.horizontal_correction,
|
||||
"horizontal_correction should be true"
|
||||
);
|
||||
}
|
||||
|
||||
/// Complete end-to-end integration flow testing all 8 steps sequentially on a single
|
||||
/// server instance over TCP sockets.
|
||||
#[tokio::test]
|
||||
async fn test_server_full_integration_flow() {
|
||||
let addr = start_test_server(CameraModel::Link2).await;
|
||||
|
||||
// 1. GET / returns 200 OK with text/html UI
|
||||
let (status, headers, body) = send_http_get(addr, "/").await;
|
||||
assert_eq!(status, 200);
|
||||
assert!(headers.get("content-type").unwrap().contains("text/html"));
|
||||
assert!(
|
||||
body.contains("<!DOCTYPE html>")
|
||||
|| body.contains("<!doctype html>")
|
||||
|| body.contains("<html")
|
||||
|| body.contains("Insta360")
|
||||
);
|
||||
|
||||
// 2. GET /api/state initial state
|
||||
let (status, _, body) = send_http_get(addr, "/api/state").await;
|
||||
assert_eq!(status, 200);
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(snapshot.model, CameraModel::Link2);
|
||||
assert_eq!(snapshot.zoom, 100);
|
||||
|
||||
// 3. POST /api/ptz
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/ptz",
|
||||
r#"{"pan_arcsec": 36000, "tilt_arcsec": -18000, "zoom": 250}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(snapshot.pan_arcsec, 36000);
|
||||
assert_eq!(snapshot.tilt_arcsec, -18000);
|
||||
assert_eq!(snapshot.zoom, 250);
|
||||
assert_eq!(snapshot.real_zoom, 250);
|
||||
|
||||
// 4. POST /api/mode
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/mode",
|
||||
r#"{"mode": "DeskView", "pitch": -450, "zoom": 150}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(snapshot.video_mode, VideoMode::DeskView);
|
||||
assert_eq!(snapshot.video_aux.pitch, -450);
|
||||
|
||||
// 5. POST /api/image
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/image",
|
||||
r#"{"brightness": 75, "contrast": 65, "wb_auto": false, "wb_kelvin": 5600, "hdr": true, "filter_mode": "Warm"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(snapshot.brightness, 75);
|
||||
assert_eq!(snapshot.contrast, 65);
|
||||
assert_eq!(snapshot.wb_auto, false);
|
||||
assert_eq!(snapshot.wb_kelvin, 5600);
|
||||
assert_eq!(snapshot.filter_mode, FilterMode::Warm);
|
||||
assert!(snapshot
|
||||
.extend_functions
|
||||
.iter()
|
||||
.any(|(f, en)| *f == ExtendFunction::Hdr && *en));
|
||||
|
||||
// 6. POST /api/tracking
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/tracking",
|
||||
r#"{"track_speed": "Fast", "composition_style": "WholeBody", "gesture_binding": ["Palm", "AutoFraming"]}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(snapshot.track_speed, TrackSpeed::Fast);
|
||||
assert_eq!(snapshot.composition_style, CompositionStyle::WholeBody);
|
||||
let palm_binding = snapshot
|
||||
.gesture_bindings
|
||||
.iter()
|
||||
.find(|(g, _)| *g == Gesture::Palm);
|
||||
assert_eq!(
|
||||
palm_binding.map(|(_, f)| *f),
|
||||
Some(GestureFunction::AutoFraming)
|
||||
);
|
||||
|
||||
// 7. POST /api/preset
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/preset",
|
||||
r#"{"action": "save", "index": 3}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||
assert_ne!(snapshot.preset_flags.slot_mask & (1 << 3), 0);
|
||||
|
||||
// 8. POST /api/audio and POST /api/system
|
||||
let mut audio_res = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/audio",
|
||||
r#"{"audio_mode": "VoiceSuppression"}"#,
|
||||
)
|
||||
.await;
|
||||
if audio_res.0 != 200 {
|
||||
audio_res = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/audio",
|
||||
r#"{"mode": "VoiceSuppression"}"#,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert_eq!(audio_res.0, 200);
|
||||
|
||||
let (status, _, _) = send_http_json(
|
||||
addr,
|
||||
"POST",
|
||||
"/api/system",
|
||||
r#"{"privacy_mode": true, "horizontal_correction": true}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
let (_, _, body) = send_http_get(addr, "/api/state").await;
|
||||
let snapshot: CameraStateSnapshot = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(snapshot.audio_mode, AudioCaptureMode::VoiceSuppression);
|
||||
assert!(snapshot.privacy_mode);
|
||||
assert!(snapshot.horizontal_correction);
|
||||
}
|
||||
Reference in new issue
Block a user