Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
fp-info-cache
hardware/.history/

# CMake build directories
host/*/build/
# Component library noise (import guides, non-KiCAD EDA tool files)
hardware/Components/**/ImportGuides.html
hardware/Components/**/ImportGuide.html
Expand Down
1 change: 1 addition & 0 deletions host/linkctl-daemon/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ add_executable(linkctl-daemon
main.c
camera.c
server.c
control.c
mdns.c
)

Expand Down
182 changes: 182 additions & 0 deletions host/linkctl-daemon/control.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#include "control.h"
#include "camera.h"

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>

/* Serializes camera access between control thread and WebSocket thread */
static pthread_mutex_t camera_mutex = PTHREAD_MUTEX_INITIALIZER;

#define LINE_BUF_SIZE 256

static int listen_fd = -1;
static int client_fd = -1;
static char line_buf[LINE_BUF_SIZE];
static int line_pos = 0;

static void set_nonblock(int fd) {
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
}

static void send_response(const char *resp) {
if (client_fd < 0) return;
write(client_fd, resp, strlen(resp));
}

static void dispatch_command(const char *line) {
int rc = 0;

if (strncmp(line, "pan_tilt ", 9) == 0) {
int pd, ps, td, ts;
if (sscanf(line + 9, "%d %d %d %d", &pd, &ps, &td, &ts) == 4) {
pthread_mutex_lock(&camera_mutex);
if (ps == 0 && ts == 0) {
rc = camera_stop();
} else {
rc = camera_pan_tilt((uint8_t)pd, (uint8_t)ps,
(uint8_t)td, (uint8_t)ts);
}
pthread_mutex_unlock(&camera_mutex);
} else {
send_response("error:bad pan_tilt args\n");
return;
}
} else if (strcmp(line, "stop") == 0) {
pthread_mutex_lock(&camera_mutex);
rc = camera_stop();
pthread_mutex_unlock(&camera_mutex);
} else if (strcmp(line, "center") == 0) {
pthread_mutex_lock(&camera_mutex);
rc = camera_center();
pthread_mutex_unlock(&camera_mutex);
} else if (strncmp(line, "zoom ", 5) == 0) {
int val;
if (sscanf(line + 5, "%d", &val) == 1) {
if (val < ZOOM_MIN || val > ZOOM_MAX) {
send_response("error:zoom out of range (100-400)\n");
return;
}
pthread_mutex_lock(&camera_mutex);
rc = camera_zoom((uint16_t)val);
pthread_mutex_unlock(&camera_mutex);
} else {
send_response("error:bad zoom value\n");
return;
}
} else if (strcmp(line, "status") == 0) {
pthread_mutex_lock(&camera_mutex);
camera_status_t st = camera_get_status();
pthread_mutex_unlock(&camera_mutex);
char resp[64];
snprintf(resp, sizeof(resp), "status:%d %u\n", st.connected, st.zoom);
send_response(resp);
return;
} else {
send_response("error:unknown command\n");
return;
}

send_response(rc == 0 ? "ok\n" : "error:camera not connected\n");
}
Comment thread
jfwoods marked this conversation as resolved.

int control_init(uint16_t port) {
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("[control] socket");
return -1;
}

int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(port),
.sin_addr.s_addr = htonl(INADDR_LOOPBACK),
};
Comment thread
jfwoods marked this conversation as resolved.

if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("[control] bind");
close(listen_fd);
listen_fd = -1;
return -1;
}

if (listen(listen_fd, 1) < 0) {
perror("[control] listen");
close(listen_fd);
listen_fd = -1;
return -1;
}

set_nonblock(listen_fd);

printf("[control] TCP control socket listening on port %u\n", port);
return 0;
}

void control_service(void) {
if (listen_fd < 0) return;

/* Accept new connection (non-blocking) */
if (client_fd < 0) {
client_fd = accept(listen_fd, NULL, NULL);
if (client_fd >= 0) {
set_nonblock(client_fd);
line_pos = 0;
}
}

if (client_fd < 0) return;

/* Read available data into line buffer */
char buf[128];
ssize_t n = read(client_fd, buf, sizeof(buf));

if (n == 0) {
/* Client disconnected */
close(client_fd);
client_fd = -1;
line_pos = 0;
return;
}

if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) return;
/* Read error */
close(client_fd);
client_fd = -1;
line_pos = 0;
return;
}

for (ssize_t i = 0; i < n; i++) {
if (buf[i] == '\n' || buf[i] == '\r') {
if (line_pos > 0) {
line_buf[line_pos] = '\0';
dispatch_command(line_buf);
line_pos = 0;
}
} else if (line_pos < LINE_BUF_SIZE - 1) {
line_buf[line_pos++] = buf[i];
}
}
}

void control_destroy(void) {
if (client_fd >= 0) {
close(client_fd);
client_fd = -1;
}
if (listen_fd >= 0) {
close(listen_fd);
listen_fd = -1;
}
}
18 changes: 18 additions & 0 deletions host/linkctl-daemon/control.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#ifndef CONTROL_H
#define CONTROL_H

#include <stdint.h>

// Default TCP control port (separate from WebSocket on 9000)
#define DEFAULT_CONTROL_PORT 9001

// Initialize the TCP control socket. Returns 0 on success.
int control_init(uint16_t port);

// Non-blocking: accept new connections, read commands, dispatch.
void control_service(void);

// Shut down the control socket.
void control_destroy(void);

#endif
27 changes: 25 additions & 2 deletions host/linkctl-daemon/main.c
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "camera.h"
#include "server.h"
#include "control.h"
#include "mdns.h"

