From 2572240686293e4e82a0b01c42e9e83e86fa0e8e Mon Sep 17 00:00:00 2001 From: Jack Woods Date: Sat, 4 Apr 2026 13:10:59 -0400 Subject: [PATCH 1/3] Making a cli client controller for macOS --- host/linkctl-daemon/server.c | 3 + host/linkctl/CMakeLists.txt | 41 +++ host/linkctl/README.md | 71 ++++ host/linkctl/linkctl.c | 650 +++++++++++++++++++++++++++++++++++ 4 files changed, 765 insertions(+) create mode 100644 host/linkctl/CMakeLists.txt create mode 100644 host/linkctl/README.md create mode 100644 host/linkctl/linkctl.c diff --git a/host/linkctl-daemon/server.c b/host/linkctl-daemon/server.c index 07be81f..bf8c89d 100644 --- a/host/linkctl-daemon/server.c +++ b/host/linkctl-daemon/server.c @@ -243,6 +243,9 @@ static void handle_command(struct lws *wsi, const char *json, size_t len) { if (rc != 0) { send_json(wsi, "{\"type\":\"error\",\"message\":\"camera not connected\"}"); + } else if (strcmp(cmd_str, "status") != 0) { + // Ack successful action commands (status already sends its own response) + send_json(wsi, "{\"type\":\"ack\"}"); } cJSON_Delete(root); diff --git a/host/linkctl/CMakeLists.txt b/host/linkctl/CMakeLists.txt new file mode 100644 index 0000000..92c39c8 --- /dev/null +++ b/host/linkctl/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.14) +project(linkctl C) + +set(CMAKE_C_STANDARD 11) + +# Prefer Homebrew paths on macOS +if(APPLE) + list(PREPEND CMAKE_PREFIX_PATH "/opt/homebrew") + set(ENV{PKG_CONFIG_PATH} "/opt/homebrew/lib/pkgconfig:$ENV{PKG_CONFIG_PATH}") +endif() + +# OpenSSL (needed by libwebsockets headers) +if(APPLE) + set(OPENSSL_ROOT_DIR "/opt/homebrew/opt/openssl@3") +endif() +find_package(OpenSSL REQUIRED) + +# libwebsockets and cjson via pkg-config +find_package(PkgConfig REQUIRED) +pkg_check_modules(LWS REQUIRED libwebsockets) +pkg_check_modules(CJSON REQUIRED libcjson) + +add_executable(linkctl linkctl.c) + +target_include_directories(linkctl PRIVATE + ${LWS_INCLUDE_DIRS} + ${CJSON_INCLUDE_DIRS} + ${OPENSSL_INCLUDE_DIR} +) + +target_link_directories(linkctl PRIVATE + ${LWS_LIBRARY_DIRS} + ${CJSON_LIBRARY_DIRS} +) + +target_link_libraries(linkctl + ${LWS_LIBRARIES} + ${CJSON_LIBRARIES} +) + +install(TARGETS linkctl DESTINATION bin) diff --git a/host/linkctl/README.md b/host/linkctl/README.md new file mode 100644 index 0000000..12654fc --- /dev/null +++ b/host/linkctl/README.md @@ -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/`) +- Homebrew dependencies: `brew install libwebsockets cjson openssl@3` + +## 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 `. + +## Usage + +``` +linkctl [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 ~150ms 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 WebSocket server at `localhost:9000`, sends JSON commands using the same protocol as the ESP32 controller, and displays the response. For one-shot commands it connects, sends, prints the result, and exits. Jog mode maintains the connection for continuous interactive control. diff --git a/host/linkctl/linkctl.c b/host/linkctl/linkctl.c new file mode 100644 index 0000000..81b309d --- /dev/null +++ b/host/linkctl/linkctl.c @@ -0,0 +1,650 @@ +/* + * linkctl — CLI client for linkctl-daemon + * + * Connects to the running daemon via WebSocket and sends camera commands. + * Usage: linkctl [args] + */ + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* --- Protocol constants (must match daemon) --- */ + +#define DEFAULT_HOST "127.0.0.1" +#define DEFAULT_PORT 9000 + +#define DIR_POSITIVE 1 +#define DIR_NEGATIVE 255 +#define DIR_STOP 0 + +#define PAN_MAX_SPEED 30 +#define TILT_MAX_SPEED 20 +#define ZOOM_MIN 100 +#define ZOOM_MAX 400 + +/* --- Jog mode constants --- */ + +#define JOG_DEFAULT_PAN_SPEED 15 +#define JOG_DEFAULT_TILT_SPEED 10 +#define JOG_SPEED_STEP 3 +#define JOG_MIN_SPEED 3 +#define JOG_STOP_TIMEOUT_US 150000 /* 150ms */ + +/* --- Command types --- */ + +typedef enum { + CMD_HELP, + CMD_STATUS, + CMD_CENTER, + CMD_ZOOM_SET, + CMD_ZOOM_QUERY, + CMD_STOP, + CMD_JOG, +} command_t; + +/* --- Client state --- */ + +static struct lws_context *ctx = NULL; +static struct lws *client_wsi = NULL; +static bool done = false; +static int exit_code = 0; + +/* Parsed command */ +static command_t command; +static float zoom_value = 0.0f; + +/* Pending messages to send (up to 2 for compound center = center + zoom) */ +#define MAX_PENDING 2 +static char pending[MAX_PENDING][512]; +static int pending_count = 0; +static int pending_sent = 0; + +/* Timeout for action commands (ack may not arrive from older daemons) */ +#define ACTION_TIMEOUT_SEC 3 +static char alarm_msg[128]; +static size_t alarm_msg_len = 0; + +/* Jog mode state */ +static bool jog_active = false; + +/* Terminal state for jog mode */ +static struct termios orig_termios; +static bool termios_saved = false; + +/* --- Terminal helpers --- */ + +static void restore_terminal(void) { + if (termios_saved) { + tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios); + } +} + +/* Immediate exit — flushes output and restores terminal */ +static void finish(int code) { + restore_terminal(); + fflush(NULL); + _exit(code); +} + +static void enter_raw_mode(void) { + if (!isatty(STDIN_FILENO)) return; + + tcgetattr(STDIN_FILENO, &orig_termios); + termios_saved = true; + + struct termios raw = orig_termios; + raw.c_lflag &= ~(ICANON | ECHO); + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 0; + tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); +} + +static void handle_signal(int sig) { + (void)sig; + restore_terminal(); + done = true; +} + +/* SIGALRM handler for action command timeout. + Prints pre-formatted alarm_msg and exits. */ +static void handle_alarm(int sig) { + (void)sig; + if (alarm_msg_len > 0) { + write(STDOUT_FILENO, alarm_msg, alarm_msg_len); + } + _exit(0); +} + +/* --- JSON command builders --- */ + +static void queue_cmd(const char *json) { + if (pending_count < MAX_PENDING) { + snprintf(pending[pending_count], sizeof(pending[0]), "%s", json); + pending_count++; + } +} + +static void queue_pan_tilt(uint8_t pan_dir, uint8_t pan_speed, + uint8_t tilt_dir, uint8_t tilt_speed) { + char buf[256]; + snprintf(buf, sizeof(buf), + "{\"cmd\":\"pan_tilt\",\"pan_dir\":%u,\"pan_speed\":%u," + "\"tilt_dir\":%u,\"tilt_speed\":%u}", + pan_dir, pan_speed, tilt_dir, tilt_speed); + queue_cmd(buf); +} + +static void queue_zoom(uint16_t value) { + char buf[128]; + snprintf(buf, sizeof(buf), "{\"cmd\":\"zoom\",\"value\":%u}", value); + queue_cmd(buf); +} + +static void queue_stop(void) { + queue_cmd("{\"cmd\":\"stop\"}"); +} + +static void queue_center(void) { + queue_cmd("{\"cmd\":\"center\"}"); +} + +static void queue_status(void) { + queue_cmd("{\"cmd\":\"status\"}"); +} + +/* --- Send pending command over WebSocket --- */ + +static void send_next(void) { + if (!client_wsi || pending_sent >= pending_count) return; + + const char *json = pending[pending_sent]; + size_t len = strlen(json); + unsigned char *buf = malloc(LWS_PRE + len); + if (!buf) return; + + memcpy(buf + LWS_PRE, json, len); + lws_write(client_wsi, buf + LWS_PRE, len, LWS_WRITE_TEXT); + free(buf); + pending_sent++; + + /* Start alarm timeout when first command is sent. + If the daemon doesn't send an ack (older versions), SIGALRM fires. */ + if (pending_sent == 1 && command != CMD_STATUS && command != CMD_ZOOM_QUERY) { + signal(SIGALRM, handle_alarm); + alarm(ACTION_TIMEOUT_SEC); + } +} + +/* --- Response handling --- */ + +static void handle_response(const char *json, size_t len) { + cJSON *root = cJSON_ParseWithLength(json, len); + if (!root) return; + + cJSON *type = cJSON_GetObjectItem(root, "type"); + if (!cJSON_IsString(type)) { + cJSON_Delete(root); + return; + } + + const char *type_str = type->valuestring; + + if (strcmp(type_str, "ack") == 0) { + /* Command succeeded. If more commands pending (compound center), send next. */ + if (pending_sent < pending_count) { + send_next(); + lws_callback_on_writable(client_wsi); + cJSON_Delete(root); + return; + } + /* All commands done */ + if (command == CMD_CENTER) { + printf("Centered, zoom reset to 1.0x\n"); + } else if (command == CMD_ZOOM_SET) { + printf("Zoom set to %.1fx\n", zoom_value); + } else if (command == CMD_STOP) { + printf("Stopped\n"); + } + cJSON_Delete(root); + finish(0); + + } else if (strcmp(type_str, "status") == 0) { + cJSON *connected = cJSON_GetObjectItem(root, "camera_connected"); + cJSON *zoom = cJSON_GetObjectItem(root, "zoom"); + + bool cam = cJSON_IsTrue(connected); + int z = cJSON_IsNumber(zoom) ? (int)zoom->valuedouble : 0; + + if (command == CMD_STATUS) { + printf("Camera: %s\n", cam ? "connected" : "not connected"); + if (cam && z > 0) { + printf("Zoom: %.1fx (%d)\n", z / 100.0, z); + } + cJSON_Delete(root); + finish(0); + } else if (command == CMD_ZOOM_QUERY) { + if (cam && z > 0) { + printf("Zoom: %.1fx (%d)\n", z / 100.0, z); + } else { + printf("Camera not connected\n"); + } + cJSON_Delete(root); + finish(cam ? 0 : 1); + } + /* For other commands, the initial status on connect is ignored */ + + } else if (strcmp(type_str, "error") == 0) { + cJSON *msg = cJSON_GetObjectItem(root, "message"); + fprintf(stderr, "Error: %s\n", + cJSON_IsString(msg) ? msg->valuestring : "unknown"); + cJSON_Delete(root); + finish(1); + } + + cJSON_Delete(root); +} + +/* --- Jog mode --- */ + +static void run_jog_mode(void) { + if (!isatty(STDIN_FILENO)) { + fprintf(stderr, "Jog mode requires a terminal\n"); + exit_code = 1; + done = true; + return; + } + + printf("\n"); + printf("=== Jog Mode ===\n"); + printf(" WASD Pan/tilt +/- Speed\n"); + printf(" Space Stop c Center\n"); + printf(" q/ESC Exit\n"); + printf("\n"); + + uint8_t pan_speed = JOG_DEFAULT_PAN_SPEED; + uint8_t tilt_speed = JOG_DEFAULT_TILT_SPEED; + printf("Speed: pan=%u tilt=%u\n", pan_speed, tilt_speed); + + enter_raw_mode(); + jog_active = true; + + bool moving = false; + struct timeval last_input; + gettimeofday(&last_input, NULL); + + while (!done) { + char c; + ssize_t n = read(STDIN_FILENO, &c, 1); + + if (n == 1) { + gettimeofday(&last_input, NULL); + c = tolower((unsigned char)c); + + /* Reset pending for each jog key */ + pending_count = 0; + pending_sent = 0; + + switch (c) { + case 'w': + queue_pan_tilt(DIR_STOP, 0, DIR_POSITIVE, tilt_speed); + moving = true; + break; + case 's': + queue_pan_tilt(DIR_STOP, 0, DIR_NEGATIVE, tilt_speed); + moving = true; + break; + case 'a': + queue_pan_tilt(DIR_NEGATIVE, pan_speed, DIR_STOP, 0); + moving = true; + break; + case 'd': + queue_pan_tilt(DIR_POSITIVE, pan_speed, DIR_STOP, 0); + moving = true; + break; + case '+': case '=': + if (pan_speed + JOG_SPEED_STEP <= PAN_MAX_SPEED) + pan_speed += JOG_SPEED_STEP; + else + pan_speed = PAN_MAX_SPEED; + if (tilt_speed + JOG_SPEED_STEP <= TILT_MAX_SPEED) + tilt_speed += JOG_SPEED_STEP; + else + tilt_speed = TILT_MAX_SPEED; + printf("\rSpeed: pan=%u tilt=%u \r", pan_speed, tilt_speed); + fflush(stdout); + break; + case '-': + if (pan_speed > JOG_MIN_SPEED + JOG_SPEED_STEP) + pan_speed -= JOG_SPEED_STEP; + else + pan_speed = JOG_MIN_SPEED; + if (tilt_speed > JOG_MIN_SPEED + JOG_SPEED_STEP) + tilt_speed -= JOG_SPEED_STEP; + else + tilt_speed = JOG_MIN_SPEED; + printf("\rSpeed: pan=%u tilt=%u \r", pan_speed, tilt_speed); + fflush(stdout); + break; + case ' ': + queue_stop(); + moving = false; + break; + case 'c': + queue_center(); + moving = false; + break; + case 'q': + case 0x1B: /* ESC */ + case 0x03: /* Ctrl-C */ + pending_count = 0; + pending_sent = 0; + queue_stop(); + send_next(); + done = true; + break; + default: + break; + } + + /* Send queued jog command */ + if (pending_count > pending_sent) { + send_next(); + lws_callback_on_writable(client_wsi); + } + } + + /* Auto-stop after timeout with no input */ + if (moving) { + struct timeval now; + gettimeofday(&now, NULL); + long elapsed_us = (now.tv_sec - last_input.tv_sec) * 1000000L + + (now.tv_usec - last_input.tv_usec); + if (elapsed_us > JOG_STOP_TIMEOUT_US) { + pending_count = 0; + pending_sent = 0; + queue_stop(); + send_next(); + lws_callback_on_writable(client_wsi); + moving = false; + } + } + + /* Service WebSocket (keep connection alive, process acks) */ + lws_service(ctx, 10); + usleep(1000); + } + + restore_terminal(); + jog_active = false; + printf("\nExited jog mode.\n"); +} + +/* --- WebSocket client callback --- */ + +static int ws_callback(struct lws *wsi, enum lws_callback_reasons reason, + void *user, void *in, size_t len) { + (void)user; + + switch (reason) { + case LWS_CALLBACK_CLIENT_ESTABLISHED: + client_wsi = wsi; + if (command == CMD_JOG) { + /* Jog mode: don't send a command yet, enter jog loop */ + break; + } + /* Queue command(s) and request write */ + lws_callback_on_writable(wsi); + break; + + case LWS_CALLBACK_CLIENT_WRITEABLE: + if (pending_sent < pending_count) { + send_next(); + /* If more to send, request another writeable */ + if (pending_sent < pending_count) { + lws_callback_on_writable(wsi); + } + } + break; + + case LWS_CALLBACK_CLIENT_RECEIVE: + if (in && len > 0) { + if (jog_active) { + /* In jog mode, ignore responses (acks, status updates) */ + break; + } + + /* For status/zoom_query: ignore the initial status sent on connect, + only handle the response to our explicit command */ + static bool initial_status_received = false; + if (!initial_status_received) { + initial_status_received = true; + /* Check if this is the initial status (arrives before we send anything) */ + if (pending_sent == 0) { + /* Haven't sent our command yet — this is the connect status, ignore */ + break; + } + } + + handle_response((const char *)in, len); + } + break; + + case LWS_CALLBACK_CLIENT_CONNECTION_ERROR: + fprintf(stderr, "Error: daemon not active (is linkctl-daemon running?)\n"); + if (command != CMD_JOG) finish(1); + exit_code = 1; + done = true; + break; + + case LWS_CALLBACK_CLIENT_CLOSED: + client_wsi = NULL; + if (!done) { + fprintf(stderr, "Error: connection lost\n"); + if (command != CMD_JOG) finish(1); + exit_code = 1; + done = true; + } + break; + + default: + break; + } + + return 0; +} + +static struct lws_protocols protocols[] = { + { + .name = "arduino", + .callback = ws_callback, + .per_session_data_size = 0, + .rx_buffer_size = 4096, + }, + { NULL, NULL, 0, 0 } +}; + +/* --- Usage --- */ + +static void print_usage(void) { + printf("Usage: linkctl [args]\n"); + printf("\n"); + printf("Commands:\n"); + printf(" help Show this help\n"); + printf(" status Show camera connection and zoom level\n"); + printf(" center Center gimbal and reset zoom to 1.0x\n"); + printf(" zoom <1.0-4.0> Set zoom level\n"); + printf(" zoom Show current zoom level\n"); + printf(" stop Stop all gimbal movement\n"); + printf(" jog Interactive WASD pan/tilt mode\n"); + printf("\n"); + printf("The linkctl-daemon must be running for commands to work.\n"); +} + +/* --- Argument parsing --- */ + +static int parse_args(int argc, char **argv) { + if (argc < 2) { + print_usage(); + return -1; + } + + const char *cmd = argv[1]; + + if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0 || + strcmp(cmd, "-h") == 0) { + command = CMD_HELP; + return 0; + } + + if (strcmp(cmd, "status") == 0) { + command = CMD_STATUS; + } else if (strcmp(cmd, "center") == 0) { + command = CMD_CENTER; + } else if (strcmp(cmd, "zoom") == 0) { + if (argc >= 3) { + command = CMD_ZOOM_SET; + char *end; + zoom_value = strtof(argv[2], &end); + if (*end != '\0' || zoom_value < 1.0f || zoom_value > 4.0f) { + fprintf(stderr, "Error: zoom must be a number between 1.0 and 4.0\n"); + return -1; + } + } else { + command = CMD_ZOOM_QUERY; + } + } else if (strcmp(cmd, "stop") == 0) { + command = CMD_STOP; + } else if (strcmp(cmd, "jog") == 0) { + command = CMD_JOG; + } else { + fprintf(stderr, "Unknown command: %s\n", cmd); + fprintf(stderr, "Run 'linkctl help' for usage.\n"); + return -1; + } + + return 0; +} + +/* --- Main --- */ + +int main(int argc, char **argv) { + if (parse_args(argc, argv) != 0) { + return 1; + } + + if (command == CMD_HELP) { + print_usage(); + return 0; + } + + /* Install signal handlers */ + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + + /* Queue commands based on parsed command */ + switch (command) { + case CMD_STATUS: + case CMD_ZOOM_QUERY: + queue_status(); + break; + case CMD_CENTER: + queue_center(); + queue_zoom(ZOOM_MIN); /* Second command: reset zoom to 1x */ + break; + case CMD_ZOOM_SET: { + uint16_t raw = (uint16_t)(zoom_value * 100.0f + 0.5f); + if (raw < ZOOM_MIN) raw = ZOOM_MIN; + if (raw > ZOOM_MAX) raw = ZOOM_MAX; + queue_zoom(raw); + break; + } + case CMD_STOP: + queue_stop(); + break; + case CMD_JOG: + /* Commands sent interactively */ + break; + default: + break; + } + + /* Pre-format the alarm timeout message (signal handler can't use printf) */ + switch (command) { + case CMD_CENTER: + alarm_msg_len = snprintf(alarm_msg, sizeof(alarm_msg), + "Centered, zoom reset to 1.0x\n"); + break; + case CMD_ZOOM_SET: + alarm_msg_len = snprintf(alarm_msg, sizeof(alarm_msg), + "Zoom set to %.1fx\n", zoom_value); + break; + case CMD_STOP: + alarm_msg_len = snprintf(alarm_msg, sizeof(alarm_msg), "Stopped\n"); + break; + default: + break; + } + + /* Suppress libwebsockets logs */ + lws_set_log_level(0, NULL); + + /* Create WebSocket client context */ + struct lws_context_creation_info info; + memset(&info, 0, sizeof(info)); + info.port = CONTEXT_PORT_NO_LISTEN; /* Client only, no server */ + info.protocols = protocols; + info.gid = -1; + info.uid = -1; + + ctx = lws_create_context(&info); + if (!ctx) { + fprintf(stderr, "Error: failed to create WebSocket context\n"); + return 1; + } + + /* Initiate connection to daemon */ + struct lws_client_connect_info ccinfo; + memset(&ccinfo, 0, sizeof(ccinfo)); + ccinfo.context = ctx; + ccinfo.address = DEFAULT_HOST; + ccinfo.port = DEFAULT_PORT; + ccinfo.path = "/"; + ccinfo.host = DEFAULT_HOST; + ccinfo.origin = DEFAULT_HOST; + ccinfo.protocol = "arduino"; + + if (!lws_client_connect_via_info(&ccinfo)) { + fprintf(stderr, "Error: failed to initiate connection\n"); + lws_context_destroy(ctx); + return 1; + } + + /* Event loop */ + if (command == CMD_JOG) { + /* Wait for connection, then enter jog mode */ + while (!done && !client_wsi) { + lws_service(ctx, 50); + } + if (!done) { + run_jog_mode(); + } + } else { + /* One-shot: run until response received. + SIGALRM provides the timeout for action commands without ack. */ + while (!done) { + lws_service(ctx, 50); + } + } + + finish(exit_code); +} From a3a2fa7c8e5075ca37bc449704289e4f1b52221b Mon Sep 17 00:00:00 2001 From: Jack Woods Date: Sat, 4 Apr 2026 14:54:54 -0400 Subject: [PATCH 2/3] CLI Implementation!!! And some daemon changes to add new access interface. --- .gitignore | 3 + host/linkctl-daemon/CMakeLists.txt | 1 + host/linkctl-daemon/control.c | 164 +++++++ host/linkctl-daemon/control.h | 18 + host/linkctl-daemon/main.c | 24 +- host/linkctl/CMakeLists.txt | 33 -- host/linkctl/README.md | 4 +- host/linkctl/linkctl.c | 746 ++++++++++------------------- 8 files changed, 454 insertions(+), 539 deletions(-) create mode 100644 host/linkctl-daemon/control.c create mode 100644 host/linkctl-daemon/control.h diff --git a/.gitignore b/.gitignore index 4b1f411..858617d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,8 @@ fp-info-cache hardware/.history/ +# CMake build directories +host/*/build/ + # Worktrees .worktrees/ diff --git a/host/linkctl-daemon/CMakeLists.txt b/host/linkctl-daemon/CMakeLists.txt index 45c2a30..1aac07a 100644 --- a/host/linkctl-daemon/CMakeLists.txt +++ b/host/linkctl-daemon/CMakeLists.txt @@ -24,6 +24,7 @@ add_executable(linkctl-daemon main.c camera.c server.c + control.c mdns.c ) diff --git a/host/linkctl-daemon/control.c b/host/linkctl-daemon/control.c new file mode 100644 index 0000000..f56c85b --- /dev/null +++ b/host/linkctl-daemon/control.c @@ -0,0 +1,164 @@ +#include "control.h" +#include "camera.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#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) { + 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); + } + } else { + send_response("error:bad pan_tilt args\n"); + return; + } + } else if (strcmp(line, "stop") == 0) { + rc = camera_stop(); + } else if (strcmp(line, "center") == 0) { + rc = camera_center(); + } else if (strncmp(line, "zoom ", 5) == 0) { + int val; + if (sscanf(line + 5, "%d", &val) == 1) { + rc = camera_zoom((uint16_t)val); + } else { + send_response("error:bad zoom value\n"); + return; + } + } else if (strcmp(line, "status") == 0) { + camera_status_t st = camera_get_status(); + 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"); +} + +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_ANY), + }; + + 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; + } +} diff --git a/host/linkctl-daemon/control.h b/host/linkctl-daemon/control.h new file mode 100644 index 0000000..43d2218 --- /dev/null +++ b/host/linkctl-daemon/control.h @@ -0,0 +1,18 @@ +#ifndef CONTROL_H +#define CONTROL_H + +#include + +// 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 diff --git a/host/linkctl-daemon/main.c b/host/linkctl-daemon/main.c index b26afe0..3a81a56 100644 --- a/host/linkctl-daemon/main.c +++ b/host/linkctl-daemon/main.c @@ -1,5 +1,6 @@ #include "camera.h" #include "server.h" +#include "control.h" #include "mdns.h" #include @@ -9,6 +10,7 @@ #include #include #include +#include #define CAMERA_POLL_INTERVAL_SEC 5 @@ -103,6 +105,15 @@ static void poll_camera(void) { } } +static void *control_thread(void *arg) { + (void)arg; + while (running) { + control_service(); + usleep(5000); // 5ms = 200Hz + } + return NULL; +} + int main(int argc, char **argv) { if (parse_args(argc, argv) != 0) { return 1; @@ -143,9 +154,18 @@ 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); + 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(); @@ -153,6 +173,8 @@ int main(int argc, char **argv) { printf("\n[main] Shutting down...\n"); + pthread_join(ctrl_thread, NULL); + control_destroy(); if (use_mdns) mdns_unregister(); camera_stop(); server_destroy(); diff --git a/host/linkctl/CMakeLists.txt b/host/linkctl/CMakeLists.txt index 92c39c8..8c6077b 100644 --- a/host/linkctl/CMakeLists.txt +++ b/host/linkctl/CMakeLists.txt @@ -3,39 +3,6 @@ project(linkctl C) set(CMAKE_C_STANDARD 11) -# Prefer Homebrew paths on macOS -if(APPLE) - list(PREPEND CMAKE_PREFIX_PATH "/opt/homebrew") - set(ENV{PKG_CONFIG_PATH} "/opt/homebrew/lib/pkgconfig:$ENV{PKG_CONFIG_PATH}") -endif() - -# OpenSSL (needed by libwebsockets headers) -if(APPLE) - set(OPENSSL_ROOT_DIR "/opt/homebrew/opt/openssl@3") -endif() -find_package(OpenSSL REQUIRED) - -# libwebsockets and cjson via pkg-config -find_package(PkgConfig REQUIRED) -pkg_check_modules(LWS REQUIRED libwebsockets) -pkg_check_modules(CJSON REQUIRED libcjson) - add_executable(linkctl linkctl.c) -target_include_directories(linkctl PRIVATE - ${LWS_INCLUDE_DIRS} - ${CJSON_INCLUDE_DIRS} - ${OPENSSL_INCLUDE_DIR} -) - -target_link_directories(linkctl PRIVATE - ${LWS_LIBRARY_DIRS} - ${CJSON_LIBRARY_DIRS} -) - -target_link_libraries(linkctl - ${LWS_LIBRARIES} - ${CJSON_LIBRARIES} -) - install(TARGETS linkctl DESTINATION bin) diff --git a/host/linkctl/README.md b/host/linkctl/README.md index 12654fc..fd02b63 100644 --- a/host/linkctl/README.md +++ b/host/linkctl/README.md @@ -5,7 +5,7 @@ Command-line client for controlling the Insta360 Link camera via the `linkctl-da ## Prerequisites - `linkctl-daemon` must be running (see `host/linkctl-daemon/`) -- Homebrew dependencies: `brew install libwebsockets cjson openssl@3` +- No external dependencies — pure POSIX C ## Build @@ -68,4 +68,4 @@ Default speeds: pan=15, tilt=10. Speed range: 3–30 (pan), 3–20 (tilt). ## How It Works -`linkctl` connects to the daemon's WebSocket server at `localhost:9000`, sends JSON commands using the same protocol as the ESP32 controller, and displays the response. For one-shot commands it connects, sends, prints the result, and exits. Jog mode maintains the connection for continuous interactive control. +`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. diff --git a/host/linkctl/linkctl.c b/host/linkctl/linkctl.c index 81b309d..689a242 100644 --- a/host/linkctl/linkctl.c +++ b/host/linkctl/linkctl.c @@ -1,27 +1,32 @@ /* * linkctl — CLI client for linkctl-daemon * - * Connects to the running daemon via WebSocket and sends camera commands. + * Connects to the daemon's TCP control socket and sends camera commands. * Usage: linkctl [args] + * + * Zero external dependencies — pure POSIX. */ -#include -#include - #include #include #include #include #include +#include +#include #include #include #include +#include +#include #include +#include +#include -/* --- Protocol constants (must match daemon) --- */ +/* --- Constants --- */ #define DEFAULT_HOST "127.0.0.1" -#define DEFAULT_PORT 9000 +#define DEFAULT_PORT 9001 #define DIR_POSITIVE 1 #define DIR_NEGATIVE 255 @@ -32,76 +37,35 @@ #define ZOOM_MIN 100 #define ZOOM_MAX 400 -/* --- Jog mode constants --- */ - #define JOG_DEFAULT_PAN_SPEED 15 #define JOG_DEFAULT_TILT_SPEED 10 #define JOG_SPEED_STEP 3 #define JOG_MIN_SPEED 3 -#define JOG_STOP_TIMEOUT_US 150000 /* 150ms */ - -/* --- Command types --- */ - -typedef enum { - CMD_HELP, - CMD_STATUS, - CMD_CENTER, - CMD_ZOOM_SET, - CMD_ZOOM_QUERY, - CMD_STOP, - CMD_JOG, -} command_t; - -/* --- Client state --- */ - -static struct lws_context *ctx = NULL; -static struct lws *client_wsi = NULL; -static bool done = false; -static int exit_code = 0; - -/* Parsed command */ -static command_t command; -static float zoom_value = 0.0f; - -/* Pending messages to send (up to 2 for compound center = center + zoom) */ -#define MAX_PENDING 2 -static char pending[MAX_PENDING][512]; -static int pending_count = 0; -static int pending_sent = 0; - -/* Timeout for action commands (ack may not arrive from older daemons) */ -#define ACTION_TIMEOUT_SEC 3 -static char alarm_msg[128]; -static size_t alarm_msg_len = 0; - -/* Jog mode state */ -static bool jog_active = false; - -/* Terminal state for jog mode */ +#define JOG_STOP_TIMEOUT_US 400000 /* 400ms — matches typical macOS key repeat delay */ + +/* --- State --- */ + +static volatile bool done = false; static struct termios orig_termios; static bool termios_saved = false; +static int orig_stdin_flags = -1; /* --- Terminal helpers --- */ static void restore_terminal(void) { - if (termios_saved) { + if (termios_saved) tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios); - } -} - -/* Immediate exit — flushes output and restores terminal */ -static void finish(int code) { - restore_terminal(); - fflush(NULL); - _exit(code); + if (orig_stdin_flags >= 0) + fcntl(STDIN_FILENO, F_SETFL, orig_stdin_flags); } static void enter_raw_mode(void) { - if (!isatty(STDIN_FILENO)) return; - tcgetattr(STDIN_FILENO, &orig_termios); termios_saved = true; + orig_stdin_flags = fcntl(STDIN_FILENO, F_GETFL); + fcntl(STDIN_FILENO, F_SETFL, orig_stdin_flags | O_NONBLOCK); + struct termios raw = orig_termios; raw.c_lflag &= ~(ICANON | ECHO); raw.c_cc[VMIN] = 0; @@ -111,540 +75,316 @@ static void enter_raw_mode(void) { static void handle_signal(int sig) { (void)sig; - restore_terminal(); done = true; } -/* SIGALRM handler for action command timeout. - Prints pre-formatted alarm_msg and exits. */ -static void handle_alarm(int sig) { - (void)sig; - if (alarm_msg_len > 0) { - write(STDOUT_FILENO, alarm_msg, alarm_msg_len); - } - _exit(0); -} +/* --- TCP helpers --- */ -/* --- JSON command builders --- */ +static int tcp_connect(const char *host, uint16_t port) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; -static void queue_cmd(const char *json) { - if (pending_count < MAX_PENDING) { - snprintf(pending[pending_count], sizeof(pending[0]), "%s", json); - pending_count++; - } -} + struct sockaddr_in addr = { + .sin_family = AF_INET, + .sin_port = htons(port), + }; + inet_pton(AF_INET, host, &addr.sin_addr); -static void queue_pan_tilt(uint8_t pan_dir, uint8_t pan_speed, - uint8_t tilt_dir, uint8_t tilt_speed) { - char buf[256]; - snprintf(buf, sizeof(buf), - "{\"cmd\":\"pan_tilt\",\"pan_dir\":%u,\"pan_speed\":%u," - "\"tilt_dir\":%u,\"tilt_speed\":%u}", - pan_dir, pan_speed, tilt_dir, tilt_speed); - queue_cmd(buf); + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + close(fd); + return -1; + } + return fd; } -static void queue_zoom(uint16_t value) { - char buf[128]; - snprintf(buf, sizeof(buf), "{\"cmd\":\"zoom\",\"value\":%u}", value); - queue_cmd(buf); -} +/* Send a command and read the response line. Returns response in buf. */ +static int send_cmd(int sock, const char *cmd, char *resp, size_t resp_size) { + if (write(sock, cmd, strlen(cmd)) < 0) return -1; -static void queue_stop(void) { - queue_cmd("{\"cmd\":\"stop\"}"); -} + ssize_t n = read(sock, resp, resp_size - 1); + if (n <= 0) return -1; + resp[n] = '\0'; -static void queue_center(void) { - queue_cmd("{\"cmd\":\"center\"}"); -} + /* Strip trailing newline */ + while (n > 0 && (resp[n-1] == '\n' || resp[n-1] == '\r')) + resp[--n] = '\0'; -static void queue_status(void) { - queue_cmd("{\"cmd\":\"status\"}"); + return 0; } -/* --- Send pending command over WebSocket --- */ +/* --- Usage --- */ -static void send_next(void) { - if (!client_wsi || pending_sent >= pending_count) return; +static void print_usage(void) { + printf("Usage: linkctl [args]\n\n"); + printf("Commands:\n"); + printf(" help Show this help\n"); + printf(" status Show camera connection and zoom level\n"); + printf(" center Center gimbal and reset zoom to 1.0x\n"); + printf(" zoom <1.0-4.0> Set zoom level\n"); + printf(" zoom Show current zoom level\n"); + printf(" stop Stop all gimbal movement\n"); + printf(" jog Interactive WASD pan/tilt mode\n"); + printf("\nThe linkctl-daemon must be running for commands to work.\n"); +} - const char *json = pending[pending_sent]; - size_t len = strlen(json); - unsigned char *buf = malloc(LWS_PRE + len); - if (!buf) return; +/* --- One-shot commands --- */ - memcpy(buf + LWS_PRE, json, len); - lws_write(client_wsi, buf + LWS_PRE, len, LWS_WRITE_TEXT); - free(buf); - pending_sent++; +static int cmd_status(int sock) { + char resp[128]; + if (send_cmd(sock, "status\n", resp, sizeof(resp)) < 0) return 1; - /* Start alarm timeout when first command is sent. - If the daemon doesn't send an ack (older versions), SIGALRM fires. */ - if (pending_sent == 1 && command != CMD_STATUS && command != CMD_ZOOM_QUERY) { - signal(SIGALRM, handle_alarm); - alarm(ACTION_TIMEOUT_SEC); + int connected; + unsigned zoom; + if (sscanf(resp, "status:%d %u", &connected, &zoom) == 2) { + printf("Camera: %s\n", connected ? "connected" : "not connected"); + if (connected && zoom > 0) + printf("Zoom: %.1fx (%u)\n", zoom / 100.0, zoom); + } else { + fprintf(stderr, "%s\n", resp); + return 1; } + return 0; } -/* --- Response handling --- */ +static int cmd_center(int sock) { + char resp[128]; + /* Center + stop + zoom reset */ + if (send_cmd(sock, "center\n", resp, sizeof(resp)) < 0) return 1; + if (strncmp(resp, "ok", 2) != 0) { fprintf(stderr, "%s\n", resp); return 1; } -static void handle_response(const char *json, size_t len) { - cJSON *root = cJSON_ParseWithLength(json, len); - if (!root) return; + if (send_cmd(sock, "stop\n", resp, sizeof(resp)) < 0) return 1; - cJSON *type = cJSON_GetObjectItem(root, "type"); - if (!cJSON_IsString(type)) { - cJSON_Delete(root); - return; - } + char cmd[32]; + snprintf(cmd, sizeof(cmd), "zoom %d\n", ZOOM_MIN); + if (send_cmd(sock, cmd, resp, sizeof(resp)) < 0) return 1; + if (strncmp(resp, "ok", 2) != 0) { fprintf(stderr, "%s\n", resp); return 1; } - const char *type_str = type->valuestring; + printf("Centered, zoom reset to 1.0x\n"); + return 0; +} - if (strcmp(type_str, "ack") == 0) { - /* Command succeeded. If more commands pending (compound center), send next. */ - if (pending_sent < pending_count) { - send_next(); - lws_callback_on_writable(client_wsi); - cJSON_Delete(root); - return; - } - /* All commands done */ - if (command == CMD_CENTER) { - printf("Centered, zoom reset to 1.0x\n"); - } else if (command == CMD_ZOOM_SET) { - printf("Zoom set to %.1fx\n", zoom_value); - } else if (command == CMD_STOP) { - printf("Stopped\n"); - } - cJSON_Delete(root); - finish(0); +static int cmd_zoom_set(int sock, float value) { + uint16_t raw = (uint16_t)(value * 100.0f + 0.5f); + if (raw < ZOOM_MIN) raw = ZOOM_MIN; + if (raw > ZOOM_MAX) raw = ZOOM_MAX; - } else if (strcmp(type_str, "status") == 0) { - cJSON *connected = cJSON_GetObjectItem(root, "camera_connected"); - cJSON *zoom = cJSON_GetObjectItem(root, "zoom"); + char cmd[32], resp[128]; + snprintf(cmd, sizeof(cmd), "zoom %u\n", raw); + if (send_cmd(sock, cmd, resp, sizeof(resp)) < 0) return 1; + if (strncmp(resp, "ok", 2) != 0) { fprintf(stderr, "%s\n", resp); return 1; } - bool cam = cJSON_IsTrue(connected); - int z = cJSON_IsNumber(zoom) ? (int)zoom->valuedouble : 0; + printf("Zoom set to %.1fx\n", value); + return 0; +} - if (command == CMD_STATUS) { - printf("Camera: %s\n", cam ? "connected" : "not connected"); - if (cam && z > 0) { - printf("Zoom: %.1fx (%d)\n", z / 100.0, z); - } - cJSON_Delete(root); - finish(0); - } else if (command == CMD_ZOOM_QUERY) { - if (cam && z > 0) { - printf("Zoom: %.1fx (%d)\n", z / 100.0, z); - } else { - printf("Camera not connected\n"); - } - cJSON_Delete(root); - finish(cam ? 0 : 1); - } - /* For other commands, the initial status on connect is ignored */ - - } else if (strcmp(type_str, "error") == 0) { - cJSON *msg = cJSON_GetObjectItem(root, "message"); - fprintf(stderr, "Error: %s\n", - cJSON_IsString(msg) ? msg->valuestring : "unknown"); - cJSON_Delete(root); - finish(1); +static int cmd_zoom_query(int sock) { + char resp[128]; + if (send_cmd(sock, "status\n", resp, sizeof(resp)) < 0) return 1; + + int connected; + unsigned zoom; + if (sscanf(resp, "status:%d %u", &connected, &zoom) == 2 && connected) { + printf("Zoom: %.1fx (%u)\n", zoom / 100.0, zoom); + } else { + printf("Camera not connected\n"); + return 1; } + return 0; +} - cJSON_Delete(root); +static int cmd_stop(int sock) { + char resp[128]; + if (send_cmd(sock, "stop\n", resp, sizeof(resp)) < 0) return 1; + if (strncmp(resp, "ok", 2) != 0) { fprintf(stderr, "%s\n", resp); return 1; } + printf("Stopped\n"); + return 0; } /* --- Jog mode --- */ -static void run_jog_mode(void) { +static int cmd_jog(int sock) { if (!isatty(STDIN_FILENO)) { fprintf(stderr, "Jog mode requires a terminal\n"); - exit_code = 1; - done = true; - return; + return 1; } - printf("\n"); - printf("=== Jog Mode ===\n"); + printf("\n=== Jog Mode ===\n"); printf(" WASD Pan/tilt +/- Speed\n"); printf(" Space Stop c Center\n"); - printf(" q/ESC Exit\n"); - printf("\n"); + printf(" q/ESC Exit\n\n"); uint8_t pan_speed = JOG_DEFAULT_PAN_SPEED; uint8_t tilt_speed = JOG_DEFAULT_TILT_SPEED; printf("Speed: pan=%u tilt=%u\n", pan_speed, tilt_speed); enter_raw_mode(); - jog_active = true; bool moving = false; struct timeval last_input; gettimeofday(&last_input, NULL); + struct pollfd fds[2] = { + { .fd = STDIN_FILENO, .events = POLLIN }, + { .fd = sock, .events = POLLIN }, + }; + while (!done) { - char c; - ssize_t n = read(STDIN_FILENO, &c, 1); - - if (n == 1) { - gettimeofday(&last_input, NULL); - c = tolower((unsigned char)c); - - /* Reset pending for each jog key */ - pending_count = 0; - pending_sent = 0; - - switch (c) { - case 'w': - queue_pan_tilt(DIR_STOP, 0, DIR_POSITIVE, tilt_speed); - moving = true; - break; - case 's': - queue_pan_tilt(DIR_STOP, 0, DIR_NEGATIVE, tilt_speed); - moving = true; - break; - case 'a': - queue_pan_tilt(DIR_NEGATIVE, pan_speed, DIR_STOP, 0); - moving = true; - break; - case 'd': - queue_pan_tilt(DIR_POSITIVE, pan_speed, DIR_STOP, 0); - moving = true; - break; - case '+': case '=': - if (pan_speed + JOG_SPEED_STEP <= PAN_MAX_SPEED) - pan_speed += JOG_SPEED_STEP; - else - pan_speed = PAN_MAX_SPEED; - if (tilt_speed + JOG_SPEED_STEP <= TILT_MAX_SPEED) - tilt_speed += JOG_SPEED_STEP; - else - tilt_speed = TILT_MAX_SPEED; - printf("\rSpeed: pan=%u tilt=%u \r", pan_speed, tilt_speed); - fflush(stdout); - break; - case '-': - if (pan_speed > JOG_MIN_SPEED + JOG_SPEED_STEP) - pan_speed -= JOG_SPEED_STEP; - else - pan_speed = JOG_MIN_SPEED; - if (tilt_speed > JOG_MIN_SPEED + JOG_SPEED_STEP) - tilt_speed -= JOG_SPEED_STEP; - else - tilt_speed = JOG_MIN_SPEED; - printf("\rSpeed: pan=%u tilt=%u \r", pan_speed, tilt_speed); - fflush(stdout); - break; - case ' ': - queue_stop(); - moving = false; - break; - case 'c': - queue_center(); - moving = false; - break; - case 'q': - case 0x1B: /* ESC */ - case 0x03: /* Ctrl-C */ - pending_count = 0; - pending_sent = 0; - queue_stop(); - send_next(); - done = true; - break; - default: - break; - } + poll(fds, 2, 50); + + /* Read keyboard input */ + if (fds[0].revents & POLLIN) { + char c; + if (read(STDIN_FILENO, &c, 1) == 1) { + gettimeofday(&last_input, NULL); + c = tolower((unsigned char)c); + + char cmd[128]; + cmd[0] = '\0'; + + switch (c) { + case 'w': + snprintf(cmd, sizeof(cmd), "pan_tilt %d %d %d %d\n", + DIR_STOP, 0, DIR_NEGATIVE, tilt_speed); + moving = true; + break; + case 's': + snprintf(cmd, sizeof(cmd), "pan_tilt %d %d %d %d\n", + DIR_STOP, 0, DIR_POSITIVE, tilt_speed); + moving = true; + break; + case 'a': + snprintf(cmd, sizeof(cmd), "pan_tilt %d %d %d %d\n", + DIR_POSITIVE, pan_speed, DIR_STOP, 0); + moving = true; + break; + case 'd': + snprintf(cmd, sizeof(cmd), "pan_tilt %d %d %d %d\n", + DIR_NEGATIVE, pan_speed, DIR_STOP, 0); + moving = true; + break; + case '+': case '=': + if (pan_speed + JOG_SPEED_STEP <= PAN_MAX_SPEED) + pan_speed += JOG_SPEED_STEP; + else + pan_speed = PAN_MAX_SPEED; + if (tilt_speed + JOG_SPEED_STEP <= TILT_MAX_SPEED) + tilt_speed += JOG_SPEED_STEP; + else + tilt_speed = TILT_MAX_SPEED; + printf("\rSpeed: pan=%u tilt=%u \r", pan_speed, tilt_speed); + fflush(stdout); + break; + case '-': + if (pan_speed > JOG_MIN_SPEED + JOG_SPEED_STEP) + pan_speed -= JOG_SPEED_STEP; + else + pan_speed = JOG_MIN_SPEED; + if (tilt_speed > JOG_MIN_SPEED + JOG_SPEED_STEP) + tilt_speed -= JOG_SPEED_STEP; + else + tilt_speed = JOG_MIN_SPEED; + printf("\rSpeed: pan=%u tilt=%u \r", pan_speed, tilt_speed); + fflush(stdout); + break; + case ' ': + snprintf(cmd, sizeof(cmd), "stop\n"); + moving = false; + break; + case 'c': + snprintf(cmd, sizeof(cmd), "center\n"); + moving = false; + break; + case 'q': + case 0x1B: /* ESC */ + case 0x03: /* Ctrl-C */ + write(sock, "stop\n", 5); + done = true; + break; + } - /* Send queued jog command */ - if (pending_count > pending_sent) { - send_next(); - lws_callback_on_writable(client_wsi); + if (cmd[0]) + write(sock, cmd, strlen(cmd)); } } - /* Auto-stop after timeout with no input */ + /* Drain incoming responses (don't need them during jog) */ + if (fds[1].revents & POLLIN) { + char discard[256]; + read(sock, discard, sizeof(discard)); + } + + /* Auto-stop after no input */ if (moving) { struct timeval now; gettimeofday(&now, NULL); long elapsed_us = (now.tv_sec - last_input.tv_sec) * 1000000L + (now.tv_usec - last_input.tv_usec); if (elapsed_us > JOG_STOP_TIMEOUT_US) { - pending_count = 0; - pending_sent = 0; - queue_stop(); - send_next(); - lws_callback_on_writable(client_wsi); + write(sock, "stop\n", 5); moving = false; } } - - /* Service WebSocket (keep connection alive, process acks) */ - lws_service(ctx, 10); - usleep(1000); } restore_terminal(); - jog_active = false; printf("\nExited jog mode.\n"); -} - -/* --- WebSocket client callback --- */ - -static int ws_callback(struct lws *wsi, enum lws_callback_reasons reason, - void *user, void *in, size_t len) { - (void)user; - - switch (reason) { - case LWS_CALLBACK_CLIENT_ESTABLISHED: - client_wsi = wsi; - if (command == CMD_JOG) { - /* Jog mode: don't send a command yet, enter jog loop */ - break; - } - /* Queue command(s) and request write */ - lws_callback_on_writable(wsi); - break; - - case LWS_CALLBACK_CLIENT_WRITEABLE: - if (pending_sent < pending_count) { - send_next(); - /* If more to send, request another writeable */ - if (pending_sent < pending_count) { - lws_callback_on_writable(wsi); - } - } - break; - - case LWS_CALLBACK_CLIENT_RECEIVE: - if (in && len > 0) { - if (jog_active) { - /* In jog mode, ignore responses (acks, status updates) */ - break; - } - - /* For status/zoom_query: ignore the initial status sent on connect, - only handle the response to our explicit command */ - static bool initial_status_received = false; - if (!initial_status_received) { - initial_status_received = true; - /* Check if this is the initial status (arrives before we send anything) */ - if (pending_sent == 0) { - /* Haven't sent our command yet — this is the connect status, ignore */ - break; - } - } - - handle_response((const char *)in, len); - } - break; - - case LWS_CALLBACK_CLIENT_CONNECTION_ERROR: - fprintf(stderr, "Error: daemon not active (is linkctl-daemon running?)\n"); - if (command != CMD_JOG) finish(1); - exit_code = 1; - done = true; - break; - - case LWS_CALLBACK_CLIENT_CLOSED: - client_wsi = NULL; - if (!done) { - fprintf(stderr, "Error: connection lost\n"); - if (command != CMD_JOG) finish(1); - exit_code = 1; - done = true; - } - break; - - default: - break; - } - - return 0; -} - -static struct lws_protocols protocols[] = { - { - .name = "arduino", - .callback = ws_callback, - .per_session_data_size = 0, - .rx_buffer_size = 4096, - }, - { NULL, NULL, 0, 0 } -}; - -/* --- Usage --- */ - -static void print_usage(void) { - printf("Usage: linkctl [args]\n"); - printf("\n"); - printf("Commands:\n"); - printf(" help Show this help\n"); - printf(" status Show camera connection and zoom level\n"); - printf(" center Center gimbal and reset zoom to 1.0x\n"); - printf(" zoom <1.0-4.0> Set zoom level\n"); - printf(" zoom Show current zoom level\n"); - printf(" stop Stop all gimbal movement\n"); - printf(" jog Interactive WASD pan/tilt mode\n"); - printf("\n"); - printf("The linkctl-daemon must be running for commands to work.\n"); -} - -/* --- Argument parsing --- */ - -static int parse_args(int argc, char **argv) { - if (argc < 2) { - print_usage(); - return -1; - } - - const char *cmd = argv[1]; - - if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0 || - strcmp(cmd, "-h") == 0) { - command = CMD_HELP; - return 0; - } - - if (strcmp(cmd, "status") == 0) { - command = CMD_STATUS; - } else if (strcmp(cmd, "center") == 0) { - command = CMD_CENTER; - } else if (strcmp(cmd, "zoom") == 0) { - if (argc >= 3) { - command = CMD_ZOOM_SET; - char *end; - zoom_value = strtof(argv[2], &end); - if (*end != '\0' || zoom_value < 1.0f || zoom_value > 4.0f) { - fprintf(stderr, "Error: zoom must be a number between 1.0 and 4.0\n"); - return -1; - } - } else { - command = CMD_ZOOM_QUERY; - } - } else if (strcmp(cmd, "stop") == 0) { - command = CMD_STOP; - } else if (strcmp(cmd, "jog") == 0) { - command = CMD_JOG; - } else { - fprintf(stderr, "Unknown command: %s\n", cmd); - fprintf(stderr, "Run 'linkctl help' for usage.\n"); - return -1; - } - return 0; } /* --- Main --- */ int main(int argc, char **argv) { - if (parse_args(argc, argv) != 0) { + if (argc < 2) { + print_usage(); return 1; } - if (command == CMD_HELP) { + const char *subcmd = argv[1]; + + if (strcmp(subcmd, "help") == 0 || strcmp(subcmd, "--help") == 0 || + strcmp(subcmd, "-h") == 0) { print_usage(); return 0; } - /* Install signal handlers */ signal(SIGINT, handle_signal); signal(SIGTERM, handle_signal); - /* Queue commands based on parsed command */ - switch (command) { - case CMD_STATUS: - case CMD_ZOOM_QUERY: - queue_status(); - break; - case CMD_CENTER: - queue_center(); - queue_zoom(ZOOM_MIN); /* Second command: reset zoom to 1x */ - break; - case CMD_ZOOM_SET: { - uint16_t raw = (uint16_t)(zoom_value * 100.0f + 0.5f); - if (raw < ZOOM_MIN) raw = ZOOM_MIN; - if (raw > ZOOM_MAX) raw = ZOOM_MAX; - queue_zoom(raw); - break; - } - case CMD_STOP: - queue_stop(); - break; - case CMD_JOG: - /* Commands sent interactively */ - break; - default: - break; - } - - /* Pre-format the alarm timeout message (signal handler can't use printf) */ - switch (command) { - case CMD_CENTER: - alarm_msg_len = snprintf(alarm_msg, sizeof(alarm_msg), - "Centered, zoom reset to 1.0x\n"); - break; - case CMD_ZOOM_SET: - alarm_msg_len = snprintf(alarm_msg, sizeof(alarm_msg), - "Zoom set to %.1fx\n", zoom_value); - break; - case CMD_STOP: - alarm_msg_len = snprintf(alarm_msg, sizeof(alarm_msg), "Stopped\n"); - break; - default: - break; - } - - /* Suppress libwebsockets logs */ - lws_set_log_level(0, NULL); - - /* Create WebSocket client context */ - struct lws_context_creation_info info; - memset(&info, 0, sizeof(info)); - info.port = CONTEXT_PORT_NO_LISTEN; /* Client only, no server */ - info.protocols = protocols; - info.gid = -1; - info.uid = -1; - - ctx = lws_create_context(&info); - if (!ctx) { - fprintf(stderr, "Error: failed to create WebSocket context\n"); + /* Connect to daemon */ + int sock = tcp_connect(DEFAULT_HOST, DEFAULT_PORT); + if (sock < 0) { + fprintf(stderr, "Error: daemon not active (is linkctl-daemon running?)\n"); return 1; } - /* Initiate connection to daemon */ - struct lws_client_connect_info ccinfo; - memset(&ccinfo, 0, sizeof(ccinfo)); - ccinfo.context = ctx; - ccinfo.address = DEFAULT_HOST; - ccinfo.port = DEFAULT_PORT; - ccinfo.path = "/"; - ccinfo.host = DEFAULT_HOST; - ccinfo.origin = DEFAULT_HOST; - ccinfo.protocol = "arduino"; - - if (!lws_client_connect_via_info(&ccinfo)) { - fprintf(stderr, "Error: failed to initiate connection\n"); - lws_context_destroy(ctx); - return 1; - } + int rc = 0; - /* Event loop */ - if (command == CMD_JOG) { - /* Wait for connection, then enter jog mode */ - while (!done && !client_wsi) { - lws_service(ctx, 50); - } - if (!done) { - run_jog_mode(); + if (strcmp(subcmd, "status") == 0) { + rc = cmd_status(sock); + } else if (strcmp(subcmd, "center") == 0) { + rc = cmd_center(sock); + } else if (strcmp(subcmd, "zoom") == 0) { + if (argc >= 3) { + char *end; + float val = strtof(argv[2], &end); + if (*end != '\0' || val < 1.0f || val > 4.0f) { + fprintf(stderr, "Error: zoom must be a number between 1.0 and 4.0\n"); + rc = 1; + } else { + rc = cmd_zoom_set(sock, val); + } + } else { + rc = cmd_zoom_query(sock); } + } else if (strcmp(subcmd, "stop") == 0) { + rc = cmd_stop(sock); + } else if (strcmp(subcmd, "jog") == 0) { + rc = cmd_jog(sock); } else { - /* One-shot: run until response received. - SIGALRM provides the timeout for action commands without ack. */ - while (!done) { - lws_service(ctx, 50); - } + fprintf(stderr, "Unknown command: %s\nRun 'linkctl help' for usage.\n", subcmd); + rc = 1; } - finish(exit_code); + close(sock); + return rc; } From ce3ae1c86a05ea570554c5ed381f26446e402240 Mon Sep 17 00:00:00 2001 From: Jack Woods Date: Sat, 4 Apr 2026 15:09:26 -0400 Subject: [PATCH 3/3] Fixing some of the robot's suggestions --- host/linkctl-daemon/control.c | 20 +++++++++++++++++++- host/linkctl-daemon/main.c | 3 ++- host/linkctl-daemon/server.c | 9 +++++++-- host/linkctl/README.md | 2 +- host/linkctl/linkctl.c | 1 + 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/host/linkctl-daemon/control.c b/host/linkctl-daemon/control.c index f56c85b..bd89508 100644 --- a/host/linkctl-daemon/control.c +++ b/host/linkctl-daemon/control.c @@ -7,9 +7,13 @@ #include #include #include +#include #include #include +/* 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; @@ -32,30 +36,44 @@ static void dispatch_command(const char *line) { 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); @@ -81,7 +99,7 @@ int control_init(uint16_t port) { struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(port), - .sin_addr.s_addr = htonl(INADDR_ANY), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), }; if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { diff --git a/host/linkctl-daemon/main.c b/host/linkctl-daemon/main.c index 3a81a56..c1b729c 100644 --- a/host/linkctl-daemon/main.c +++ b/host/linkctl-daemon/main.c @@ -9,12 +9,13 @@ #include #include #include +#include #include #include #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; diff --git a/host/linkctl-daemon/server.c b/host/linkctl-daemon/server.c index bf8c89d..719185d 100644 --- a/host/linkctl-daemon/server.c +++ b/host/linkctl-daemon/server.c @@ -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(); @@ -237,14 +239,17 @@ 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 if (strcmp(cmd_str, "status") != 0) { - // Ack successful action commands (status already sends its own response) + } else { send_json(wsi, "{\"type\":\"ack\"}"); } diff --git a/host/linkctl/README.md b/host/linkctl/README.md index fd02b63..32a2c18 100644 --- a/host/linkctl/README.md +++ b/host/linkctl/README.md @@ -62,7 +62,7 @@ linkctl [args] | `C` | Center gimbal | | `Q` / ESC | Exit jog mode | -Movement continues while a key is held (via terminal key repeat) and automatically stops ~150ms after release. +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). diff --git a/host/linkctl/linkctl.c b/host/linkctl/linkctl.c index 689a242..b3f0c6a 100644 --- a/host/linkctl/linkctl.c +++ b/host/linkctl/linkctl.c @@ -153,6 +153,7 @@ static int cmd_center(int sock) { if (strncmp(resp, "ok", 2) != 0) { fprintf(stderr, "%s\n", resp); return 1; } if (send_cmd(sock, "stop\n", resp, sizeof(resp)) < 0) return 1; + if (strncmp(resp, "ok", 2) != 0) { fprintf(stderr, "%s\n", resp); return 1; } char cmd[32]; snprintf(cmd, sizeof(cmd), "zoom %d\n", ZOOM_MIN);