#include <stdio.h>
Expand All @@ -8,11 +9,13 @@
#include <string.h>
#include <unistd.h>
#include <stdbool.h>
#include <stdatomic.h>
#include <time.h>
#include <pthread.h>

#define CAMERA_POLL_INTERVAL_SEC 5

static volatile bool running = true;
static atomic_bool running = true;
static bool use_mdns = true;
static uint16_t port = DEFAULT_PORT;
static bool test_mode = false;
Expand Down Expand Up @@ -103,6 +106,15 @@ static void poll_camera(void) {
}
}

static void *control_thread(void *arg) {
(void)arg;
while (running) {
control_service();
usleep(5000); // 5ms = 200Hz
}
return NULL;
Comment thread
jfwoods marked this conversation as resolved.
}

int main(int argc, char **argv) {
if (parse_args(argc, argv) != 0) {
return 1;
Expand Down Expand Up @@ -143,16 +155,27 @@ int main(int argc, char **argv) {
}
}

// Start TCP control socket for CLI (runs in its own thread)
if (control_init(DEFAULT_CONTROL_PORT) != 0) {
fprintf(stderr, "[main] Control socket failed (continuing without)\n");
}

// Control socket thread — decoupled from lws event loop
pthread_t ctrl_thread;
pthread_create(&ctrl_thread, NULL, control_thread, NULL);

Comment thread
jfwoods marked this conversation as resolved.
Comment on lines 155 to +166
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The daemon has an existing automated test suite for the WebSocket interface (test_daemon.py), but this PR adds a new TCP control protocol and thread. It would be good to add coverage that exercises the TCP control socket (connect, send each command, verify responses), and ideally a regression test that ensures WebSocket errors (unknown cmd / missing zoom value) do not produce an extra ack message.

Copilot uses AI. Check for mistakes.
printf("[main] linkctl-daemon running on port %u\n", port);

// Main event loop
// Main event loop (WebSocket + camera only)
while (running) {
server_service(50); // 50ms poll
poll_camera();
Comment thread
jfwoods marked this conversation as resolved.
}

printf("\n[main] Shutting down...\n");

pthread_join(ctrl_thread, NULL);
control_destroy();
if (use_mdns) mdns_unregister();
camera_stop();
server_destroy();
Expand Down
8 changes: 8 additions & 0 deletions host/linkctl-daemon/server.c
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ static void handle_command(struct lws *wsi, const char *json, size_t len) {
rc = camera_zoom((uint16_t)val->valueint);
} else {
send_json(wsi, "{\"type\":\"error\",\"message\":\"missing zoom value\"}");
cJSON_Delete(root);
return;
}
} else if (strcmp(cmd_str, "stop") == 0) {
rc = camera_stop();
Expand All @@ -237,12 +239,18 @@ static void handle_command(struct lws *wsi, const char *json, size_t len) {
free(resp_json);
}
cJSON_Delete(resp);
cJSON_Delete(root);
return;
} else {
send_json(wsi, "{\"type\":\"error\",\"message\":\"unknown command\"}");
cJSON_Delete(root);
return;
}

if (rc != 0) {
send_json(wsi, "{\"type\":\"error\",\"message\":\"camera not connected\"}");
} else {
send_json(wsi, "{\"type\":\"ack\"}");
}

cJSON_Delete(root);
Expand Down
8 changes: 8 additions & 0 deletions host/linkctl/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
cmake_minimum_required(VERSION 3.14)
project(linkctl C)

set(CMAKE_C_STANDARD 11)

add_executable(linkctl linkctl.c)

install(TARGETS linkctl DESTINATION bin)
71 changes: 71 additions & 0 deletions host/linkctl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# linkctl

Command-line client for controlling the Insta360 Link camera via the `linkctl-daemon`.

## Prerequisites

- `linkctl-daemon` must be running (see `host/linkctl-daemon/`)
- No external dependencies — pure POSIX C

## Build

```bash
cd host/linkctl
mkdir -p build && cd build
cmake ..
make
```

## Install (optional)

Install to `/usr/local/bin` so `linkctl` is available system-wide:

```bash
sudo cmake --install . --prefix /usr/local
```

Or run directly from the build directory: `./build/linkctl <command>`.

## Usage

```
linkctl <command> [args]
```

### Commands

| Command | Description |
|---------|-------------|
| `linkctl help` | Show usage information |
| `linkctl status` | Show camera connection state and current zoom |
| `linkctl center` | Center the gimbal and reset zoom to 1.0x |
| `linkctl zoom <1.0-4.0>` | Set zoom level (e.g., `linkctl zoom 2.5`) |
| `linkctl zoom` | Show the current zoom level |
| `linkctl stop` | Stop all gimbal movement |
| `linkctl jog` | Enter interactive pan/tilt mode |

### Jog Mode

`linkctl jog` enters an interactive mode for real-time camera control using keyboard input.

**Controls:**

| Key | Action |
|-----|--------|
| `W` | Tilt up |
| `S` | Tilt down |
| `A` | Pan left |
| `D` | Pan right |
| `+` / `=` | Increase speed |
| `-` | Decrease speed |
| Space | Stop movement |
| `C` | Center gimbal |
| `Q` / ESC | Exit jog mode |

Movement continues while a key is held (via terminal key repeat) and automatically stops ~400ms after release.

Default speeds: pan=15, tilt=10. Speed range: 3–30 (pan), 3–20 (tilt).

## How It Works

`linkctl` connects to the daemon's TCP control socket at `localhost:9001` using a simple line-based text protocol. For one-shot commands it connects, sends, prints the result, and exits. Jog mode maintains the connection and uses `poll()` on both stdin and the socket for responsive keyboard-driven camera control.
Loading
Loading