diff --git a/Makefile b/Makefile index 9e780ce..215b088 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,25 @@ -.PHONY: all clean cli +ROOT := $(abspath .) +include $(ROOT)/common.mk -all: cli +.PHONY: all clean modules cli node -cli: +all: modules cli node + +modules: + $(MAKE) -C src/modules/common + $(MAKE) -C src/modules/media_ctrl + $(MAKE) -C src/modules/v4l2_ctrl + $(MAKE) -C src/modules/serial + $(MAKE) -C src/modules/transport + $(MAKE) -C src/modules/discovery + $(MAKE) -C src/modules/config + $(MAKE) -C src/modules/protocol + +cli: modules $(MAKE) -C dev/cli +node: modules + $(MAKE) -C src/node + clean: rm -rf build/ diff --git a/common.mk b/common.mk index 6230c9d..0ecbc03 100644 --- a/common.mk +++ b/common.mk @@ -2,6 +2,11 @@ # Each including Makefile must set ROOT before including this file: # ROOT := $(abspath ) -CC = gcc -CFLAGS = -std=c11 -Wall -Wextra -D_GNU_SOURCE -flto -I$(ROOT)/include -BUILD = $(ROOT)/build +CC = gcc +CFLAGS = -std=c11 -Wall -Wextra -D_GNU_SOURCE -flto -I$(ROOT)/include +BUILD = $(ROOT)/build + +# Automatic dependency generation. +# -MMD emit a .d file listing header prerequisites alongside each .o +# -MP add phony targets for headers so removed headers don't break make +DEPFLAGS = -MMD -MP diff --git a/dev/cli/Makefile b/dev/cli/Makefile index 22bafca..c463788 100644 --- a/dev/cli/Makefile +++ b/dev/cli/Makefile @@ -9,47 +9,79 @@ SERIAL_OBJ = $(BUILD)/serial/serial.o TRANSPORT_OBJ = $(BUILD)/transport/transport.o DISCOVERY_OBJ = $(BUILD)/discovery/discovery.o CONFIG_OBJ = $(BUILD)/config/config.o +PROTOCOL_OBJ = $(BUILD)/protocol/protocol.o -.PHONY: all clean modules +CLI_SRCS = \ + media_ctrl_cli.c \ + v4l2_ctrl_cli.c \ + transport_cli.c \ + discovery_cli.c \ + config_cli.c \ + protocol_cli.c \ + query_cli.c -all: modules \ +CLI_OBJS = $(CLI_SRCS:%.c=$(CLI_BUILD)/%.o) + +.PHONY: all clean + +all: \ $(CLI_BUILD)/media_ctrl_cli \ $(CLI_BUILD)/v4l2_ctrl_cli \ $(CLI_BUILD)/transport_cli \ $(CLI_BUILD)/discovery_cli \ - $(CLI_BUILD)/config_cli + $(CLI_BUILD)/config_cli \ + $(CLI_BUILD)/protocol_cli \ + $(CLI_BUILD)/query_cli -modules: - $(MAKE) -C $(ROOT)/src/modules/common - $(MAKE) -C $(ROOT)/src/modules/media_ctrl - $(MAKE) -C $(ROOT)/src/modules/v4l2_ctrl - $(MAKE) -C $(ROOT)/src/modules/serial - $(MAKE) -C $(ROOT)/src/modules/transport - $(MAKE) -C $(ROOT)/src/modules/discovery - $(MAKE) -C $(ROOT)/src/modules/config +# Module objects delegate to their sub-makes. +$(COMMON_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/common +$(MEDIA_CTRL_OBJ):; $(MAKE) -C $(ROOT)/src/modules/media_ctrl +$(V4L2_CTRL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/v4l2_ctrl +$(SERIAL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/serial +$(TRANSPORT_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/transport +$(DISCOVERY_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/discovery +$(CONFIG_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/config +$(PROTOCOL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/protocol -$(CLI_BUILD)/media_ctrl_cli: media_ctrl_cli.c $(COMMON_OBJ) $(MEDIA_CTRL_OBJ) | $(CLI_BUILD) +# Compile each CLI source to its own .o (generates .d alongside). +$(CLI_BUILD)/%.o: %.c | $(CLI_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< + +# Link rules. +$(CLI_BUILD)/media_ctrl_cli: $(CLI_BUILD)/media_ctrl_cli.o $(COMMON_OBJ) $(MEDIA_CTRL_OBJ) $(CC) $(CFLAGS) -o $@ $^ -$(CLI_BUILD)/v4l2_ctrl_cli: v4l2_ctrl_cli.c $(COMMON_OBJ) $(V4L2_CTRL_OBJ) | $(CLI_BUILD) +$(CLI_BUILD)/v4l2_ctrl_cli: $(CLI_BUILD)/v4l2_ctrl_cli.o $(COMMON_OBJ) $(V4L2_CTRL_OBJ) $(CC) $(CFLAGS) -o $@ $^ -$(CLI_BUILD)/transport_cli: transport_cli.c $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) | $(CLI_BUILD) +$(CLI_BUILD)/transport_cli: $(CLI_BUILD)/transport_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) $(CC) $(CFLAGS) -o $@ $^ -lpthread -$(CLI_BUILD)/discovery_cli: discovery_cli.c $(COMMON_OBJ) $(SERIAL_OBJ) $(DISCOVERY_OBJ) | $(CLI_BUILD) +$(CLI_BUILD)/discovery_cli: $(CLI_BUILD)/discovery_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(DISCOVERY_OBJ) $(CC) $(CFLAGS) -o $@ $^ -lpthread -$(CLI_BUILD)/config_cli: config_cli.c $(COMMON_OBJ) $(CONFIG_OBJ) | $(CLI_BUILD) +$(CLI_BUILD)/config_cli: $(CLI_BUILD)/config_cli.o $(COMMON_OBJ) $(CONFIG_OBJ) $(CC) $(CFLAGS) -o $@ $^ +$(CLI_BUILD)/protocol_cli: $(CLI_BUILD)/protocol_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) $(PROTOCOL_OBJ) + $(CC) $(CFLAGS) -o $@ $^ -lpthread + +$(CLI_BUILD)/query_cli: $(CLI_BUILD)/query_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) $(DISCOVERY_OBJ) $(PROTOCOL_OBJ) + $(CC) $(CFLAGS) -o $@ $^ -lpthread + $(CLI_BUILD): mkdir -p $@ clean: rm -f \ + $(CLI_OBJS) \ + $(CLI_OBJS:%.o=%.d) \ $(CLI_BUILD)/media_ctrl_cli \ $(CLI_BUILD)/v4l2_ctrl_cli \ $(CLI_BUILD)/transport_cli \ $(CLI_BUILD)/discovery_cli \ - $(CLI_BUILD)/config_cli + $(CLI_BUILD)/config_cli \ + $(CLI_BUILD)/protocol_cli \ + $(CLI_BUILD)/query_cli + +-include $(CLI_OBJS:%.o=%.d) diff --git a/dev/cli/config_cli.c b/dev/cli/config_cli.c index fc9a1d4..85d9ec5 100644 --- a/dev/cli/config_cli.c +++ b/dev/cli/config_cli.c @@ -62,7 +62,7 @@ int main(int argc, char **argv) { } if (!APP_IS_OK(err)) { - fprintf(stderr, "config_load: errno %d\n", err.detail.syscall.err_no); + app_error_print(&err); return 1; } diff --git a/dev/cli/protocol_cli.c b/dev/cli/protocol_cli.c new file mode 100644 index 0000000..3e5ba9c --- /dev/null +++ b/dev/cli/protocol_cli.c @@ -0,0 +1,250 @@ +#include +#include +#include +#include + +#include "protocol.h" +#include "transport.h" +#include "error.h" + +/* -- frame decoder --------------------------------------------------------- */ + +static const char *cmd_name(uint16_t cmd) { + switch (cmd) { + case PROTO_CMD_STREAM_OPEN: return "STREAM_OPEN"; + case PROTO_CMD_STREAM_CLOSE: return "STREAM_CLOSE"; + case PROTO_CMD_ENUM_DEVICES: return "ENUM_DEVICES"; + case PROTO_CMD_ENUM_CONTROLS: return "ENUM_CONTROLS"; + case PROTO_CMD_GET_CONTROL: return "GET_CONTROL"; + case PROTO_CMD_SET_CONTROL: return "SET_CONTROL"; + case PROTO_CMD_ENUM_MONITORS: return "ENUM_MONITORS"; + default: return "unknown"; + } +} + +static const char *event_name(uint8_t code) { + switch (code) { + case PROTO_EVENT_INTERRUPTED: return "INTERRUPTED"; + case PROTO_EVENT_RESUMED: return "RESUMED"; + default: return "unknown"; + } +} + +static const char *status_name(uint16_t s) { + switch (s) { + case PROTO_STATUS_OK: return "OK"; + case PROTO_STATUS_ERROR: return "ERROR"; + case PROTO_STATUS_UNKNOWN_CMD: return "UNKNOWN_CMD"; + case PROTO_STATUS_INVALID_PARAMS: return "INVALID_PARAMS"; + case PROTO_STATUS_NOT_FOUND: return "NOT_FOUND"; + default: return "unknown"; + } +} + + +static void decode_and_print(struct Transport_Frame *frame) { + switch (frame->message_type) { + + case PROTO_MSG_VIDEO_FRAME: { + struct Proto_Video_Frame vf; + struct App_Error e = proto_read_video_frame( + frame->payload, frame->payload_length, &vf); + if (!APP_IS_OK(e)) { app_error_print(&e); return; } + printf("VIDEO_FRAME stream_id=%u data_len=%u\n", + vf.stream_id, vf.data_len); + break; + } + + case PROTO_MSG_STREAM_EVENT: { + struct Proto_Stream_Event ev; + struct App_Error e = proto_read_stream_event( + frame->payload, frame->payload_length, &ev); + if (!APP_IS_OK(e)) { app_error_print(&e); return; } + printf("STREAM_EVENT stream_id=%u event=%s\n", + ev.stream_id, event_name(ev.event_code)); + break; + } + + case PROTO_MSG_CONTROL_REQUEST: { + struct Proto_Request_Header hdr; + struct App_Error e = proto_read_request_header( + frame->payload, frame->payload_length, &hdr); + if (!APP_IS_OK(e)) { app_error_print(&e); return; } + + printf("CONTROL_REQUEST request_id=%u command=%s\n", + hdr.request_id, cmd_name(hdr.command)); + + switch (hdr.command) { + case PROTO_CMD_STREAM_OPEN: { + struct Proto_Stream_Open so; + e = proto_read_stream_open(frame->payload, frame->payload_length, &so); + if (!APP_IS_OK(e)) { app_error_print(&e); return; } + printf(" stream_id=%u format=0x%04x pixel_format=0x%04x origin=0x%04x\n", + so.stream_id, so.format, so.pixel_format, so.origin); + break; + } + case PROTO_CMD_STREAM_CLOSE: { + struct Proto_Stream_Close sc; + e = proto_read_stream_close(frame->payload, frame->payload_length, &sc); + if (!APP_IS_OK(e)) { app_error_print(&e); return; } + printf(" stream_id=%u\n", sc.stream_id); + break; + } + case PROTO_CMD_ENUM_CONTROLS: { + struct Proto_Enum_Controls_Req req; + e = proto_read_enum_controls_req(frame->payload, frame->payload_length, &req); + if (!APP_IS_OK(e)) { app_error_print(&e); return; } + printf(" device_index=%u\n", req.device_index); + break; + } + case PROTO_CMD_GET_CONTROL: { + struct Proto_Get_Control_Req req; + e = proto_read_get_control_req(frame->payload, frame->payload_length, &req); + if (!APP_IS_OK(e)) { app_error_print(&e); return; } + printf(" device_index=%u control_id=%u\n", + req.device_index, req.control_id); + break; + } + case PROTO_CMD_SET_CONTROL: { + struct Proto_Set_Control_Req req; + e = proto_read_set_control_req(frame->payload, frame->payload_length, &req); + if (!APP_IS_OK(e)) { app_error_print(&e); return; } + printf(" device_index=%u control_id=%u value=%d\n", + req.device_index, req.control_id, req.value); + break; + } + default: + break; + } + break; + } + + case PROTO_MSG_CONTROL_RESPONSE: { + struct Proto_Response_Header hdr; + struct App_Error e = proto_read_response_header( + frame->payload, frame->payload_length, &hdr); + if (!APP_IS_OK(e)) { app_error_print(&e); return; } + + printf("CONTROL_RESPONSE request_id=%u status=%s\n", + hdr.request_id, status_name(hdr.status)); + + /* extra bytes are command-specific; caller must know the command */ + break; + } + + default: + printf("unknown message_type=0x%04x payload_length=%u\n", + frame->message_type, frame->payload_length); + break; + } +} + +/* -- server mode ----------------------------------------------------------- */ + +static void server_on_frame(struct Transport_Conn *conn, + struct Transport_Frame *frame, void *ud) +{ + (void)conn; (void)ud; + decode_and_print(frame); + free(frame->payload); +} + +static void server_on_connect(struct Transport_Conn *conn, void *ud) { + (void)conn; (void)ud; + printf("client connected\n"); +} + +static void server_on_disconnect(struct Transport_Conn *conn, void *ud) { + (void)conn; (void)ud; + printf("client disconnected\n"); +} + +static int run_server(uint16_t port) { + struct Transport_Server_Config cfg = { + .port = port, + .max_connections = 8, + .max_payload = 16 * 1024 * 1024, + .on_frame = server_on_frame, + .on_connect = server_on_connect, + .on_disconnect = server_on_disconnect, + }; + + struct Transport_Server *server; + struct App_Error e = transport_server_create(&server, &cfg); + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + + e = transport_server_start(server); + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + + printf("listening on port %u\n", port); + pause(); + return 0; +} + +/* -- client mode ----------------------------------------------------------- */ + +static void client_on_frame(struct Transport_Conn *conn, + struct Transport_Frame *frame, void *ud) +{ + (void)conn; (void)ud; + decode_and_print(frame); + free(frame->payload); +} + +static void client_on_disconnect(struct Transport_Conn *conn, void *ud) { + (void)conn; (void)ud; + printf("disconnected\n"); +} + +static int run_client(const char *host, uint16_t port) { + struct Transport_Conn *conn; + struct App_Error e = transport_connect(&conn, host, port, + 16 * 1024 * 1024, + client_on_frame, client_on_disconnect, NULL); + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + + printf("connected — sending STREAM_OPEN request\n"); + + e = proto_write_stream_open(conn, + /*request_id=*/1, + /*stream_id=*/0, + PROTO_FORMAT_MJPEG, + 0, + PROTO_ORIGIN_DEVICE_NATIVE); + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + + printf("sent STREAM_OPEN; waiting for response (ctrl-c to exit)\n"); + pause(); + return 0; +} + +/* -- usage ----------------------------------------------------------------- */ + +static void usage(void) { + fprintf(stderr, + "usage: protocol_cli --server [port]\n" + " protocol_cli --client host port\n" + "\n" + " --server [port] listen and decode incoming protocol frames\n" + " (default port 8000)\n" + " --client host port connect, send STREAM_OPEN, decode responses\n"); +} + +int main(int argc, char **argv) { + if (argc < 2) { usage(); return 1; } + + if (strcmp(argv[1], "--server") == 0) { + uint16_t port = 8000; + if (argc >= 3) { port = (uint16_t)atoi(argv[2]); } + return run_server(port); + } + + if (strcmp(argv[1], "--client") == 0) { + if (argc < 4) { usage(); return 1; } + uint16_t port = (uint16_t)atoi(argv[3]); + return run_client(argv[2], port); + } + + usage(); + return 1; +} diff --git a/dev/cli/query_cli.c b/dev/cli/query_cli.c new file mode 100644 index 0000000..9bb81be --- /dev/null +++ b/dev/cli/query_cli.c @@ -0,0 +1,297 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "discovery.h" +#include "transport.h" +#include "protocol.h" +#include "error.h" + +/* ------------------------------------------------------------------------- + * Discovery — wait for first matching node + * ------------------------------------------------------------------------- */ + +struct Discovery_Wait { + struct Discovery_Peer peer; + atomic_int found; +}; + +static void on_peer_found(const struct Discovery_Peer *peer, void *userdata) { + struct Discovery_Wait *w = userdata; + if (atomic_load(&w->found)) { return; } + w->peer = *peer; + atomic_store(&w->found, 1); +} + +static int wait_for_node(struct Discovery_Peer *peer_out, + const char *self_name, int timeout_ms) +{ + struct Discovery_Wait w; + memset(&w, 0, sizeof(w)); + atomic_init(&w.found, 0); + + struct Discovery_Config cfg = { + .site_id = 0, + .tcp_port = 0, + .function_flags = 0, + .name = self_name, + .interval_ms = 2000, + .timeout_intervals= 3, + .on_peer_found = on_peer_found, + .userdata = &w, + }; + + struct Discovery *disc; + struct App_Error e = discovery_create(&disc, &cfg); + if (!APP_IS_OK(e)) { app_error_print(&e); return -1; } + + e = discovery_start(disc); + if (!APP_IS_OK(e)) { app_error_print(&e); discovery_destroy(disc); return -1; } + + printf("waiting for a node (timeout %d ms)...\n", timeout_ms); + + int elapsed = 0; + while (!atomic_load(&w.found) && elapsed < timeout_ms) { + usleep(100000); + elapsed += 100; + } + + discovery_destroy(disc); + + if (!atomic_load(&w.found)) { + fprintf(stderr, "no node found within %d ms\n", timeout_ms); + return -1; + } + + *peer_out = w.peer; + return 0; +} + +/* ------------------------------------------------------------------------- + * Response handling — semaphore-based synchronisation + * ------------------------------------------------------------------------- */ + +struct Query_State { + sem_t sem; + uint16_t last_request_id; + int done; +}; + +/* ------------------------------------------------------------------------- + * ENUM_DEVICES callbacks + * ------------------------------------------------------------------------- */ + +static void on_media_device( + const char *path, uint8_t path_len, + const char *driver, uint8_t driver_len, + const char *model, uint8_t model_len, + const char *bus_info, uint8_t bus_info_len, + uint8_t vcount, + void *ud) +{ + (void)ud; + printf(" media %.*s driver=%.*s model=%.*s bus=%.*s (%u video node(s))\n", + (int)path_len, path, + (int)driver_len, driver, + (int)model_len, model, + (int)bus_info_len, bus_info, + (unsigned)vcount); +} + +static void caps_str(uint32_t caps, char *buf, size_t len) { + /* Build a compact comma-separated list of the relevant V4L2_CAP_* bits. */ + static const struct { uint32_t bit; const char *name; } flags[] = { + { 0x00000001u, "video-capture" }, + { 0x00000002u, "video-output" }, + { 0x00000010u, "vbi-capture" }, + { 0x00800000u, "meta-capture" }, + { 0x04000000u, "streaming" }, + }; + buf[0] = '\0'; + size_t pos = 0; + for (size_t i = 0; i < sizeof(flags)/sizeof(flags[0]); i++) { + if (!(caps & flags[i].bit)) { continue; } + int n = snprintf(buf + pos, len - pos, "%s%s", + pos ? "," : "", flags[i].name); + if (n < 0 || (size_t)n >= len - pos) { break; } + pos += (size_t)n; + } +} + +static void on_video_node( + const char *path, uint8_t path_len, + const char *ename, uint8_t ename_len, + uint32_t etype, uint32_t eflags, + uint32_t dcaps, + uint8_t pflags, uint8_t is_capture, + void *ud) +{ + (void)eflags; (void)pflags; (void)ud; + char caps[128]; + caps_str(dcaps, caps, sizeof(caps)); + printf(" video %.*s entity=%.*s type=0x%08x caps=[%s]%s\n", + (int)path_len, path, + (int)ename_len, ename, + etype, caps, + is_capture ? " [capture]" : ""); +} + +static void on_standalone( + const char *path, uint8_t path_len, + const char *name, uint8_t name_len, + void *ud) +{ + (void)ud; + printf(" standalone %.*s card=%.*s\n", + (int)path_len, path, + (int)name_len, name); +} + +/* ------------------------------------------------------------------------- + * ENUM_CONTROLS callbacks + * ------------------------------------------------------------------------- */ + +static void on_control( + uint32_t id, uint8_t type, uint32_t flags, + const char *name, uint8_t name_len, + int32_t min, int32_t max, int32_t step, + int32_t default_val, int32_t current_val, + uint8_t menu_count, void *ud) +{ + (void)flags; (void)ud; + printf(" ctrl id=0x%08x type=%u %.*s" + " min=%d max=%d step=%d default=%d current=%d", + id, type, + (int)name_len, name, + min, max, step, default_val, current_val); + if (menu_count) { printf(" (%u menu items)", (unsigned)menu_count); } + printf("\n"); +} + +/* ------------------------------------------------------------------------- + * Frame handler + * ------------------------------------------------------------------------- */ + +struct Frame_State { + struct Query_State *q; + uint16_t pending_cmd; /* command we expect a response for */ + uint16_t device_count; /* flat video node count from ENUM_DEVICES */ +}; + +static void on_frame(struct Transport_Conn *conn, + struct Transport_Frame *frame, void *userdata) +{ + (void)conn; + struct Frame_State *fs = userdata; + + if (frame->message_type == PROTO_MSG_CONTROL_RESPONSE) { + switch (fs->pending_cmd) { + case PROTO_CMD_ENUM_DEVICES: { + struct Proto_Response_Header hdr; + struct App_Error e = proto_read_enum_devices_response( + frame->payload, frame->payload_length, &hdr, + on_media_device, on_video_node, on_standalone, NULL); + if (!APP_IS_OK(e)) { app_error_print(&e); } + else if (hdr.status != PROTO_STATUS_OK) { + fprintf(stderr, "ENUM_DEVICES failed: status=%u\n", hdr.status); + } + break; + } + case PROTO_CMD_ENUM_CONTROLS: { + struct Proto_Response_Header hdr; + struct App_Error e = proto_read_enum_controls_response( + frame->payload, frame->payload_length, &hdr, + on_control, NULL, NULL); + if (!APP_IS_OK(e)) { app_error_print(&e); } + else if (hdr.status != PROTO_STATUS_OK) { + fprintf(stderr, "ENUM_CONTROLS failed: status=%u\n", hdr.status); + } + break; + } + default: + break; + } + sem_post(&fs->q->sem); + } + + free(frame->payload); +} + +static void on_disconnect(struct Transport_Conn *conn, void *userdata) { + (void)conn; (void)userdata; + printf("disconnected\n"); +} + +/* ------------------------------------------------------------------------- + * Entry point + * ------------------------------------------------------------------------- */ + +static void usage(void) { + fprintf(stderr, + "usage: query_cli [--timeout ms] [--controls device_index]\n" + "\n" + " Discovers a video node on the LAN and queries its devices.\n" + " --timeout ms discovery timeout in ms (default 5000)\n" + " --controls idx also enumerate controls for device at index idx\n"); +} + +int main(int argc, char **argv) { + int timeout_ms = 5000; + int query_controls = -1; /* -1 = don't query controls */ + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--timeout") == 0 && i + 1 < argc) { + timeout_ms = atoi(argv[++i]); + } else if (strcmp(argv[i], "--controls") == 0 && i + 1 < argc) { + query_controls = atoi(argv[++i]); + } else if (strcmp(argv[i], "--help") == 0) { + usage(); return 0; + } + } + + /* Discover a node */ + struct Discovery_Peer peer; + if (wait_for_node(&peer, "query_cli:0", timeout_ms) != 0) { return 1; } + + char addr_str[INET_ADDRSTRLEN]; + struct in_addr in = { .s_addr = peer.addr }; + inet_ntop(AF_INET, &in, addr_str, sizeof(addr_str)); + printf("found node: %s addr=%s port=%u\n", + peer.name, addr_str, peer.tcp_port); + + /* Set up connection state */ + struct Query_State q; + sem_init(&q.sem, 0, 0); + + struct Frame_State fs = { .q = &q, .pending_cmd = 0, .device_count = 0 }; + + struct Transport_Conn *conn; + struct App_Error e = transport_connect(&conn, addr_str, peer.tcp_port, + 16 * 1024 * 1024, on_frame, on_disconnect, &fs); + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + + /* ENUM_DEVICES */ + printf("\ndevices:\n"); + fs.pending_cmd = PROTO_CMD_ENUM_DEVICES; + e = proto_write_enum_devices(conn, 1); + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + sem_wait(&q.sem); + + /* ENUM_CONTROLS for a specific device if requested */ + if (query_controls >= 0) { + printf("\ncontrols for device %d:\n", query_controls); + fs.pending_cmd = PROTO_CMD_ENUM_CONTROLS; + e = proto_write_enum_controls(conn, 2, (uint16_t)query_controls); + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + sem_wait(&q.sem); + } + + transport_conn_close(conn); + sem_destroy(&q.sem); + return 0; +} diff --git a/dev/web/.gitignore b/dev/web/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/dev/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/dev/web/Makefile b/dev/web/Makefile new file mode 100644 index 0000000..ed66624 --- /dev/null +++ b/dev/web/Makefile @@ -0,0 +1,9 @@ +.PHONY: all clean + +all: node_modules + +node_modules: package.json + npm install + +clean: + rm -rf node_modules diff --git a/dev/web/discovery.mjs b/dev/web/discovery.mjs new file mode 100644 index 0000000..9ab84a6 --- /dev/null +++ b/dev/web/discovery.mjs @@ -0,0 +1,86 @@ +/* + * UDP multicast discovery — streaming callback API. + * + * Announcement wire format (matches discovery.c): + * [u16 msg_type=0x0010][u32 payload_len] <- 6-byte frame header + * [u8 protocol_version] + * [u16 site_id] + * [u16 tcp_port] + * [u16 function_flags] + * [u8 name_len] + * [bytes name...] + */ + +import dgram from 'node:dgram'; + +const MULTICAST_GROUP = '224.0.0.251'; +const DISCOVERY_PORT = 5353; +const ANNOUNCE_TYPE = 0x0010; +const HEADER_SIZE = 6; +const ANN_FIXED_SIZE = 8; + +/* + * Open a discovery socket, send an immediate announce to prompt replies, + * and call on_peer for each newly-seen peer (deduplicated by addr+name). + * + * Returns a stop() function. Call it when done (e.g. user closed the picker + * or selected a node). Safe to call multiple times. + */ +export function start_discovery(on_peer) { + const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true }); + const seen = new Set(); + let closed = false; + + sock.on('error', () => stop()); + + sock.on('message', (msg, rinfo) => { + if (msg.length < HEADER_SIZE) { return; } + const msg_type = msg.readUInt16BE(0); + const payload_len = msg.readUInt32BE(2); + if (msg_type !== ANNOUNCE_TYPE) { return; } + if (msg.length < HEADER_SIZE + payload_len) { return; } + if (payload_len < ANN_FIXED_SIZE) { return; } + + const p = msg.slice(HEADER_SIZE); + const site_id = p.readUInt16BE(1); + const tcp_port = p.readUInt16BE(3); + const func_flags = p.readUInt16BE(5); + const name_len = p.readUInt8(7); + if (payload_len < ANN_FIXED_SIZE + name_len) { return; } + const name = p.toString('utf8', 8, 8 + name_len); + + const key = `${rinfo.address}:${name}`; + if (seen.has(key)) { return; } + seen.add(key); + + on_peer({ addr: rinfo.address, tcp_port, site_id, function_flags: func_flags, name }); + }); + + sock.bind(DISCOVERY_PORT, () => { + sock.addMembership(MULTICAST_GROUP); + send_announce(sock); + }); + + function stop() { + if (closed) { return; } + closed = true; + try { sock.close(); } catch {} + } + + return stop; +} + +function send_announce(sock) { + const name = Buffer.from('web-inspector', 'utf8'); + const payload_len = 8 + name.length; + const buf = Buffer.allocUnsafe(6 + payload_len); + buf.writeUInt16BE(ANNOUNCE_TYPE, 0); + buf.writeUInt32BE(payload_len, 2); + buf.writeUInt8(1, 6); /* protocol_version */ + buf.writeUInt16BE(0, 7); /* site_id */ + buf.writeUInt16BE(0, 9); /* tcp_port (0 = no server) */ + buf.writeUInt16BE(0, 11); /* function_flags */ + buf.writeUInt8(name.length, 13); + name.copy(buf, 14); + sock.send(buf, 0, buf.length, DISCOVERY_PORT, MULTICAST_GROUP); +} diff --git a/dev/web/node_client.mjs b/dev/web/node_client.mjs new file mode 100644 index 0000000..ea1107b --- /dev/null +++ b/dev/web/node_client.mjs @@ -0,0 +1,135 @@ +/* + * TCP client for video-node. + * Handles frame reassembly, request/response correlation by request_id, + * and reconnection state. + */ + +import net from 'node:net'; +import { EventEmitter } from 'node:events'; +import { + FRAME_HEADER_SIZE, MSG_CONTROL_RESPONSE, + encode_enum_devices, encode_enum_controls, + encode_get_control, encode_set_control, + decode_enum_devices_response, decode_enum_controls_response, + decode_get_control_response, decode_set_control_response, +} from './protocol.mjs'; + +export class Node_Client extends EventEmitter { + constructor() { + super(); + this._socket = null; + this._buf = Buffer.alloc(0); + this._pending = new Map(); /* request_id -> { resolve, reject, decode } */ + this._next_id = 1; + this.connected = false; + this.host = null; + this.port = null; + } + + connect(host, port) { + if (this._socket) { this._socket.destroy(); } + + this.host = host; + this.port = port; + + return new Promise((resolve, reject) => { + const sock = net.createConnection({ host, port }); + + sock.once('connect', () => { + this._socket = sock; + this.connected = true; + this._buf = Buffer.alloc(0); + this.emit('connect'); + resolve(); + }); + + sock.once('error', err => { + if (!this.connected) { reject(err); } + else { this.emit('error', err); } + }); + + sock.on('data', chunk => this._on_data(chunk)); + + sock.on('close', () => { + this.connected = false; + this._socket = null; + /* reject all pending requests */ + for (const { reject: rej } of this._pending.values()) { + rej(new Error('disconnected')); + } + this._pending.clear(); + this.emit('disconnect'); + }); + }); + } + + disconnect() { + if (this._socket) { this._socket.destroy(); } + } + + /* ---------------------------------------------------------------------- */ + + _on_data(chunk) { + this._buf = Buffer.concat([this._buf, chunk]); + while (true) { + if (this._buf.length < FRAME_HEADER_SIZE) { break; } + const msg_type = this._buf.readUInt16BE(0); + const payload_len = this._buf.readUInt32BE(2); + if (this._buf.length < FRAME_HEADER_SIZE + payload_len) { break; } + const payload = this._buf.slice(FRAME_HEADER_SIZE, FRAME_HEADER_SIZE + payload_len); + this._buf = this._buf.slice(FRAME_HEADER_SIZE + payload_len); + this._on_frame(msg_type, payload); + } + } + + _on_frame(msg_type, payload) { + if (msg_type !== MSG_CONTROL_RESPONSE) { return; } + if (payload.length < 4) { return; } + const request_id = payload.readUInt16BE(0); + const entry = this._pending.get(request_id); + if (!entry) { return; } + this._pending.delete(request_id); + try { + entry.resolve(entry.decode(payload)); + } catch (err) { + entry.reject(err); + } + } + + _request(frame, decode_fn) { + if (!this.connected) { return Promise.reject(new Error('not connected')); } + const id = this._alloc_id(); + /* patch request_id into the frame payload (bytes 6-7) */ + frame.writeUInt16BE(id, FRAME_HEADER_SIZE); + return new Promise((resolve, reject) => { + this._pending.set(id, { resolve, reject, decode: decode_fn }); + this._socket.write(frame, err => { + if (err) { this._pending.delete(id); reject(err); } + }); + }); + } + + _alloc_id() { + const id = this._next_id; + this._next_id = (this._next_id % 0xFFFF) + 1; + return id; + } + + /* ---------------------------------------------------------------------- */ + + enum_devices() { + return this._request(encode_enum_devices(0), decode_enum_devices_response); + } + + enum_controls(device_index) { + return this._request(encode_enum_controls(0, device_index), decode_enum_controls_response); + } + + get_control(device_index, control_id) { + return this._request(encode_get_control(0, device_index, control_id), decode_get_control_response); + } + + set_control(device_index, control_id, value) { + return this._request(encode_set_control(0, device_index, control_id, value), decode_set_control_response); + } +} diff --git a/dev/web/package.json b/dev/web/package.json new file mode 100644 index 0000000..b7af4d9 --- /dev/null +++ b/dev/web/package.json @@ -0,0 +1,8 @@ +{ + "name": "video-web", + "version": "1.0.0", + "type": "module", + "dependencies": { + "express": "^5.2.1" + } +} diff --git a/dev/web/protocol.mjs b/dev/web/protocol.mjs new file mode 100644 index 0000000..e6019f8 --- /dev/null +++ b/dev/web/protocol.mjs @@ -0,0 +1,196 @@ +/* + * Binary protocol encoder/decoder for video-node. + * + * Frame layout (TCP): + * [u16 message_type][u32 payload_length][payload...] + * + * CONTROL_REQUEST payload: + * [u16 request_id][u16 command][command-specific...] + * + * CONTROL_RESPONSE payload: + * [u16 request_id][u16 status][command-specific...] + * + * str8 encoding: [u8 length][bytes...] — not NUL-terminated on wire. + */ + +export const FRAME_HEADER_SIZE = 6; +export const MSG_CONTROL_REQUEST = 0x0002; +export const MSG_CONTROL_RESPONSE = 0x0003; + +export const CMD_ENUM_DEVICES = 0x0003; +export const CMD_ENUM_CONTROLS = 0x0004; +export const CMD_GET_CONTROL = 0x0005; +export const CMD_SET_CONTROL = 0x0006; + +export const STATUS_OK = 0x0000; + +/* ------------------------------------------------------------------------- + * Low-level buffer helpers + * ------------------------------------------------------------------------- */ + +function read_str8(buf, offset) { + const len = buf.readUInt8(offset); + const value = buf.toString('utf8', offset + 1, offset + 1 + len); + return { value, size: 1 + len }; +} + +function read_i64(buf, offset) { + /* Returns a JS number — safe for the int32 values V4L2 uses in practice. */ + const hi = buf.readInt32BE(offset); + const lo = buf.readUInt32BE(offset + 4); + return hi * 0x100000000 + lo; +} + +/* ------------------------------------------------------------------------- + * Frame builder + * ------------------------------------------------------------------------- */ + +export function build_frame(msg_type, payload) { + const frame = Buffer.allocUnsafe(FRAME_HEADER_SIZE + payload.length); + frame.writeUInt16BE(msg_type, 0); + frame.writeUInt32BE(payload.length, 2); + payload.copy(frame, FRAME_HEADER_SIZE); + return frame; +} + +/* ------------------------------------------------------------------------- + * Request encoders — return a complete TCP frame Buffer. + * request_id must be provided by the caller (u16). + * ------------------------------------------------------------------------- */ + +export function encode_enum_devices(request_id) { + const p = Buffer.allocUnsafe(4); + p.writeUInt16BE(request_id, 0); + p.writeUInt16BE(CMD_ENUM_DEVICES, 2); + return build_frame(MSG_CONTROL_REQUEST, p); +} + +export function encode_enum_controls(request_id, device_index) { + const p = Buffer.allocUnsafe(6); + p.writeUInt16BE(request_id, 0); + p.writeUInt16BE(CMD_ENUM_CONTROLS, 2); + p.writeUInt16BE(device_index, 4); + return build_frame(MSG_CONTROL_REQUEST, p); +} + +export function encode_get_control(request_id, device_index, control_id) { + const p = Buffer.allocUnsafe(10); + p.writeUInt16BE(request_id, 0); + p.writeUInt16BE(CMD_GET_CONTROL, 2); + p.writeUInt16BE(device_index, 4); + p.writeUInt32BE(control_id, 6); + return build_frame(MSG_CONTROL_REQUEST, p); +} + +export function encode_set_control(request_id, device_index, control_id, value) { + const p = Buffer.allocUnsafe(14); + p.writeUInt16BE(request_id, 0); + p.writeUInt16BE(CMD_SET_CONTROL, 2); + p.writeUInt16BE(device_index, 4); + p.writeUInt16BE(0, 6); + p.writeUInt32BE(control_id, 6); + p.writeInt32BE(value, 10); + return build_frame(MSG_CONTROL_REQUEST, p); +} + +/* ------------------------------------------------------------------------- + * Response decoders — take a payload Buffer (without frame header). + * All throw on malformed data. + * ------------------------------------------------------------------------- */ + +export function decode_response_header(payload) { + return { + request_id: payload.readUInt16BE(0), + status: payload.readUInt16BE(2), + }; +} + +export function decode_enum_devices_response(payload) { + const hdr = decode_response_header(payload); + if (hdr.status !== STATUS_OK) { return { ...hdr, media: [], standalone: [] }; } + + let pos = 4; + const media_count = payload.readUInt16BE(pos); pos += 2; + const media = []; + + for (let i = 0; i < media_count; i++) { + let s; + s = read_str8(payload, pos); pos += s.size; const path = s.value; + s = read_str8(payload, pos); pos += s.size; const driver = s.value; + s = read_str8(payload, pos); pos += s.size; const model = s.value; + s = read_str8(payload, pos); pos += s.size; const bus_info = s.value; + const vcount = payload.readUInt8(pos); pos++; + + const video_nodes = []; + for (let j = 0; j < vcount; j++) { + s = read_str8(payload, pos); pos += s.size; const vpath = s.value; + s = read_str8(payload, pos); pos += s.size; const entity_name = s.value; + const entity_type = payload.readUInt32BE(pos); pos += 4; + const entity_flags = payload.readUInt32BE(pos); pos += 4; + const device_caps = payload.readUInt32BE(pos); pos += 4; + const pad_flags = payload.readUInt8(pos); pos++; + const is_capture = payload.readUInt8(pos); pos++; + video_nodes.push({ path: vpath, entity_name, entity_type, + entity_flags, device_caps, pad_flags, is_capture: !!is_capture }); + } + + media.push({ path, driver, model, bus_info, video_nodes }); + } + + const standalone_count = payload.readUInt16BE(pos); pos += 2; + const standalone = []; + for (let i = 0; i < standalone_count; i++) { + let s; + s = read_str8(payload, pos); pos += s.size; const path = s.value; + s = read_str8(payload, pos); pos += s.size; const name = s.value; + standalone.push({ path, name }); + } + + return { ...hdr, media, standalone }; +} + +export function decode_enum_controls_response(payload) { + const hdr = decode_response_header(payload); + if (hdr.status !== STATUS_OK) { return { ...hdr, controls: [] }; } + + let pos = 4; + const count = payload.readUInt16BE(pos); pos += 2; + const controls = []; + + for (let i = 0; i < count; i++) { + const id = payload.readUInt32BE(pos); pos += 4; + const type = payload.readUInt8(pos); pos++; + const flags = payload.readUInt32BE(pos); pos += 4; + const s = read_str8(payload, pos); pos += s.size; + const name = s.value; + const min = payload.readInt32BE(pos); pos += 4; + const max = payload.readInt32BE(pos); pos += 4; + const step = payload.readInt32BE(pos); pos += 4; + const def = payload.readInt32BE(pos); pos += 4; + const cur = payload.readInt32BE(pos); pos += 4; + const menu_count = payload.readUInt8(pos); pos++; + + const menu_items = []; + for (let j = 0; j < menu_count; j++) { + const midx = payload.readUInt32BE(pos); pos += 4; + const ms = read_str8(payload, pos); pos += ms.size; + const mval = read_i64(payload, pos); pos += 8; + menu_items.push({ index: midx, name: ms.value, int_value: mval }); + } + + controls.push({ id, type, flags, name, min, max, step, + default_val: def, current_val: cur, menu_items }); + } + + return { ...hdr, controls }; +} + +export function decode_get_control_response(payload) { + const hdr = decode_response_header(payload); + const value = (hdr.status === STATUS_OK) ? payload.readInt32BE(4) : 0; + return { ...hdr, value }; +} + +export function decode_set_control_response(payload) { + return decode_response_header(payload); +} diff --git a/dev/web/public/app.mjs b/dev/web/public/app.mjs new file mode 100644 index 0000000..93e73d5 --- /dev/null +++ b/dev/web/public/app.mjs @@ -0,0 +1,503 @@ +/* V4L2 control type codes (must match CTRL_TYPE_* in v4l2_ctrl.h) */ +const CTRL_INTEGER = 1; +const CTRL_BOOLEAN = 2; +const CTRL_MENU = 3; +const CTRL_BUTTON = 4; +const CTRL_INTEGER_MENU = 9; + +/* V4L2 control flags */ +const FLAG_DISABLED = 0x0001; +const FLAG_GRABBED = 0x0002; +const FLAG_READ_ONLY = 0x0004; +const FLAG_INACTIVE = 0x0010; + +/* V4L2_CAP bits (from linux/videodev2.h) */ +const CAP_VIDEO_CAPTURE = 0x00000001; +const CAP_META_CAPTURE = 0x00800000; + +/* ------------------------------------------------------------------------- + * State + * ------------------------------------------------------------------------- */ + +let selected_device_idx = null; +let device_data = null; /* last ENUM_DEVICES result */ + +/* ------------------------------------------------------------------------- + * Utilities + * ------------------------------------------------------------------------- */ + +const $ = id => document.getElementById(id); + +function toast(msg, type = '') { + const el = document.createElement('div'); + el.className = 'toast' + (type ? ` ${type}` : ''); + el.textContent = msg; + $('toast-container').appendChild(el); + setTimeout(() => { + el.classList.add('fading'); + setTimeout(() => el.remove(), 450); + }, 2500); +} + +async function api(method, path, body) { + const opts = { method, headers: { 'Content-Type': 'application/json' } }; + if (body !== undefined) { opts.body = JSON.stringify(body); } + const res = await fetch(path, opts); + const data = await res.json(); + if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); } + return data; +} + +function caps_tags(device_caps) { + const tags = []; + if (device_caps & CAP_VIDEO_CAPTURE) { tags.push('video'); } + if (device_caps & CAP_META_CAPTURE) { tags.push('meta'); } + return tags.join(' '); +} + +/* ------------------------------------------------------------------------- + * Connection UI + * ------------------------------------------------------------------------- */ + +async function refresh_status() { + try { + const s = await api('GET', '/api/status'); + const dot = $('status-dot'); + const text = $('status-text'); + if (s.connected) { + dot.className = 'ok'; + text.textContent = `${s.host}:${s.port}`; + $('btn-disconnect').style.display = ''; + $('btn-connect').style.display = 'none'; + await refresh_devices(); + } else { + dot.className = ''; + text.textContent = 'disconnected'; + $('btn-disconnect').style.display = 'none'; + $('btn-connect').style.display = ''; + show_empty_devices(); + } + } catch (err) { + console.error(err); + } +} + +$('btn-connect').addEventListener('click', async () => { + const host = $('inp-host').value.trim(); + const port = $('inp-port').value.trim(); + if (!host || !port) { toast('enter host and port', 'err'); return; } + try { + await api('POST', '/api/connect', { host, port: parseInt(port) }); + toast('connected', 'ok'); + await refresh_status(); + } catch (err) { + toast(err.message, 'err'); + } +}); + +let discovery_es = null; + +$('btn-discover').addEventListener('click', () => { + if (discovery_es) { return; } /* already open */ + show_peer_picker(); +}); + +async function connect_to_peer(peer) { + await api('POST', '/api/connect', { host: peer.addr, port: peer.tcp_port }); + $('inp-host').value = peer.addr; + $('inp-port').value = peer.tcp_port; + toast(`connected to ${peer.name}`, 'ok'); +} + +function show_peer_picker() { + /* -- overlay ----------------------------------------------------------- */ + const overlay = document.createElement('div'); + overlay.style.cssText = ` + position:fixed; inset:0; background:rgba(0,0,0,0.6); + display:flex; align-items:center; justify-content:center; z-index:200; + `; + + const box = document.createElement('div'); + box.style.cssText = ` + background:var(--surface); border:1px solid var(--border); + border-radius:8px; padding:20px; min-width:320px; max-width:480px; + `; + + const header = document.createElement('div'); + header.style.cssText = 'display:flex; align-items:center; gap:10px; margin-bottom:14px;'; + + const title = document.createElement('div'); + title.style.cssText = 'font-weight:600; flex:1;'; + title.textContent = 'Discovering nodes…'; + + const spinner = document.createElement('span'); + spinner.textContent = '⟳'; + spinner.style.cssText = 'animation:spin 1s linear infinite; display:inline-block; font-size:16px;'; + + const cancel_btn = document.createElement('button'); + cancel_btn.textContent = 'Close'; + + header.appendChild(title); + header.appendChild(spinner); + header.appendChild(cancel_btn); + box.appendChild(header); + + const peer_list = document.createElement('div'); + peer_list.style.cssText = 'display:flex; flex-direction:column; gap:6px; min-height:40px;'; + + const hint = document.createElement('div'); + hint.style.cssText = 'color:var(--text-dim); font-size:12px; padding:8px 0;'; + hint.textContent = 'Waiting for announcements…'; + peer_list.appendChild(hint); + + box.appendChild(peer_list); + overlay.appendChild(box); + document.body.appendChild(overlay); + + /* add spin keyframe once */ + if (!document.getElementById('spin-style')) { + const s = document.createElement('style'); + s.id = 'spin-style'; + s.textContent = '@keyframes spin { to { transform:rotate(360deg); } }'; + document.head.appendChild(s); + } + + /* -- SSE --------------------------------------------------------------- */ + const es = new EventSource('/api/discover'); + discovery_es = es; + + es.onmessage = e => { + const peer = JSON.parse(e.data); + hint.remove(); + title.textContent = 'Select a node:'; + + const btn = document.createElement('button'); + btn.style.cssText = 'display:flex; width:100%; text-align:left; padding:8px 12px; gap:12px; align-items:baseline;'; + btn.innerHTML = ` + ${peer.name} + + ${peer.addr}:${peer.tcp_port} + `; + btn.addEventListener('click', async () => { + close_picker(); + try { + await connect_to_peer(peer); + await refresh_status(); + } catch (err) { + toast(err.message, 'err'); + } + }); + peer_list.appendChild(btn); + }; + + es.onerror = () => { + title.textContent = 'Discovery error'; + spinner.style.display = 'none'; + }; + + /* -- close ------------------------------------------------------------- */ + function close_picker() { + es.close(); + discovery_es = null; + overlay.remove(); + } + + cancel_btn.addEventListener('click', close_picker); + overlay.addEventListener('click', e => { if (e.target === overlay) { close_picker(); } }); +} + +$('btn-disconnect').addEventListener('click', async () => { + await api('POST', '/api/disconnect'); + selected_device_idx = null; + device_data = null; + show_empty_devices(); + show_empty_controls(); + await refresh_status(); +}); + +$('btn-refresh-devices').addEventListener('click', refresh_devices); + +/* ------------------------------------------------------------------------- + * Device list + * ------------------------------------------------------------------------- */ + +function show_empty_devices() { + $('device-list').innerHTML = '
Not connected
'; +} + +function show_empty_controls(msg = 'Select a device') { + $('controls-title').textContent = 'Controls'; + $('controls-scroll').innerHTML = `
${msg}
`; +} + +async function refresh_devices() { + try { + device_data = await api('GET', '/api/devices'); + render_device_list(); + } catch (err) { + toast(err.message, 'err'); + } +} + +function render_device_list() { + const list = $('device-list'); + list.innerHTML = ''; + + if (!device_data) { return; } + + let flat_idx = 0; + + const { media, standalone } = device_data; + + if (media.length > 0) { + for (const md of media) { + const group = document.createElement('div'); + group.className = 'device-group'; + + const gh = document.createElement('div'); + gh.className = 'device-group-header'; + gh.textContent = `${md.model} (${md.driver})`; + group.appendChild(gh); + + for (const vn of md.video_nodes) { + const idx = flat_idx++; + group.appendChild(make_device_item(idx, vn.path, + vn.entity_name, vn.device_caps, vn.is_capture)); + } + list.appendChild(group); + } + } + + if (standalone.length > 0) { + const group = document.createElement('div'); + group.className = 'device-group'; + const gh = document.createElement('div'); + gh.className = 'device-group-header'; + gh.textContent = 'Standalone'; + group.appendChild(gh); + for (const sd of standalone) { + const idx = flat_idx++; + group.appendChild(make_device_item(idx, sd.path, sd.name, 0, false)); + } + list.appendChild(group); + } + + if (flat_idx === 0) { + list.innerHTML = '
No devices found
'; + } + + /* re-select previously selected device */ + if (selected_device_idx !== null) { + const item = list.querySelector(`[data-idx="${selected_device_idx}"]`); + if (item) { item.classList.add('selected'); } + } +} + +function make_device_item(idx, path, label, device_caps, is_capture) { + const el = document.createElement('div'); + el.className = 'device-item'; + el.dataset.idx = idx; + + const caps_text = caps_tags(device_caps); + const badge = is_capture ? 'capture' : ''; + + el.innerHTML = ` +
${path}${badge}
+
${label}
+ ${caps_text ? `
${caps_text}
` : ''} + `; + + el.addEventListener('click', () => select_device(idx, path)); + return el; +} + +async function select_device(idx, path) { + selected_device_idx = idx; + document.querySelectorAll('.device-item').forEach(el => { + el.classList.toggle('selected', parseInt(el.dataset.idx) === idx); + }); + await load_controls(idx, path); +} + +/* ------------------------------------------------------------------------- + * Controls + * ------------------------------------------------------------------------- */ + +async function load_controls(device_idx, device_path) { + $('controls-title').textContent = `Controls — ${device_path}`; + $('controls-scroll').innerHTML = '
Loading…
'; + try { + const result = await api('GET', `/api/devices/${device_idx}/controls`); + render_controls(device_idx, result.controls ?? []); + } catch (err) { + toast(err.message, 'err'); + show_empty_controls('Failed to load controls'); + } +} + +/* + * V4L2 control IDs with their class prefix (from linux/v4l2-controls.h). + * Used for grouping controls into sections. + */ +const CTRL_CLASSES = [ + { base: 0x00980000, name: 'User' }, + { base: 0x009a0000, name: 'Camera' }, + { base: 0x009b0000, name: 'Flash' }, + { base: 0x009c0000, name: 'JPEG' }, + { base: 0x009e0000, name: 'Image Source' }, + { base: 0x009f0000, name: 'Image Proc' }, + { base: 0x00a20000, name: 'Codec' }, +]; + +function ctrl_class_name(id) { + for (const c of CTRL_CLASSES) { + if ((id & 0xFFFF0000) === c.base) { return c.name; } + } + return 'Other'; +} + +function render_controls(device_idx, controls) { + const scroll = $('controls-scroll'); + scroll.innerHTML = ''; + + if (controls.length === 0) { + scroll.innerHTML = '
No controls
'; + return; + } + + const groups = new Map(); + for (const ctrl of controls) { + const cls = ctrl_class_name(ctrl.id); + if (!groups.has(cls)) { groups.set(cls, []); } + groups.get(cls).push(ctrl); + } + + for (const [cls_name, ctrls] of groups) { + const group_el = document.createElement('div'); + group_el.className = 'ctrl-group'; + + const title = document.createElement('div'); + title.className = 'ctrl-group-title'; + title.textContent = cls_name; + group_el.appendChild(title); + + for (const ctrl of ctrls) { + const row = make_ctrl_row(device_idx, ctrl); + if (row) { group_el.appendChild(row); } + } + scroll.appendChild(group_el); + } +} + +function make_ctrl_row(device_idx, ctrl) { + const disabled = !!(ctrl.flags & FLAG_DISABLED); + const read_only = !!(ctrl.flags & (FLAG_READ_ONLY | FLAG_GRABBED)); + + const row = document.createElement('div'); + row.className = 'ctrl-row' + + (disabled ? ' disabled' : '') + + (read_only ? ' readonly' : ''); + + const label_el = document.createElement('div'); + label_el.className = 'ctrl-label'; + label_el.textContent = ctrl.name; + row.appendChild(label_el); + + const input_wrap = document.createElement('div'); + input_wrap.className = 'ctrl-input'; + + const value_el = document.createElement('div'); + value_el.className = 'ctrl-value-display'; + + let input_el = null; + let get_value = null; + + switch (ctrl.type) { + case CTRL_BOOLEAN: { + input_el = document.createElement('input'); + input_el.type = 'checkbox'; + input_el.checked = !!ctrl.current_val; + get_value = () => input_el.checked ? 1 : 0; + value_el.textContent = ''; + break; + } + case CTRL_MENU: + case CTRL_INTEGER_MENU: { + input_el = document.createElement('select'); + for (const item of ctrl.menu_items) { + const opt = document.createElement('option'); + opt.value = item.index; + opt.textContent = ctrl.type === CTRL_INTEGER_MENU + ? item.int_value.toString() + : item.name; + if (item.index === ctrl.current_val) { opt.selected = true; } + input_el.appendChild(opt); + } + get_value = () => parseInt(input_el.value); + value_el.textContent = ''; + break; + } + case CTRL_BUTTON: { + input_el = document.createElement('button'); + input_el.textContent = ctrl.name; + input_el.style.width = '100%'; + get_value = () => 1; + value_el.textContent = ''; + break; + } + case CTRL_INTEGER: + default: { + const range = document.createElement('input'); + range.type = 'range'; + range.min = ctrl.min; + range.max = ctrl.max; + range.step = ctrl.step || 1; + range.value = ctrl.current_val; + value_el.textContent = ctrl.current_val; + range.addEventListener('input', () => { + value_el.textContent = range.value; + }); + input_el = range; + get_value = () => parseInt(range.value); + break; + } + } + + if (!input_el) { return null; } + + input_wrap.appendChild(input_el); + row.appendChild(input_wrap); + row.appendChild(value_el); + + /* send SET_CONTROL on change/click */ + if (!read_only && !disabled) { + const send = async () => { + try { + const id_hex = ctrl.id.toString(16).padStart(8, '0'); + await api('POST', `/api/devices/${device_idx}/controls/${id_hex}`, + { value: get_value() }); + } catch (err) { + toast(`${ctrl.name}: ${err.message}`, 'err'); + } + }; + + if (ctrl.type === CTRL_BUTTON) { + input_el.addEventListener('click', send); + } else if (ctrl.type === CTRL_BOOLEAN || ctrl.type === CTRL_MENU + || ctrl.type === CTRL_INTEGER_MENU) { + input_el.addEventListener('change', send); + } else { + /* slider: send on pointerup so we don't flood the node */ + input_el.addEventListener('pointerup', send); + input_el.addEventListener('keyup', send); + } + } + + return row; +} + +/* ------------------------------------------------------------------------- + * Init + * ------------------------------------------------------------------------- */ + +refresh_status(); diff --git a/dev/web/public/index.html b/dev/web/public/index.html new file mode 100644 index 0000000..f1e0065 --- /dev/null +++ b/dev/web/public/index.html @@ -0,0 +1,328 @@ + + + + + + Video Node Inspector + + + + +
+
+

Video Node Inspector

+ disconnected + + +
+ +
+ + + + + +
+ +
+
+

+ Devices + +

+
+
Not connected
+
+
+
+

Controls

+
+
Select a device
+
+
+
+ +
+ + + + diff --git a/dev/web/server.mjs b/dev/web/server.mjs new file mode 100644 index 0000000..94afd05 --- /dev/null +++ b/dev/web/server.mjs @@ -0,0 +1,174 @@ +/* + * Express 5 web server — REST bridge to video-node. + * + * Usage: + * node server.mjs [--host IP] [--port PORT] [--discover] [--listen PORT] + * + * If --host and --port are given, connects on startup. + * If --discover is given, runs UDP discovery to find the node automatically. + * Otherwise, call POST /api/connect to connect at runtime. + */ + +import express from 'express'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { Node_Client } from './node_client.mjs'; +import { start_discovery } from './discovery.mjs'; + +/* ------------------------------------------------------------------------- + * Argument parsing + * ------------------------------------------------------------------------- */ + +const args = process.argv.slice(2); +const arg = name => { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : null; +}; + +const opt_host = arg('--host'); +const opt_port = arg('--port') ? parseInt(arg('--port')) : null; +const opt_discover = args.includes('--discover'); +const listen_port = arg('--listen') ? parseInt(arg('--listen')) : 3000; + +/* ------------------------------------------------------------------------- + * Node client (singleton) + * ------------------------------------------------------------------------- */ + +const client = new Node_Client(); + +client.on('disconnect', () => { + console.log('video-node disconnected'); +}); + +/* ------------------------------------------------------------------------- + * Express app + * ------------------------------------------------------------------------- */ + +const app = express(); +app.use(express.json()); + +const __dir = path.dirname(fileURLToPath(import.meta.url)); +app.use(express.static(path.join(__dir, 'public'))); + +/* -- Status ---------------------------------------------------------------- */ + +app.get('/api/status', (_req, res) => { + res.json({ + connected: client.connected, + host: client.host, + port: client.port, + }); +}); + +/* -- Connect --------------------------------------------------------------- */ + +app.post('/api/connect', async (req, res) => { + const { host, port } = req.body ?? {}; + if (!host || !port) { + return res.status(400).json({ error: 'host and port required' }); + } + try { + await client.connect(host, parseInt(port)); + res.json({ ok: true, host: client.host, port: client.port }); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/* + * SSE stream: each discovered peer arrives as a JSON event. + * The client closes the connection when done (user picked a node or dismissed). + */ +app.get('/api/discover', (req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const stop = start_discovery(peer => { + res.write(`data: ${JSON.stringify(peer)}\n\n`); + }); + + req.on('close', stop); +}); + +app.post('/api/disconnect', (_req, res) => { + client.disconnect(); + res.json({ ok: true }); +}); + +/* -- Devices --------------------------------------------------------------- */ + +app.get('/api/devices', async (_req, res) => { + try { + const result = await client.enum_devices(); + res.json(result); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/* -- Controls -------------------------------------------------------------- */ + +app.get('/api/devices/:idx/controls', async (req, res) => { + const idx = parseInt(req.params.idx); + if (isNaN(idx)) { return res.status(400).json({ error: 'invalid device index' }); } + try { + const result = await client.enum_controls(idx); + res.json(result); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +app.get('/api/devices/:idx/controls/:ctrl_id', async (req, res) => { + const idx = parseInt(req.params.idx); + const ctrl_id = parseInt(req.params.ctrl_id, 16) || parseInt(req.params.ctrl_id); + if (isNaN(idx) || isNaN(ctrl_id)) { + return res.status(400).json({ error: 'invalid params' }); + } + try { + const result = await client.get_control(idx, ctrl_id); + res.json(result); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +app.post('/api/devices/:idx/controls/:ctrl_id', async (req, res) => { + const idx = parseInt(req.params.idx); + const ctrl_id = parseInt(req.params.ctrl_id, 16) || parseInt(req.params.ctrl_id); + const value = req.body?.value; + if (isNaN(idx) || isNaN(ctrl_id) || value === undefined) { + return res.status(400).json({ error: 'invalid params' }); + } + try { + const result = await client.set_control(idx, ctrl_id, parseInt(value)); + res.json(result); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +/* ------------------------------------------------------------------------- + * Startup + * ------------------------------------------------------------------------- */ + +app.listen(listen_port, () => { + console.log(`listening on http://localhost:${listen_port}`); +}); + +if (opt_discover) { + console.log('discovering video-nodes...'); + const stop = start_discovery(peer => { + console.log(`found ${peer.name} at ${peer.addr}:${peer.tcp_port} — connecting`); + stop(); + client.connect(peer.addr, peer.tcp_port) + .then(() => console.log('connected')) + .catch(err => console.error('connect failed:', err.message)); + }); +} else if (opt_host && opt_port) { + client.connect(opt_host, opt_port) + .then(() => console.log(`connected to ${opt_host}:${opt_port}`)) + .catch(err => console.error('connect failed:', err.message)); +} diff --git a/include/error.h b/include/error.h index 097e79a..e14006a 100644 --- a/include/error.h +++ b/include/error.h @@ -14,8 +14,8 @@ struct Syscall_Error_Detail { }; struct Invalid_Error_Detail { - /* fields added as concrete cases arise */ - int placeholder; + int config_line; /* source line number, or 0 if not applicable */ + const char *message; /* static string describing what was wrong */ }; struct App_Error { @@ -51,6 +51,14 @@ void app_error_print(struct App_Error *e); .line = __LINE__, \ }) +#define APP_INVALID_ERROR_MSG(cfg_line, msg) \ + ((struct App_Error){ \ + .code = ERR_INVALID, \ + .file = __FILE__, \ + .line = __LINE__, \ + .detail = { .invalid = { .config_line = (cfg_line), .message = (msg) } }, \ + }) + #define APP_NOT_FOUND_ERROR() \ ((struct App_Error){ \ .code = ERR_NOT_FOUND, \ diff --git a/include/protocol.h b/include/protocol.h new file mode 100644 index 0000000..7855cbf --- /dev/null +++ b/include/protocol.h @@ -0,0 +1,404 @@ +#pragma once + +#include +#include "error.h" +#include "transport.h" + +/* ------------------------------------------------------------------------- + * Message type constants + * ------------------------------------------------------------------------- */ + +#define PROTO_MSG_VIDEO_FRAME 0x0001u +#define PROTO_MSG_CONTROL_REQUEST 0x0002u +#define PROTO_MSG_CONTROL_RESPONSE 0x0003u +#define PROTO_MSG_STREAM_EVENT 0x0004u + +/* ------------------------------------------------------------------------- + * Command codes (carried in CONTROL_REQUEST payload offset 2) + * ------------------------------------------------------------------------- */ + +#define PROTO_CMD_STREAM_OPEN 0x0001u +#define PROTO_CMD_STREAM_CLOSE 0x0002u +#define PROTO_CMD_ENUM_DEVICES 0x0003u +#define PROTO_CMD_ENUM_CONTROLS 0x0004u +#define PROTO_CMD_GET_CONTROL 0x0005u +#define PROTO_CMD_SET_CONTROL 0x0006u +#define PROTO_CMD_ENUM_MONITORS 0x0007u + +/* ------------------------------------------------------------------------- + * Response status codes (carried in CONTROL_RESPONSE payload offset 2) + * ------------------------------------------------------------------------- */ + +#define PROTO_STATUS_OK 0x0000u +#define PROTO_STATUS_ERROR 0x0001u +#define PROTO_STATUS_UNKNOWN_CMD 0x0002u +#define PROTO_STATUS_INVALID_PARAMS 0x0003u +#define PROTO_STATUS_NOT_FOUND 0x0004u + +/* ------------------------------------------------------------------------- + * Stream event codes (carried in STREAM_EVENT payload offset 2) + * ------------------------------------------------------------------------- */ + +#define PROTO_EVENT_INTERRUPTED 0x01u +#define PROTO_EVENT_RESUMED 0x02u + +/* ------------------------------------------------------------------------- + * Codec format codes (STREAM_OPEN format field) + * ------------------------------------------------------------------------- */ + +#define PROTO_FORMAT_MJPEG 0x0001u +#define PROTO_FORMAT_H264 0x0002u +#define PROTO_FORMAT_H265 0x0003u +#define PROTO_FORMAT_AV1 0x0004u +#define PROTO_FORMAT_FFV1 0x0005u +#define PROTO_FORMAT_PRORES 0x0006u +#define PROTO_FORMAT_QOI 0x0007u +#define PROTO_FORMAT_RAW 0x0008u +#define PROTO_FORMAT_RAW_ZSTD 0x0009u + +/* ------------------------------------------------------------------------- + * Pixel format codes (STREAM_OPEN pixel_format field; 0 for compressed) + * ------------------------------------------------------------------------- */ + +#define PROTO_PIXEL_BGRA8888 0x0001u +#define PROTO_PIXEL_RGBA8888 0x0002u +#define PROTO_PIXEL_BGR888 0x0003u +#define PROTO_PIXEL_YUV420P 0x0004u +#define PROTO_PIXEL_YUV422 0x0005u + +/* ------------------------------------------------------------------------- + * Origin codes (STREAM_OPEN origin field; informational only) + * ------------------------------------------------------------------------- */ + +#define PROTO_ORIGIN_DEVICE_NATIVE 0x0001u +#define PROTO_ORIGIN_LIBJPEG_TURBO 0x0002u +#define PROTO_ORIGIN_FFMPEG_LIBAV 0x0003u +#define PROTO_ORIGIN_FFMPEG_PROC 0x0004u +#define PROTO_ORIGIN_VAAPI 0x0005u +#define PROTO_ORIGIN_NVENC 0x0006u +#define PROTO_ORIGIN_SOFTWARE 0x0007u + +/* ------------------------------------------------------------------------- + * Structs used by write functions (variable-length response payloads) + * ------------------------------------------------------------------------- */ + +struct Proto_Menu_Item { + uint32_t index; + const char *name; + int64_t int_value; +}; + +struct Proto_Control_Info { + uint32_t id; + uint8_t type; + uint32_t flags; + const char *name; + int32_t min, max, step, default_val, current_val; + uint8_t menu_count; + const struct Proto_Menu_Item *menu_items; +}; + +/* + * A video node associated with a media controller device. + * entity_type and entity_flags are MEDIA_ENT_F_* / MEDIA_ENT_FL_* values. + * pad_flags uses MEDIA_PAD_FLAG_SOURCE / MEDIA_PAD_FLAG_SINK. + * is_capture: 1 if this node is the primary video capture output. + */ +struct Proto_Video_Node_Info { + const char *path; + const char *entity_name; + uint32_t entity_type; + uint32_t entity_flags; + uint32_t device_caps; /* V4L2_CAP_* bits from VIDIOC_QUERYCAP */ + uint8_t pad_flags; + uint8_t is_capture; +}; + +/* + * A media controller device and its associated video nodes. + * video_node_count must be <= 255. + */ +struct Proto_Media_Device_Info { + const char *path; + const char *driver; + const char *model; + const char *bus_info; + uint8_t video_node_count; + const struct Proto_Video_Node_Info *video_nodes; +}; + +/* + * A standalone V4L2 device with no associated media controller. + * name is the card name from VIDIOC_QUERYCAP. + */ +struct Proto_Standalone_Device_Info { + const char *path; + const char *name; +}; + +struct Proto_Monitor_Info { + int32_t x, y; + uint32_t width, height; + const char *name; +}; + +/* ------------------------------------------------------------------------- + * Structs used by read functions + * Fields with pointer types point INTO the caller's payload buffer. + * The caller must keep the payload alive while using those pointers. + * Strings are NOT NUL-terminated. + * ------------------------------------------------------------------------- */ + +struct Proto_Video_Frame { + uint16_t stream_id; + const uint8_t *data; + uint32_t data_len; +}; + +struct Proto_Stream_Event { + uint16_t stream_id; + uint8_t event_code; +}; + +struct Proto_Request_Header { + uint16_t request_id; + uint16_t command; +}; + +struct Proto_Stream_Open { + uint16_t request_id; + uint16_t stream_id; + uint16_t format; + uint16_t pixel_format; + uint16_t origin; +}; + +struct Proto_Stream_Close { + uint16_t request_id; + uint16_t stream_id; +}; + +struct Proto_Enum_Controls_Req { + uint16_t request_id; + uint16_t device_index; +}; + +struct Proto_Get_Control_Req { + uint16_t request_id; + uint16_t device_index; + uint32_t control_id; +}; + +struct Proto_Set_Control_Req { + uint16_t request_id; + uint16_t device_index; + uint32_t control_id; + int32_t value; +}; + +struct Proto_Response_Header { + uint16_t request_id; + uint16_t status; +}; + +struct Proto_Get_Control_Resp { + uint16_t request_id; + uint16_t status; + int32_t value; +}; + +/* ------------------------------------------------------------------------- + * Write functions — serialize and send via transport_send_frame. + * All return APP_OK or an error from the transport layer. + * ------------------------------------------------------------------------- */ + +/* + * VIDEO_FRAME: prepends stream_id (2 bytes) to data and sends. + * data/data_len is the compressed frame; the stream must already be open. + */ +struct App_Error proto_write_video_frame(struct Transport_Conn *conn, + uint16_t stream_id, const uint8_t *data, uint32_t data_len); + +/* STREAM_EVENT (3 bytes) */ +struct App_Error proto_write_stream_event(struct Transport_Conn *conn, + uint16_t stream_id, uint8_t event_code); + +/* CONTROL_REQUEST: STREAM_OPEN (12 bytes) */ +struct App_Error proto_write_stream_open(struct Transport_Conn *conn, + uint16_t request_id, uint16_t stream_id, + uint16_t format, uint16_t pixel_format, uint16_t origin); + +/* CONTROL_REQUEST: STREAM_CLOSE (6 bytes) */ +struct App_Error proto_write_stream_close(struct Transport_Conn *conn, + uint16_t request_id, uint16_t stream_id); + +/* CONTROL_REQUEST: ENUM_DEVICES (4 bytes, no extra fields) */ +struct App_Error proto_write_enum_devices(struct Transport_Conn *conn, + uint16_t request_id); + +/* CONTROL_REQUEST: ENUM_CONTROLS (6 bytes) */ +struct App_Error proto_write_enum_controls(struct Transport_Conn *conn, + uint16_t request_id, uint16_t device_index); + +/* CONTROL_REQUEST: GET_CONTROL (10 bytes) */ +struct App_Error proto_write_get_control(struct Transport_Conn *conn, + uint16_t request_id, uint16_t device_index, uint32_t control_id); + +/* CONTROL_REQUEST: SET_CONTROL (14 bytes) */ +struct App_Error proto_write_set_control(struct Transport_Conn *conn, + uint16_t request_id, uint16_t device_index, + uint32_t control_id, int32_t value); + +/* CONTROL_REQUEST: ENUM_MONITORS (4 bytes, no extra fields) */ +struct App_Error proto_write_enum_monitors(struct Transport_Conn *conn, + uint16_t request_id); + +/* + * CONTROL_RESPONSE: generic. + * payload/payload_len are the command-specific bytes after request_id+status. + * Pass payload=NULL, payload_len=0 for responses with no extra fields + * (e.g. STREAM_OPEN ok, STREAM_CLOSE, SET_CONTROL). + */ +struct App_Error proto_write_control_response(struct Transport_Conn *conn, + uint16_t request_id, uint16_t status, + const uint8_t *payload, uint32_t payload_len); + +/* CONTROL_RESPONSE: ENUM_DEVICES */ +struct App_Error proto_write_enum_devices_response(struct Transport_Conn *conn, + uint16_t request_id, uint16_t status, + const struct Proto_Media_Device_Info *media_devices, uint16_t media_count, + const struct Proto_Standalone_Device_Info *standalone, uint16_t standalone_count); + +/* CONTROL_RESPONSE: ENUM_CONTROLS */ +struct App_Error proto_write_enum_controls_response(struct Transport_Conn *conn, + uint16_t request_id, uint16_t status, + const struct Proto_Control_Info *controls, uint16_t count); + +/* CONTROL_RESPONSE: GET_CONTROL */ +struct App_Error proto_write_get_control_response(struct Transport_Conn *conn, + uint16_t request_id, uint16_t status, int32_t value); + +/* CONTROL_RESPONSE: ENUM_MONITORS */ +struct App_Error proto_write_enum_monitors_response(struct Transport_Conn *conn, + uint16_t request_id, uint16_t status, + const struct Proto_Monitor_Info *monitors, uint16_t count); + +/* ------------------------------------------------------------------------- + * Read functions — parse raw payload bytes into typed structs. + * All return APP_INVALID_ERROR_MSG(0, ...) on malformed payloads. + * ------------------------------------------------------------------------- */ + +struct App_Error proto_read_video_frame( + const uint8_t *payload, uint32_t length, + struct Proto_Video_Frame *out); + +struct App_Error proto_read_stream_event( + const uint8_t *payload, uint32_t length, + struct Proto_Stream_Event *out); + +/* + * Read the common 4-byte request header (request_id + command). + * Dispatch on header.command, then call the appropriate specific reader. + * ENUM_DEVICES and ENUM_MONITORS have no extra fields beyond the header. + */ +struct App_Error proto_read_request_header( + const uint8_t *payload, uint32_t length, + struct Proto_Request_Header *out); + +struct App_Error proto_read_stream_open( + const uint8_t *payload, uint32_t length, + struct Proto_Stream_Open *out); + +struct App_Error proto_read_stream_close( + const uint8_t *payload, uint32_t length, + struct Proto_Stream_Close *out); + +struct App_Error proto_read_enum_controls_req( + const uint8_t *payload, uint32_t length, + struct Proto_Enum_Controls_Req *out); + +struct App_Error proto_read_get_control_req( + const uint8_t *payload, uint32_t length, + struct Proto_Get_Control_Req *out); + +struct App_Error proto_read_set_control_req( + const uint8_t *payload, uint32_t length, + struct Proto_Set_Control_Req *out); + +/* + * Read the common 4-byte response header (request_id + status). + * For responses with no extra fields (STREAM_OPEN, STREAM_CLOSE, SET_CONTROL), + * this is the complete parse. + */ +struct App_Error proto_read_response_header( + const uint8_t *payload, uint32_t length, + struct Proto_Response_Header *out); + +struct App_Error proto_read_get_control_response( + const uint8_t *payload, uint32_t length, + struct Proto_Get_Control_Resp *out); + +/* + * Variable-length response readers use callbacks to avoid heap allocation. + * Strings point into the payload and are NOT NUL-terminated; use *_len. + */ +/* + * on_media_device is called once per media controller device. + * on_video_node is called video_node_count times immediately after, + * once per video node belonging to that media device. + * on_standalone is called once per V4L2 device with no media controller. + * Any callback may be NULL. + */ +struct App_Error proto_read_enum_devices_response( + const uint8_t *payload, uint32_t length, + struct Proto_Response_Header *header_out, + void (*on_media_device)( + const char *path, uint8_t path_len, + const char *driver, uint8_t driver_len, + const char *model, uint8_t model_len, + const char *bus_info, uint8_t bus_info_len, + uint8_t video_node_count, + void *userdata), + void (*on_video_node)( + const char *path, uint8_t path_len, + const char *entity_name, uint8_t entity_name_len, + uint32_t entity_type, uint32_t entity_flags, + uint32_t device_caps, + uint8_t pad_flags, uint8_t is_capture, + void *userdata), + void (*on_standalone)( + const char *path, uint8_t path_len, + const char *name, uint8_t name_len, + void *userdata), + void *userdata); + +/* + * on_control is called once per control. + * on_menu_item is called once per menu item immediately after its on_control + * call; menu_count in on_control says how many to expect. + * on_menu_item may be NULL if the caller does not need menu items. + */ +struct App_Error proto_read_enum_controls_response( + const uint8_t *payload, uint32_t length, + struct Proto_Response_Header *header_out, + void (*on_control)( + uint32_t id, uint8_t type, uint32_t flags, + const char *name, uint8_t name_len, + int32_t min, int32_t max, int32_t step, + int32_t default_val, int32_t current_val, + uint8_t menu_count, void *userdata), + void (*on_menu_item)( + uint32_t index, + const char *name, uint8_t name_len, + int64_t int_value, + void *userdata), + void *userdata); + +struct App_Error proto_read_enum_monitors_response( + const uint8_t *payload, uint32_t length, + struct Proto_Response_Header *header_out, + void (*on_monitor)( + int32_t x, int32_t y, uint32_t width, uint32_t height, + const char *name, uint8_t name_len, + void *userdata), + void *userdata); diff --git a/planning.md b/planning.md index a6ebd2e..08d30e9 100644 --- a/planning.md +++ b/planning.md @@ -48,7 +48,8 @@ Modules are listed in intended build order. Each depends only on modules above i | 4 | `serial` | done | `put`/`get` primitives for little-endian binary serialization into byte buffers | | 5 | `transport` | done | Encapsulated transport — frame header, TCP stream abstraction, single-write send | | 6 | `discovery` | done | UDP multicast announcements, peer table, found/lost callbacks | -| 8 | `protocol` | not started | Typed `write_*`/`read_*` functions for all message types; builds on serial + transport | +| 8 | `protocol` | done | Typed `write_*`/`read_*` functions for all message types; builds on serial + transport | +| — | `node` | done | Video node binary — config, discovery, transport server, V4L2/media control request handlers | | 9 | `frame_alloc` | not started | Per-frame allocation with bookkeeping (byte budget, ref counting) | | 10 | `relay` | not started | Input dispatch to output queues (low-latency and completeness modes) | | 11 | `ingest` | not started | V4L2 capture loop — dequeue buffers, emit one encapsulated frame per buffer | diff --git a/src/modules/common/Makefile b/src/modules/common/Makefile index a44b75d..8aa50d7 100644 --- a/src/modules/common/Makefile +++ b/src/modules/common/Makefile @@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/common all: $(MODULE_BUILD)/error.o -$(MODULE_BUILD)/error.o: error.c $(ROOT)/include/error.h | $(MODULE_BUILD) - $(CC) $(CFLAGS) -c -o $@ $< +$(MODULE_BUILD)/error.o: error.c | $(MODULE_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< $(MODULE_BUILD): mkdir -p $@ clean: - rm -f $(MODULE_BUILD)/error.o + rm -f $(MODULE_BUILD)/error.o $(MODULE_BUILD)/error.d + +-include $(MODULE_BUILD)/error.d diff --git a/src/modules/common/error.c b/src/modules/common/error.c index 3927684..be7401e 100644 --- a/src/modules/common/error.c +++ b/src/modules/common/error.c @@ -17,7 +17,15 @@ void app_error_print(struct App_Error *e) { fprintf(stderr, "syscall error: %s\n", strerror(e->detail.syscall.err_no)); break; case ERR_INVALID: - fprintf(stderr, "invalid argument\n"); + if (e->detail.invalid.config_line > 0) { + fprintf(stderr, "config error at line %d: %s\n", + e->detail.invalid.config_line, + e->detail.invalid.message ? e->detail.invalid.message : "invalid value"); + } else if (e->detail.invalid.message) { + fprintf(stderr, "invalid: %s\n", e->detail.invalid.message); + } else { + fprintf(stderr, "invalid argument\n"); + } break; case ERR_NOT_FOUND: fprintf(stderr, "not found\n"); diff --git a/src/modules/config/Makefile b/src/modules/config/Makefile index 58610ab..8da6090 100644 --- a/src/modules/config/Makefile +++ b/src/modules/config/Makefile @@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/config all: $(MODULE_BUILD)/config.o -$(MODULE_BUILD)/config.o: config.c $(ROOT)/include/config.h | $(MODULE_BUILD) - $(CC) $(CFLAGS) -c -o $@ $< +$(MODULE_BUILD)/config.o: config.c | $(MODULE_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< $(MODULE_BUILD): mkdir -p $@ clean: - rm -f $(MODULE_BUILD)/config.o + rm -f $(MODULE_BUILD)/config.o $(MODULE_BUILD)/config.d + +-include $(MODULE_BUILD)/config.d diff --git a/src/modules/config/config.c b/src/modules/config/config.c index 16eaa2b..d223465 100644 --- a/src/modules/config/config.c +++ b/src/modules/config/config.c @@ -54,7 +54,10 @@ static void normalise_separators(char *s) { /* -- parse typed value ----------------------------------------------------- */ -static uint32_t parse_flags(const char *raw, const struct Config_Flag_Def *defs) { +/* returns -1 on unknown token, otherwise 0 and sets *out */ +static int parse_flags(const char *raw, const struct Config_Flag_Def *defs, + uint32_t *out, char *bad_token, size_t bad_token_len) +{ char buf[MAX_STR]; strncpy(buf, raw, MAX_STR - 1); buf[MAX_STR - 1] = '\0'; @@ -63,16 +66,24 @@ static uint32_t parse_flags(const char *raw, const struct Config_Flag_Def *defs) uint32_t bits = 0; char *tok = strtok(buf, " \t"); while (tok) { + int found = 0; for (const struct Config_Flag_Def *fd = defs; fd && fd->token; fd++) { - if (strcmp(tok, fd->token) == 0) { bits |= fd->value; break; } + if (strcmp(tok, fd->token) == 0) { bits |= fd->value; found = 1; break; } + } + if (!found) { + strncpy(bad_token, tok, bad_token_len - 1); + bad_token[bad_token_len - 1] = '\0'; + return -1; } tok = strtok(NULL, " \t"); } - return bits; + *out = bits; + return 0; } -static void set_entry_value(struct Config_Entry *e, - const struct Config_Def *def, const char *raw) +/* returns APP_OK or an error with config_line set */ +static struct App_Error set_entry_value(struct Config_Entry *e, + const struct Config_Def *def, const char *raw, int cfg_line) { e->type = def->type; switch (def->type) { @@ -80,16 +91,36 @@ static void set_entry_value(struct Config_Entry *e, strncpy(e->val.s, raw, MAX_STR - 1); e->val.s[MAX_STR - 1] = '\0'; break; - case CONFIG_UINT16: - e->val.u16 = (uint16_t)strtoul(raw, NULL, 10); + case CONFIG_UINT16: { + char *end; + unsigned long v = strtoul(raw, &end, 10); + if (end == raw || *end != '\0') { + return APP_INVALID_ERROR_MSG(cfg_line, "expected integer"); + } + if (v > 0xFFFF) { + return APP_INVALID_ERROR_MSG(cfg_line, "value out of range for u16"); + } + e->val.u16 = (uint16_t)v; break; - case CONFIG_UINT32: - e->val.u32 = (uint32_t)strtoul(raw, NULL, 10); + } + case CONFIG_UINT32: { + char *end; + unsigned long v = strtoul(raw, &end, 10); + if (end == raw || *end != '\0') { + return APP_INVALID_ERROR_MSG(cfg_line, "expected integer"); + } + e->val.u32 = (uint32_t)v; break; - case CONFIG_FLAGS: - e->val.flags = parse_flags(raw, def->flags); + } + case CONFIG_FLAGS: { + char bad[MAX_STR]; + if (parse_flags(raw, def->flags, &e->val.flags, bad, sizeof(bad)) != 0) { + return APP_INVALID_ERROR_MSG(cfg_line, "unknown flag token"); + } break; + } } + return APP_OK; } /* -- entry table ----------------------------------------------------------- */ @@ -130,13 +161,15 @@ static struct Config_Entry *add_entry(struct Config *cfg, /* -- fill defaults --------------------------------------------------------- */ -static void fill_defaults(struct Config *cfg) { +static struct App_Error fill_defaults(struct Config *cfg) { for (const struct Config_Def *d = cfg->schema; d->section; d++) { if (find_entry(cfg, d->section, d->key)) { continue; } struct Config_Entry *e = add_entry(cfg, d->section, d->key); if (!e) { continue; } - set_entry_value(e, d, d->default_val ? d->default_val : ""); + struct App_Error err = set_entry_value(e, d, d->default_val ? d->default_val : "", 0); + if (!APP_IS_OK(err)) { return err; } } + return APP_OK; } /* -- file parser ----------------------------------------------------------- */ @@ -147,8 +180,10 @@ static struct App_Error parse_file(struct Config *cfg, const char *path) { char line[MAX_LINE]; char section[MAX_STR] = ""; + int lineno = 0; while (fgets(line, sizeof(line), f)) { + lineno++; strip_comment(line); strip(line); if (line[0] == '\0') { continue; } @@ -178,7 +213,9 @@ static struct App_Error parse_file(struct Config *cfg, const char *path) { struct Config_Entry *e = find_entry(cfg, section, key); if (!e) { e = add_entry(cfg, section, key); } if (!e) { continue; } - set_entry_value(e, def, val); + + struct App_Error err = set_entry_value(e, def, val, lineno); + if (!APP_IS_OK(err)) { fclose(f); return err; } } fclose(f); @@ -197,7 +234,9 @@ struct App_Error config_load(struct Config **out, const char *path, struct App_Error err = parse_file(cfg, path); if (!APP_IS_OK(err)) { free(cfg); return err; } - fill_defaults(cfg); + err = fill_defaults(cfg); + if (!APP_IS_OK(err)) { free(cfg); return err; } + *out = cfg; return APP_OK; } @@ -208,7 +247,10 @@ struct App_Error config_defaults(struct Config **out, struct Config *cfg = calloc(1, sizeof(*cfg)); if (!cfg) { return APP_SYSCALL_ERROR(); } cfg->schema = schema; - fill_defaults(cfg); + + struct App_Error err = fill_defaults(cfg); + if (!APP_IS_OK(err)) { free(cfg); return err; } + *out = cfg; return APP_OK; } diff --git a/src/modules/discovery/Makefile b/src/modules/discovery/Makefile index 3202cf1..6d0fd65 100644 --- a/src/modules/discovery/Makefile +++ b/src/modules/discovery/Makefile @@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/discovery all: $(MODULE_BUILD)/discovery.o -$(MODULE_BUILD)/discovery.o: discovery.c $(ROOT)/include/discovery.h | $(MODULE_BUILD) - $(CC) $(CFLAGS) -c -o $@ $< +$(MODULE_BUILD)/discovery.o: discovery.c | $(MODULE_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< $(MODULE_BUILD): mkdir -p $@ clean: - rm -f $(MODULE_BUILD)/discovery.o + rm -f $(MODULE_BUILD)/discovery.o $(MODULE_BUILD)/discovery.d + +-include $(MODULE_BUILD)/discovery.d diff --git a/src/modules/discovery/discovery.c b/src/modules/discovery/discovery.c index 11ed374..46ccd15 100644 --- a/src/modules/discovery/discovery.c +++ b/src/modules/discovery/discovery.c @@ -40,7 +40,9 @@ struct Discovery { pthread_t announce_thread; pthread_t receive_thread; atomic_int running; - atomic_int early_announce; /* set when a new peer is seen */ + + pthread_mutex_t announce_mutex; + pthread_cond_t announce_cond; /* signaled to wake announce thread early */ pthread_mutex_t peers_mutex; struct Peer_Entry peers[DISCOVERY_MAX_PEERS]; @@ -131,23 +133,26 @@ static void *announce_thread_fn(void *arg) { send_announcement(d); + pthread_mutex_lock(&d->announce_mutex); while (atomic_load(&d->running)) { - /* sleep in 100 ms increments; breaks early if a new peer is detected - * or if destroy is called */ - uint32_t elapsed = 0; - while (atomic_load(&d->running) && elapsed < d->config.interval_ms) { - if (atomic_load(&d->early_announce)) { break; } - struct timespec ts = { .tv_sec = 0, .tv_nsec = 100 * 1000000L }; - nanosleep(&ts, NULL); - elapsed += 100; + struct timespec abs; + clock_gettime(CLOCK_REALTIME, &abs); + uint32_t ms = d->config.interval_ms; + abs.tv_sec += ms / 1000u; + abs.tv_nsec += (long)(ms % 1000u) * 1000000L; + if (abs.tv_nsec >= 1000000000L) { + abs.tv_sec++; + abs.tv_nsec -= 1000000000L; } + /* blocks until signaled (new peer / shutdown) or interval elapses */ + pthread_cond_timedwait(&d->announce_cond, &d->announce_mutex, &abs); if (!atomic_load(&d->running)) { break; } - atomic_store(&d->early_announce, 0); send_announcement(d); check_timeouts(d); } + pthread_mutex_unlock(&d->announce_mutex); return NULL; } @@ -228,11 +233,14 @@ static void *receive_thread_fn(void *arg) { pthread_mutex_unlock(&d->peers_mutex); - if (is_new) { - atomic_store(&d->early_announce, 1); - if (d->config.on_peer_found) { - d->config.on_peer_found(&peer_copy, d->config.userdata); - } + /* respond to every announcement — the sender may be a fresh instance + * that doesn't know about us yet even if we already have it in our table */ + pthread_mutex_lock(&d->announce_mutex); + pthread_cond_signal(&d->announce_cond); + pthread_mutex_unlock(&d->announce_mutex); + + if (is_new && d->config.on_peer_found) { + d->config.on_peer_found(&peer_copy, d->config.userdata); } } @@ -262,7 +270,8 @@ struct App_Error discovery_create(struct Discovery **out, inet_pton(AF_INET, DISCOVERY_MULTICAST_GROUP, &d->mcast_addr.sin_addr); atomic_init(&d->running, 0); - atomic_init(&d->early_announce, 0); + pthread_mutex_init(&d->announce_mutex, NULL); + pthread_cond_init(&d->announce_cond, NULL); pthread_mutex_init(&d->peers_mutex, NULL); *out = d; @@ -326,9 +335,15 @@ struct App_Error discovery_start(struct Discovery *d) { void discovery_destroy(struct Discovery *d) { atomic_store(&d->running, 0); + /* wake announce thread so it exits without waiting for the full interval */ + pthread_mutex_lock(&d->announce_mutex); + pthread_cond_signal(&d->announce_cond); + pthread_mutex_unlock(&d->announce_mutex); close(d->sock); pthread_join(d->announce_thread, NULL); pthread_join(d->receive_thread, NULL); + pthread_cond_destroy(&d->announce_cond); + pthread_mutex_destroy(&d->announce_mutex); pthread_mutex_destroy(&d->peers_mutex); free(d); } diff --git a/src/modules/media_ctrl/Makefile b/src/modules/media_ctrl/Makefile index 8269ac9..2ab9348 100644 --- a/src/modules/media_ctrl/Makefile +++ b/src/modules/media_ctrl/Makefile @@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/media_ctrl all: $(MODULE_BUILD)/media_ctrl.o -$(MODULE_BUILD)/media_ctrl.o: media_ctrl.c $(ROOT)/include/media_ctrl.h $(ROOT)/include/error.h | $(MODULE_BUILD) - $(CC) $(CFLAGS) -c -o $@ $< +$(MODULE_BUILD)/media_ctrl.o: media_ctrl.c | $(MODULE_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< $(MODULE_BUILD): mkdir -p $@ clean: - rm -f $(MODULE_BUILD)/media_ctrl.o + rm -f $(MODULE_BUILD)/media_ctrl.o $(MODULE_BUILD)/media_ctrl.d + +-include $(MODULE_BUILD)/media_ctrl.d diff --git a/src/modules/protocol/Makefile b/src/modules/protocol/Makefile new file mode 100644 index 0000000..53e6ff2 --- /dev/null +++ b/src/modules/protocol/Makefile @@ -0,0 +1,19 @@ +ROOT := $(abspath ../../..) +include $(ROOT)/common.mk + +MODULE_BUILD = $(BUILD)/protocol + +.PHONY: all clean + +all: $(MODULE_BUILD)/protocol.o + +$(MODULE_BUILD)/protocol.o: protocol.c | $(MODULE_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< + +$(MODULE_BUILD): + mkdir -p $@ + +clean: + rm -f $(MODULE_BUILD)/protocol.o $(MODULE_BUILD)/protocol.d + +-include $(MODULE_BUILD)/protocol.d diff --git a/src/modules/protocol/protocol.c b/src/modules/protocol/protocol.c new file mode 100644 index 0000000..69bf750 --- /dev/null +++ b/src/modules/protocol/protocol.c @@ -0,0 +1,706 @@ +#include +#include + +#include "protocol.h" +#include "serial.h" + +/* -- growable write buffer ------------------------------------------------- */ + +struct Wbuf { + uint8_t *data; + uint32_t len; + uint32_t cap; +}; + +static struct App_Error wbuf_init(struct Wbuf *b, uint32_t initial) { + b->data = malloc(initial); + if (!b->data) { return APP_SYSCALL_ERROR(); } + b->len = 0; + b->cap = initial; + return APP_OK; +} + +static void wbuf_free(struct Wbuf *b) { + free(b->data); +} + +static struct App_Error wbuf_grow(struct Wbuf *b, uint32_t extra) { + if (b->len + extra <= b->cap) { return APP_OK; } + uint32_t newcap = b->cap ? b->cap : 64; + while (newcap < b->len + extra) { newcap *= 2; } + uint8_t *p = realloc(b->data, newcap); + if (!p) { return APP_SYSCALL_ERROR(); } + b->data = p; + b->cap = newcap; + return APP_OK; +} + +static struct App_Error wbuf_u8(struct Wbuf *b, uint8_t v) { + struct App_Error e = wbuf_grow(b, 1); + if (!APP_IS_OK(e)) { return e; } + put_u8(b->data, b->len, v); + b->len += 1; + return APP_OK; +} + +static struct App_Error wbuf_u16(struct Wbuf *b, uint16_t v) { + struct App_Error e = wbuf_grow(b, 2); + if (!APP_IS_OK(e)) { return e; } + put_u16(b->data, b->len, v); + b->len += 2; + return APP_OK; +} + +static struct App_Error wbuf_u32(struct Wbuf *b, uint32_t v) { + struct App_Error e = wbuf_grow(b, 4); + if (!APP_IS_OK(e)) { return e; } + put_u32(b->data, b->len, v); + b->len += 4; + return APP_OK; +} + +static struct App_Error wbuf_i32(struct Wbuf *b, int32_t v) { + struct App_Error e = wbuf_grow(b, 4); + if (!APP_IS_OK(e)) { return e; } + put_i32(b->data, b->len, v); + b->len += 4; + return APP_OK; +} + +static struct App_Error wbuf_i64(struct Wbuf *b, int64_t v) { + struct App_Error e = wbuf_grow(b, 8); + if (!APP_IS_OK(e)) { return e; } + put_i64(b->data, b->len, v); + b->len += 8; + return APP_OK; +} + +/* Serialise a string as u8 length prefix + bytes (truncates at 255). */ +static struct App_Error wbuf_str8(struct Wbuf *b, const char *s) { + size_t slen = s ? strlen(s) : 0; + uint8_t n = (slen > 255u) ? 255u : (uint8_t)slen; + struct App_Error e = wbuf_grow(b, (uint32_t)1 + n); + if (!APP_IS_OK(e)) { return e; } + put_u8(b->data, b->len, n); + b->len += 1; + memcpy(b->data + b->len, s, n); + b->len += n; + return APP_OK; +} + +static struct App_Error wbuf_bytes(struct Wbuf *b, + const uint8_t *data, uint32_t len) +{ + struct App_Error e = wbuf_grow(b, len); + if (!APP_IS_OK(e)) { return e; } + memcpy(b->data + b->len, data, len); + b->len += len; + return APP_OK; +} + +/* -- sequential read cursor ------------------------------------------------ */ + +struct Cursor { + const uint8_t *buf; + uint32_t len; + uint32_t pos; + int ok; +}; + +static void cur_init(struct Cursor *c, const uint8_t *buf, uint32_t len) { + c->buf = buf; + c->len = len; + c->pos = 0; + c->ok = 1; +} + +static void cur_need(struct Cursor *c, uint32_t n) { + if (c->ok && c->pos + n > c->len) { c->ok = 0; } +} + +static uint8_t cur_u8(struct Cursor *c) { + cur_need(c, 1); + if (!c->ok) { return 0; } + uint8_t v = get_u8(c->buf, c->pos); + c->pos += 1; + return v; +} + +static uint16_t cur_u16(struct Cursor *c) { + cur_need(c, 2); + if (!c->ok) { return 0; } + uint16_t v = get_u16(c->buf, c->pos); + c->pos += 2; + return v; +} + +static uint32_t cur_u32(struct Cursor *c) { + cur_need(c, 4); + if (!c->ok) { return 0; } + uint32_t v = get_u32(c->buf, c->pos); + c->pos += 4; + return v; +} + +static int32_t cur_i32(struct Cursor *c) { + cur_need(c, 4); + if (!c->ok) { return 0; } + int32_t v = get_i32(c->buf, c->pos); + c->pos += 4; + return v; +} + +static int64_t cur_i64(struct Cursor *c) { + cur_need(c, 8); + if (!c->ok) { return 0; } + int64_t v = get_i64(c->buf, c->pos); + c->pos += 8; + return v; +} + +/* Read a u8-length-prefixed string; returns pointer into buf and sets *len. */ +static const char *cur_str8(struct Cursor *c, uint8_t *len_out) { + uint8_t n = cur_u8(c); + cur_need(c, n); + if (!c->ok) { *len_out = 0; return NULL; } + const char *p = (const char *)(c->buf + c->pos); + c->pos += n; + *len_out = n; + return p; +} + + +#define CUR_CHECK(c) \ + do { if (!(c).ok) { return APP_INVALID_ERROR_MSG(0, "payload too short"); } } while (0) + +/* -- write helpers --------------------------------------------------------- */ + +/* + * Build a CONTROL_RESPONSE base (request_id + status) into b, then append + * extra, then send — all in one call. Used by the specific response writers. + */ +static struct App_Error send_response(struct Transport_Conn *conn, + uint16_t request_id, uint16_t status, + const uint8_t *extra, uint32_t extra_len) +{ + struct Wbuf b; + struct App_Error e = wbuf_init(&b, 4 + extra_len); + if (!APP_IS_OK(e)) { return e; } + + e = wbuf_u16(&b, request_id); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u16(&b, status); if (!APP_IS_OK(e)) { goto fail; } + if (extra && extra_len > 0) { + e = wbuf_bytes(&b, extra, extra_len); + if (!APP_IS_OK(e)) { goto fail; } + } + + e = transport_send_frame(conn, PROTO_MSG_CONTROL_RESPONSE, b.data, b.len); +fail: + wbuf_free(&b); + return e; +} + +/* -- write functions ------------------------------------------------------- */ + +struct App_Error proto_write_video_frame(struct Transport_Conn *conn, + uint16_t stream_id, const uint8_t *data, uint32_t data_len) +{ + uint32_t total = 2u + data_len; + uint8_t *buf = malloc(total); + if (!buf) { return APP_SYSCALL_ERROR(); } + put_u16(buf, 0, stream_id); + memcpy(buf + 2, data, data_len); + struct App_Error e = transport_send_frame(conn, PROTO_MSG_VIDEO_FRAME, buf, total); + free(buf); + return e; +} + +struct App_Error proto_write_stream_event(struct Transport_Conn *conn, + uint16_t stream_id, uint8_t event_code) +{ + uint8_t buf[3]; + put_u16(buf, 0, stream_id); + put_u8 (buf, 2, event_code); + return transport_send_frame(conn, PROTO_MSG_STREAM_EVENT, buf, 3); +} + +struct App_Error proto_write_stream_open(struct Transport_Conn *conn, + uint16_t request_id, uint16_t stream_id, + uint16_t format, uint16_t pixel_format, uint16_t origin) +{ + uint8_t buf[12]; + put_u16(buf, 0, request_id); + put_u16(buf, 2, PROTO_CMD_STREAM_OPEN); + put_u16(buf, 4, stream_id); + put_u16(buf, 6, format); + put_u16(buf, 8, pixel_format); + put_u16(buf, 10, origin); + return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 12); +} + +struct App_Error proto_write_stream_close(struct Transport_Conn *conn, + uint16_t request_id, uint16_t stream_id) +{ + uint8_t buf[6]; + put_u16(buf, 0, request_id); + put_u16(buf, 2, PROTO_CMD_STREAM_CLOSE); + put_u16(buf, 4, stream_id); + return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 6); +} + +struct App_Error proto_write_enum_devices(struct Transport_Conn *conn, + uint16_t request_id) +{ + uint8_t buf[4]; + put_u16(buf, 0, request_id); + put_u16(buf, 2, PROTO_CMD_ENUM_DEVICES); + return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 4); +} + +struct App_Error proto_write_enum_controls(struct Transport_Conn *conn, + uint16_t request_id, uint16_t device_index) +{ + uint8_t buf[6]; + put_u16(buf, 0, request_id); + put_u16(buf, 2, PROTO_CMD_ENUM_CONTROLS); + put_u16(buf, 4, device_index); + return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 6); +} + +struct App_Error proto_write_get_control(struct Transport_Conn *conn, + uint16_t request_id, uint16_t device_index, uint32_t control_id) +{ + uint8_t buf[10]; + put_u16(buf, 0, request_id); + put_u16(buf, 2, PROTO_CMD_GET_CONTROL); + put_u16(buf, 4, device_index); + put_u32(buf, 6, control_id); + return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 10); +} + +struct App_Error proto_write_set_control(struct Transport_Conn *conn, + uint16_t request_id, uint16_t device_index, + uint32_t control_id, int32_t value) +{ + uint8_t buf[14]; + put_u16(buf, 0, request_id); + put_u16(buf, 2, PROTO_CMD_SET_CONTROL); + put_u16(buf, 4, device_index); + put_u32(buf, 6, control_id); + put_i32(buf, 10, value); + return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 14); +} + +struct App_Error proto_write_enum_monitors(struct Transport_Conn *conn, + uint16_t request_id) +{ + uint8_t buf[4]; + put_u16(buf, 0, request_id); + put_u16(buf, 2, PROTO_CMD_ENUM_MONITORS); + return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 4); +} + +struct App_Error proto_write_control_response(struct Transport_Conn *conn, + uint16_t request_id, uint16_t status, + const uint8_t *payload, uint32_t payload_len) +{ + return send_response(conn, request_id, status, payload, payload_len); +} + +struct App_Error proto_write_get_control_response(struct Transport_Conn *conn, + uint16_t request_id, uint16_t status, int32_t value) +{ + uint8_t extra[4]; + put_i32(extra, 0, value); + return send_response(conn, request_id, status, extra, 4); +} + +struct App_Error proto_write_enum_devices_response(struct Transport_Conn *conn, + uint16_t request_id, uint16_t status, + const struct Proto_Media_Device_Info *media_devices, uint16_t media_count, + const struct Proto_Standalone_Device_Info *standalone, uint16_t standalone_count) +{ + struct Wbuf b; + struct App_Error e = wbuf_init(&b, 128); + if (!APP_IS_OK(e)) { return e; } + + e = wbuf_u16(&b, request_id); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u16(&b, status); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u16(&b, media_count); if (!APP_IS_OK(e)) { goto fail; } + + for (uint16_t i = 0; i < media_count; i++) { + const struct Proto_Media_Device_Info *m = &media_devices[i]; + e = wbuf_str8(&b, m->path); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_str8(&b, m->driver); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_str8(&b, m->model); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_str8(&b, m->bus_info); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u8 (&b, m->video_node_count); if (!APP_IS_OK(e)) { goto fail; } + + for (uint8_t j = 0; j < m->video_node_count; j++) { + const struct Proto_Video_Node_Info *v = &m->video_nodes[j]; + e = wbuf_str8(&b, v->path); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_str8(&b, v->entity_name); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u32 (&b, v->entity_type); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u32 (&b, v->entity_flags);if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u32 (&b, v->device_caps); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u8 (&b, v->pad_flags); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u8 (&b, v->is_capture); if (!APP_IS_OK(e)) { goto fail; } + } + } + + e = wbuf_u16(&b, standalone_count); if (!APP_IS_OK(e)) { goto fail; } + for (uint16_t i = 0; i < standalone_count; i++) { + e = wbuf_str8(&b, standalone[i].path); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_str8(&b, standalone[i].name); if (!APP_IS_OK(e)) { goto fail; } + } + + e = transport_send_frame(conn, PROTO_MSG_CONTROL_RESPONSE, b.data, b.len); +fail: + wbuf_free(&b); + return e; +} + +struct App_Error proto_write_enum_controls_response(struct Transport_Conn *conn, + uint16_t request_id, uint16_t status, + const struct Proto_Control_Info *controls, uint16_t count) +{ + struct Wbuf b; + struct App_Error e = wbuf_init(&b, 256); + if (!APP_IS_OK(e)) { return e; } + + e = wbuf_u16(&b, request_id); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u16(&b, status); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u16(&b, count); if (!APP_IS_OK(e)) { goto fail; } + + for (uint16_t i = 0; i < count; i++) { + const struct Proto_Control_Info *c = &controls[i]; + e = wbuf_u32(&b, c->id); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u8 (&b, c->type); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u32(&b, c->flags); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_str8(&b, c->name); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_i32(&b, c->min); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_i32(&b, c->max); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_i32(&b, c->step); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_i32(&b, c->default_val); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_i32(&b, c->current_val); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u8 (&b, c->menu_count); if (!APP_IS_OK(e)) { goto fail; } + + for (uint8_t j = 0; j < c->menu_count; j++) { + const struct Proto_Menu_Item *m = &c->menu_items[j]; + e = wbuf_u32(&b, m->index); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_str8(&b, m->name); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_i64(&b, m->int_value); if (!APP_IS_OK(e)) { goto fail; } + } + } + + e = transport_send_frame(conn, PROTO_MSG_CONTROL_RESPONSE, b.data, b.len); +fail: + wbuf_free(&b); + return e; +} + +struct App_Error proto_write_enum_monitors_response(struct Transport_Conn *conn, + uint16_t request_id, uint16_t status, + const struct Proto_Monitor_Info *monitors, uint16_t count) +{ + struct Wbuf b; + struct App_Error e = wbuf_init(&b, 64); + if (!APP_IS_OK(e)) { return e; } + + e = wbuf_u16(&b, request_id); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u16(&b, status); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u16(&b, count); if (!APP_IS_OK(e)) { goto fail; } + + for (uint16_t i = 0; i < count; i++) { + const struct Proto_Monitor_Info *m = &monitors[i]; + e = wbuf_i32(&b, m->x); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_i32(&b, m->y); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u32(&b, m->width); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_u32(&b, m->height); if (!APP_IS_OK(e)) { goto fail; } + e = wbuf_str8(&b, m->name); if (!APP_IS_OK(e)) { goto fail; } + } + + e = transport_send_frame(conn, PROTO_MSG_CONTROL_RESPONSE, b.data, b.len); +fail: + wbuf_free(&b); + return e; +} + +/* -- read functions -------------------------------------------------------- */ + +struct App_Error proto_read_video_frame( + const uint8_t *payload, uint32_t length, + struct Proto_Video_Frame *out) +{ + if (length < 2) { return APP_INVALID_ERROR_MSG(0, "VIDEO_FRAME payload too short"); } + out->stream_id = get_u16(payload, 0); + out->data = payload + 2; + out->data_len = length - 2; + return APP_OK; +} + +struct App_Error proto_read_stream_event( + const uint8_t *payload, uint32_t length, + struct Proto_Stream_Event *out) +{ + if (length < 3) { return APP_INVALID_ERROR_MSG(0, "STREAM_EVENT payload too short"); } + out->stream_id = get_u16(payload, 0); + out->event_code = get_u8 (payload, 2); + return APP_OK; +} + +struct App_Error proto_read_request_header( + const uint8_t *payload, uint32_t length, + struct Proto_Request_Header *out) +{ + if (length < 4) { return APP_INVALID_ERROR_MSG(0, "CONTROL_REQUEST payload too short"); } + out->request_id = get_u16(payload, 0); + out->command = get_u16(payload, 2); + return APP_OK; +} + +struct App_Error proto_read_stream_open( + const uint8_t *payload, uint32_t length, + struct Proto_Stream_Open *out) +{ + if (length < 12) { return APP_INVALID_ERROR_MSG(0, "STREAM_OPEN payload too short"); } + out->request_id = get_u16(payload, 0); + out->stream_id = get_u16(payload, 4); + out->format = get_u16(payload, 6); + out->pixel_format = get_u16(payload, 8); + out->origin = get_u16(payload, 10); + return APP_OK; +} + +struct App_Error proto_read_stream_close( + const uint8_t *payload, uint32_t length, + struct Proto_Stream_Close *out) +{ + if (length < 6) { return APP_INVALID_ERROR_MSG(0, "STREAM_CLOSE payload too short"); } + out->request_id = get_u16(payload, 0); + out->stream_id = get_u16(payload, 4); + return APP_OK; +} + +struct App_Error proto_read_enum_controls_req( + const uint8_t *payload, uint32_t length, + struct Proto_Enum_Controls_Req *out) +{ + if (length < 6) { return APP_INVALID_ERROR_MSG(0, "ENUM_CONTROLS request payload too short"); } + out->request_id = get_u16(payload, 0); + out->device_index = get_u16(payload, 4); + return APP_OK; +} + +struct App_Error proto_read_get_control_req( + const uint8_t *payload, uint32_t length, + struct Proto_Get_Control_Req *out) +{ + if (length < 10) { return APP_INVALID_ERROR_MSG(0, "GET_CONTROL request payload too short"); } + out->request_id = get_u16(payload, 0); + out->device_index = get_u16(payload, 4); + out->control_id = get_u32(payload, 6); + return APP_OK; +} + +struct App_Error proto_read_set_control_req( + const uint8_t *payload, uint32_t length, + struct Proto_Set_Control_Req *out) +{ + if (length < 14) { return APP_INVALID_ERROR_MSG(0, "SET_CONTROL request payload too short"); } + out->request_id = get_u16(payload, 0); + out->device_index = get_u16(payload, 4); + out->control_id = get_u32(payload, 6); + out->value = get_i32(payload, 10); + return APP_OK; +} + +struct App_Error proto_read_response_header( + const uint8_t *payload, uint32_t length, + struct Proto_Response_Header *out) +{ + if (length < 4) { return APP_INVALID_ERROR_MSG(0, "CONTROL_RESPONSE payload too short"); } + out->request_id = get_u16(payload, 0); + out->status = get_u16(payload, 2); + return APP_OK; +} + +struct App_Error proto_read_get_control_response( + const uint8_t *payload, uint32_t length, + struct Proto_Get_Control_Resp *out) +{ + if (length < 8) { return APP_INVALID_ERROR_MSG(0, "GET_CONTROL response payload too short"); } + out->request_id = get_u16(payload, 0); + out->status = get_u16(payload, 2); + out->value = get_i32(payload, 4); + return APP_OK; +} + +struct App_Error proto_read_enum_devices_response( + const uint8_t *payload, uint32_t length, + struct Proto_Response_Header *header_out, + void (*on_media_device)( + const char *path, uint8_t path_len, + const char *driver, uint8_t driver_len, + const char *model, uint8_t model_len, + const char *bus_info, uint8_t bus_info_len, + uint8_t video_node_count, + void *userdata), + void (*on_video_node)( + const char *path, uint8_t path_len, + const char *entity_name, uint8_t entity_name_len, + uint32_t entity_type, uint32_t entity_flags, + uint32_t device_caps, + uint8_t pad_flags, uint8_t is_capture, + void *userdata), + void (*on_standalone)( + const char *path, uint8_t path_len, + const char *name, uint8_t name_len, + void *userdata), + void *userdata) +{ + struct Cursor c; + cur_init(&c, payload, length); + + header_out->request_id = cur_u16(&c); + header_out->status = cur_u16(&c); + uint16_t media_count = cur_u16(&c); + CUR_CHECK(c); + + for (uint16_t i = 0; i < media_count; i++) { + uint8_t path_len, driver_len, model_len, bus_info_len; + const char *path = cur_str8(&c, &path_len); + const char *driver = cur_str8(&c, &driver_len); + const char *model = cur_str8(&c, &model_len); + const char *bus_info = cur_str8(&c, &bus_info_len); + uint8_t vcount = cur_u8(&c); + CUR_CHECK(c); + + if (on_media_device) { + on_media_device(path, path_len, driver, driver_len, + model, model_len, bus_info, bus_info_len, + vcount, userdata); + } + + for (uint8_t j = 0; j < vcount; j++) { + uint8_t vpath_len, ename_len; + const char *vpath = cur_str8(&c, &vpath_len); + const char *ename = cur_str8(&c, &ename_len); + uint32_t etype = cur_u32(&c); + uint32_t eflags = cur_u32(&c); + uint32_t dcaps = cur_u32(&c); + uint8_t pflags = cur_u8(&c); + uint8_t iscap = cur_u8(&c); + CUR_CHECK(c); + + if (on_video_node) { + on_video_node(vpath, vpath_len, ename, ename_len, + etype, eflags, dcaps, pflags, iscap, userdata); + } + } + } + + uint16_t standalone_count = cur_u16(&c); + CUR_CHECK(c); + + for (uint16_t i = 0; i < standalone_count; i++) { + uint8_t path_len, name_len; + const char *path = cur_str8(&c, &path_len); + const char *name = cur_str8(&c, &name_len); + CUR_CHECK(c); + if (on_standalone) { on_standalone(path, path_len, name, name_len, userdata); } + } + + return APP_OK; +} + +struct App_Error proto_read_enum_controls_response( + const uint8_t *payload, uint32_t length, + struct Proto_Response_Header *header_out, + void (*on_control)( + uint32_t id, uint8_t type, uint32_t flags, + const char *name, uint8_t name_len, + int32_t min, int32_t max, int32_t step, + int32_t default_val, int32_t current_val, + uint8_t menu_count, void *userdata), + void (*on_menu_item)( + uint32_t index, + const char *name, uint8_t name_len, + int64_t int_value, + void *userdata), + void *userdata) +{ + struct Cursor c; + cur_init(&c, payload, length); + + header_out->request_id = cur_u16(&c); + header_out->status = cur_u16(&c); + uint16_t count = cur_u16(&c); + CUR_CHECK(c); + + for (uint16_t i = 0; i < count; i++) { + uint32_t id = cur_u32(&c); + uint8_t type = cur_u8 (&c); + uint32_t flags = cur_u32(&c); + uint8_t name_len; + const char *name = cur_str8(&c, &name_len); + int32_t min = cur_i32(&c); + int32_t max = cur_i32(&c); + int32_t step = cur_i32(&c); + int32_t def = cur_i32(&c); + int32_t cur = cur_i32(&c); + uint8_t menu_count = cur_u8(&c); + CUR_CHECK(c); + + if (on_control) { + on_control(id, type, flags, name, name_len, + min, max, step, def, cur, menu_count, userdata); + } + + for (uint8_t j = 0; j < menu_count; j++) { + uint32_t midx = cur_u32(&c); + uint8_t mname_len; + const char *mname = cur_str8(&c, &mname_len); + int64_t mval = cur_i64(&c); + CUR_CHECK(c); + if (on_menu_item) { + on_menu_item(midx, mname, mname_len, mval, userdata); + } + } + } + + return APP_OK; +} + +struct App_Error proto_read_enum_monitors_response( + const uint8_t *payload, uint32_t length, + struct Proto_Response_Header *header_out, + void (*on_monitor)( + int32_t x, int32_t y, uint32_t width, uint32_t height, + const char *name, uint8_t name_len, + void *userdata), + void *userdata) +{ + struct Cursor c; + cur_init(&c, payload, length); + + header_out->request_id = cur_u16(&c); + header_out->status = cur_u16(&c); + uint16_t count = cur_u16(&c); + CUR_CHECK(c); + + for (uint16_t i = 0; i < count; i++) { + int32_t x = cur_i32(&c); + int32_t y = cur_i32(&c); + uint32_t width = cur_u32(&c); + uint32_t height = cur_u32(&c); + uint8_t name_len; + const char *name = cur_str8(&c, &name_len); + CUR_CHECK(c); + if (on_monitor) { + on_monitor(x, y, width, height, name, name_len, userdata); + } + } + + return APP_OK; +} diff --git a/src/modules/serial/Makefile b/src/modules/serial/Makefile index d868ef8..a6d616b 100644 --- a/src/modules/serial/Makefile +++ b/src/modules/serial/Makefile @@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/serial all: $(MODULE_BUILD)/serial.o -$(MODULE_BUILD)/serial.o: serial.c $(ROOT)/include/serial.h | $(MODULE_BUILD) - $(CC) $(CFLAGS) -c -o $@ $< +$(MODULE_BUILD)/serial.o: serial.c | $(MODULE_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< $(MODULE_BUILD): mkdir -p $@ clean: - rm -f $(MODULE_BUILD)/serial.o + rm -f $(MODULE_BUILD)/serial.o $(MODULE_BUILD)/serial.d + +-include $(MODULE_BUILD)/serial.d diff --git a/src/modules/transport/Makefile b/src/modules/transport/Makefile index 0e53f17..5ccee98 100644 --- a/src/modules/transport/Makefile +++ b/src/modules/transport/Makefile @@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/transport all: $(MODULE_BUILD)/transport.o -$(MODULE_BUILD)/transport.o: transport.c $(ROOT)/include/transport.h | $(MODULE_BUILD) - $(CC) $(CFLAGS) -c -o $@ $< +$(MODULE_BUILD)/transport.o: transport.c | $(MODULE_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< $(MODULE_BUILD): mkdir -p $@ clean: - rm -f $(MODULE_BUILD)/transport.o + rm -f $(MODULE_BUILD)/transport.o $(MODULE_BUILD)/transport.d + +-include $(MODULE_BUILD)/transport.d diff --git a/src/modules/v4l2_ctrl/Makefile b/src/modules/v4l2_ctrl/Makefile index 7b31550..4f4eb87 100644 --- a/src/modules/v4l2_ctrl/Makefile +++ b/src/modules/v4l2_ctrl/Makefile @@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/v4l2_ctrl all: $(MODULE_BUILD)/v4l2_ctrl.o -$(MODULE_BUILD)/v4l2_ctrl.o: v4l2_ctrl.c $(ROOT)/include/v4l2_ctrl.h $(ROOT)/include/error.h | $(MODULE_BUILD) - $(CC) $(CFLAGS) -c -o $@ $< +$(MODULE_BUILD)/v4l2_ctrl.o: v4l2_ctrl.c | $(MODULE_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< $(MODULE_BUILD): mkdir -p $@ clean: - rm -f $(MODULE_BUILD)/v4l2_ctrl.o + rm -f $(MODULE_BUILD)/v4l2_ctrl.o $(MODULE_BUILD)/v4l2_ctrl.d + +-include $(MODULE_BUILD)/v4l2_ctrl.d diff --git a/src/node/Makefile b/src/node/Makefile new file mode 100644 index 0000000..4b343d8 --- /dev/null +++ b/src/node/Makefile @@ -0,0 +1,42 @@ +ROOT := $(abspath ../..) +include $(ROOT)/common.mk + +NODE_BUILD = $(BUILD)/node +MAIN_OBJ = $(NODE_BUILD)/main.o +COMMON_OBJ = $(BUILD)/common/error.o +MEDIA_OBJ = $(BUILD)/media_ctrl/media_ctrl.o +V4L2_OBJ = $(BUILD)/v4l2_ctrl/v4l2_ctrl.o +SERIAL_OBJ = $(BUILD)/serial/serial.o +TRANSPORT_OBJ = $(BUILD)/transport/transport.o +DISCOVERY_OBJ = $(BUILD)/discovery/discovery.o +CONFIG_OBJ = $(BUILD)/config/config.o +PROTOCOL_OBJ = $(BUILD)/protocol/protocol.o + +.PHONY: all clean + +all: $(NODE_BUILD)/video-node + +$(NODE_BUILD)/video-node: $(MAIN_OBJ) \ + $(COMMON_OBJ) $(MEDIA_OBJ) $(V4L2_OBJ) $(SERIAL_OBJ) \ + $(TRANSPORT_OBJ) $(DISCOVERY_OBJ) $(CONFIG_OBJ) $(PROTOCOL_OBJ) + $(CC) $(CFLAGS) -o $@ $^ -lpthread + +$(MAIN_OBJ): main.c | $(NODE_BUILD) + $(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $< + +$(COMMON_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/common +$(MEDIA_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/media_ctrl +$(V4L2_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/v4l2_ctrl +$(SERIAL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/serial +$(TRANSPORT_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/transport +$(DISCOVERY_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/discovery +$(CONFIG_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/config +$(PROTOCOL_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/protocol + +$(NODE_BUILD): + mkdir -p $@ + +clean: + rm -f $(NODE_BUILD)/video-node $(MAIN_OBJ) $(NODE_BUILD)/main.d + +-include $(NODE_BUILD)/main.d diff --git a/src/node/main.c b/src/node/main.c new file mode 100644 index 0000000..9afca5c --- /dev/null +++ b/src/node/main.c @@ -0,0 +1,617 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "config.h" +#include "discovery.h" +#include "transport.h" +#include "protocol.h" +#include "media_ctrl.h" +#include "v4l2_ctrl.h" +#include "error.h" + +/* ------------------------------------------------------------------------- + * Device enumeration + * ------------------------------------------------------------------------- */ + +/* Entity type flag for a V4L2 I/O interface entity */ +#define MEDIA_ENT_F_IO_V4L 0x10001u + +#define MAX_VIDEO_NODES 32 +#define MAX_MEDIA_DEVICES 8 +#define MAX_VNODES_PER_MD 8 +#define MAX_CONTROLS 256 + +/* 5 ("/dev/") + NAME_MAX (255) + 1 (NUL) = 261; round up */ +#define DEV_PATH_MAX 264 + +struct VNode { + char path[DEV_PATH_MAX]; + uint32_t dev_major; + uint32_t dev_minor; + uint32_t device_caps; /* V4L2_CAP_* from VIDIOC_QUERYCAP cap.device_caps */ + int claimed; + char card[32]; /* VIDIOC_QUERYCAP card name, empty if unavailable */ +}; + +struct MNode { + char path[DEV_PATH_MAX]; + char entity_name[32]; + uint32_t entity_type; + uint32_t entity_flags; + uint32_t device_caps; + uint8_t is_capture; + int vnode_index; /* index into VNode array */ +}; + +struct MediaDev { + char path[DEV_PATH_MAX]; + char driver[16]; + char model[32]; + char bus_info[32]; + struct MNode vnodes[MAX_VNODES_PER_MD]; + int vnode_count; +}; + +struct Device_List { + struct VNode vnodes[MAX_VIDEO_NODES]; + int vnode_count; + struct MediaDev media[MAX_MEDIA_DEVICES]; + int media_count; +}; + +static int scan_video_nodes(struct Device_List *dl) { + DIR *d = opendir("/dev"); + if (!d) { return -1; } + + struct dirent *ent; + while ((ent = readdir(d)) != NULL && dl->vnode_count < MAX_VIDEO_NODES) { + if (strncmp(ent->d_name, "video", 5) != 0) { continue; } + const char *suffix = ent->d_name + 5; + int numeric = (*suffix != '\0'); + for (const char *p = suffix; *p; p++) { + if (*p < '0' || *p > '9') { numeric = 0; break; } + } + if (!numeric) { continue; } + + struct VNode *v = &dl->vnodes[dl->vnode_count]; + snprintf(v->path, sizeof(v->path), "/dev/%s", ent->d_name); + + struct stat st; + if (stat(v->path, &st) != 0 || !S_ISCHR(st.st_mode)) { continue; } + v->dev_major = (uint32_t)major(st.st_rdev); + v->dev_minor = (uint32_t)minor(st.st_rdev); + v->claimed = 0; + v->card[0] = '\0'; + + /* Try to get card name */ + int fd = open(v->path, O_RDONLY | O_NONBLOCK); + if (fd >= 0) { + struct v4l2_capability cap; + if (ioctl(fd, VIDIOC_QUERYCAP, &cap) == 0) { + strncpy(v->card, (const char *)cap.card, sizeof(v->card) - 1); + v->card[sizeof(v->card) - 1] = '\0'; + /* use per-node caps when available, fall back to physical caps */ + v->device_caps = (cap.capabilities & V4L2_CAP_DEVICE_CAPS) + ? cap.device_caps : cap.capabilities; + } + close(fd); + } + + dl->vnode_count++; + } + closedir(d); + return 0; +} + +struct Entity_Cb_State { + struct Device_List *dl; + struct MediaDev *md; +}; + +static void entity_callback(const struct Media_Entity *entity, void *userdata) { + struct Entity_Cb_State *state = userdata; + struct Device_List *dl = state->dl; + struct MediaDev *md = state->md; + + if (entity->dev_major == 0) { return; } + if (md->vnode_count >= MAX_VNODES_PER_MD) { return; } + + /* Find matching video node by device number */ + for (int i = 0; i < dl->vnode_count; i++) { + if (dl->vnodes[i].dev_major != entity->dev_major) { continue; } + if (dl->vnodes[i].dev_minor != entity->dev_minor) { continue; } + + dl->vnodes[i].claimed = 1; + + struct MNode *mn = &md->vnodes[md->vnode_count++]; + strncpy(mn->path, dl->vnodes[i].path, sizeof(mn->path) - 1); + mn->path[sizeof(mn->path) - 1] = '\0'; + strncpy(mn->entity_name, entity->name, sizeof(mn->entity_name) - 1); + mn->entity_name[sizeof(mn->entity_name) - 1] = '\0'; + mn->entity_type = entity->type; + mn->entity_flags = entity->flags; + mn->device_caps = dl->vnodes[i].device_caps; + mn->is_capture = (mn->device_caps & V4L2_CAP_VIDEO_CAPTURE) ? 1 : 0; + mn->vnode_index = i; + break; + } +} + +static void scan_media_devices(struct Device_List *dl) { + DIR *d = opendir("/dev"); + if (!d) { return; } + + struct dirent *ent; + while ((ent = readdir(d)) != NULL && dl->media_count < MAX_MEDIA_DEVICES) { + if (strncmp(ent->d_name, "media", 5) != 0) { continue; } + const char *suffix = ent->d_name + 5; + int numeric = (*suffix != '\0'); + for (const char *p = suffix; *p; p++) { + if (*p < '0' || *p > '9') { numeric = 0; break; } + } + if (!numeric) { continue; } + + struct MediaDev *md = &dl->media[dl->media_count]; + snprintf(md->path, sizeof(md->path), "/dev/%s", ent->d_name); + md->vnode_count = 0; + + struct Media_Ctrl *ctrl; + if (!APP_IS_OK(media_ctrl_open(md->path, &ctrl))) { continue; } + + struct Media_Device_Info info; + if (APP_IS_OK(media_ctrl_get_info(ctrl, &info))) { + strncpy(md->driver, info.driver, sizeof(md->driver) - 1); + strncpy(md->model, info.model, sizeof(md->model) - 1); + strncpy(md->bus_info, info.bus_info, sizeof(md->bus_info) - 1); + md->driver[sizeof(md->driver) - 1] = '\0'; + md->model[sizeof(md->model) - 1] = '\0'; + md->bus_info[sizeof(md->bus_info) - 1] = '\0'; + } + + struct Entity_Cb_State state = { .dl = dl, .md = md }; + media_ctrl_enum_entities(ctrl, entity_callback, &state); + media_ctrl_close(ctrl); + + dl->media_count++; + } + closedir(d); +} + +static void build_device_list(struct Device_List *dl) { + memset(dl, 0, sizeof(*dl)); + scan_video_nodes(dl); + scan_media_devices(dl); +} + +/* ------------------------------------------------------------------------- + * Control enumeration helpers + * ------------------------------------------------------------------------- */ + +struct Ctrl_Build { + struct Proto_Control_Info items[MAX_CONTROLS]; + char names[MAX_CONTROLS][32]; + int count; +}; + +static void ctrl_enum_cb( + const struct V4l2_Ctrl_Desc *desc, + uint32_t menu_count, const struct V4l2_Menu_Item *menu_items, + void *userdata) +{ + (void)menu_count; (void)menu_items; + struct Ctrl_Build *b = userdata; + if (b->count >= MAX_CONTROLS) { return; } + + int i = b->count++; + strncpy(b->names[i], desc->name, 31); + b->names[i][31] = '\0'; + + b->items[i].id = desc->id; + b->items[i].type = (uint8_t)desc->type; + b->items[i].flags = desc->flags; + b->items[i].name = b->names[i]; + b->items[i].min = desc->min; + b->items[i].max = desc->max; + b->items[i].step = desc->step; + b->items[i].default_val = desc->default_value; + b->items[i].current_val = desc->current_value; + b->items[i].menu_count = 0; + b->items[i].menu_items = NULL; +} + +/* ------------------------------------------------------------------------- + * Node state + * ------------------------------------------------------------------------- */ + +struct Node { + struct Config *config; + struct Transport_Server *server; + struct Discovery *discovery; + struct Device_List devices; +}; + +/* ------------------------------------------------------------------------- + * Request handlers + * ------------------------------------------------------------------------- */ + +static void handle_enum_devices(struct Node *node, + struct Transport_Conn *conn, uint16_t request_id) +{ + /* Build Proto_Media_Device_Info array */ + struct Proto_Video_Node_Info vnodes[MAX_MEDIA_DEVICES * MAX_VNODES_PER_MD]; + struct Proto_Media_Device_Info mdevs[MAX_MEDIA_DEVICES]; + int vnode_offset = 0; + + for (int i = 0; i < node->devices.media_count; i++) { + struct MediaDev *md = &node->devices.media[i]; + mdevs[i].path = md->path; + mdevs[i].driver = md->driver; + mdevs[i].model = md->model; + mdevs[i].bus_info = md->bus_info; + mdevs[i].video_node_count = (uint8_t)md->vnode_count; + mdevs[i].video_nodes = &vnodes[vnode_offset]; + + for (int j = 0; j < md->vnode_count; j++) { + struct MNode *mn = &md->vnodes[j]; + vnodes[vnode_offset + j].path = mn->path; + vnodes[vnode_offset + j].entity_name = mn->entity_name; + vnodes[vnode_offset + j].entity_type = mn->entity_type; + vnodes[vnode_offset + j].entity_flags= mn->entity_flags; + vnodes[vnode_offset + j].device_caps = mn->device_caps; + vnodes[vnode_offset + j].pad_flags = 0; + vnodes[vnode_offset + j].is_capture = mn->is_capture; + } + vnode_offset += md->vnode_count; + } + + /* Build standalone list */ + struct Proto_Standalone_Device_Info standalone[MAX_VIDEO_NODES]; + int standalone_count = 0; + for (int i = 0; i < node->devices.vnode_count; i++) { + if (node->devices.vnodes[i].claimed) { continue; } + standalone[standalone_count].path = node->devices.vnodes[i].path; + standalone[standalone_count].name = node->devices.vnodes[i].card; + standalone_count++; + } + + struct App_Error e = proto_write_enum_devices_response(conn, + request_id, PROTO_STATUS_OK, + mdevs, (uint16_t)node->devices.media_count, + standalone, (uint16_t)standalone_count); + if (!APP_IS_OK(e)) { app_error_print(&e); } +} + +static void handle_enum_controls(struct Node *node, + struct Transport_Conn *conn, + const uint8_t *payload, uint32_t length) +{ + struct Proto_Enum_Controls_Req req; + struct App_Error e = proto_read_enum_controls_req(payload, length, &req); + if (!APP_IS_OK(e)) { + proto_write_control_response(conn, 0, + PROTO_STATUS_INVALID_PARAMS, NULL, 0); + return; + } + + /* Resolve device_index to a path across media-owned + standalone nodes */ + const char *path = NULL; + int idx = (int)req.device_index; + for (int i = 0; i < node->devices.media_count && path == NULL; i++) { + struct MediaDev *md = &node->devices.media[i]; + if (idx < md->vnode_count) { + path = md->vnodes[idx].path; + } else { + idx -= md->vnode_count; + } + } + if (path == NULL) { + /* Check standalone */ + for (int i = 0; i < node->devices.vnode_count; i++) { + if (node->devices.vnodes[i].claimed) { continue; } + if (idx == 0) { path = node->devices.vnodes[i].path; break; } + idx--; + } + } + + if (path == NULL) { + proto_write_control_response(conn, req.request_id, + PROTO_STATUS_NOT_FOUND, NULL, 0); + return; + } + + struct V4l2_Ctrl_Handle *handle; + e = v4l2_ctrl_open(path, &handle); + if (!APP_IS_OK(e)) { + proto_write_control_response(conn, req.request_id, + PROTO_STATUS_ERROR, NULL, 0); + return; + } + + struct Ctrl_Build build = { .count = 0 }; + v4l2_ctrl_enumerate(handle, ctrl_enum_cb, &build); + v4l2_ctrl_close(handle); + + e = proto_write_enum_controls_response(conn, + req.request_id, PROTO_STATUS_OK, + build.items, (uint16_t)build.count); + if (!APP_IS_OK(e)) { app_error_print(&e); } +} + +static void handle_get_control(struct Node *node, + struct Transport_Conn *conn, + const uint8_t *payload, uint32_t length) +{ + struct Proto_Get_Control_Req req; + struct App_Error e = proto_read_get_control_req(payload, length, &req); + if (!APP_IS_OK(e)) { + proto_write_control_response(conn, 0, + PROTO_STATUS_INVALID_PARAMS, NULL, 0); + return; + } + + /* Same device resolution as enum_controls */ + const char *path = NULL; + int idx = (int)req.device_index; + for (int i = 0; i < node->devices.media_count && path == NULL; i++) { + struct MediaDev *md = &node->devices.media[i]; + if (idx < md->vnode_count) { + path = md->vnodes[idx].path; + } else { + idx -= md->vnode_count; + } + } + if (path == NULL) { + for (int i = 0; i < node->devices.vnode_count; i++) { + if (node->devices.vnodes[i].claimed) { continue; } + if (idx == 0) { path = node->devices.vnodes[i].path; break; } + idx--; + } + } + + if (path == NULL) { + proto_write_control_response(conn, req.request_id, + PROTO_STATUS_NOT_FOUND, NULL, 0); + return; + } + + struct V4l2_Ctrl_Handle *handle; + e = v4l2_ctrl_open(path, &handle); + if (!APP_IS_OK(e)) { + proto_write_control_response(conn, req.request_id, + PROTO_STATUS_ERROR, NULL, 0); + return; + } + + int32_t value; + e = v4l2_ctrl_get(handle, req.control_id, &value); + v4l2_ctrl_close(handle); + + if (!APP_IS_OK(e)) { + proto_write_control_response(conn, req.request_id, + PROTO_STATUS_ERROR, NULL, 0); + return; + } + + e = proto_write_get_control_response(conn, + req.request_id, PROTO_STATUS_OK, value); + if (!APP_IS_OK(e)) { app_error_print(&e); } +} + +static void handle_set_control(struct Node *node, + struct Transport_Conn *conn, + const uint8_t *payload, uint32_t length) +{ + struct Proto_Set_Control_Req req; + struct App_Error e = proto_read_set_control_req(payload, length, &req); + if (!APP_IS_OK(e)) { + proto_write_control_response(conn, 0, + PROTO_STATUS_INVALID_PARAMS, NULL, 0); + return; + } + + const char *path = NULL; + int idx = (int)req.device_index; + for (int i = 0; i < node->devices.media_count && path == NULL; i++) { + struct MediaDev *md = &node->devices.media[i]; + if (idx < md->vnode_count) { + path = md->vnodes[idx].path; + } else { + idx -= md->vnode_count; + } + } + if (path == NULL) { + for (int i = 0; i < node->devices.vnode_count; i++) { + if (node->devices.vnodes[i].claimed) { continue; } + if (idx == 0) { path = node->devices.vnodes[i].path; break; } + idx--; + } + } + + if (path == NULL) { + proto_write_control_response(conn, req.request_id, + PROTO_STATUS_NOT_FOUND, NULL, 0); + return; + } + + struct V4l2_Ctrl_Handle *handle; + e = v4l2_ctrl_open(path, &handle); + if (!APP_IS_OK(e)) { + proto_write_control_response(conn, req.request_id, + PROTO_STATUS_ERROR, NULL, 0); + return; + } + + e = v4l2_ctrl_set(handle, req.control_id, req.value); + v4l2_ctrl_close(handle); + + uint16_t status = APP_IS_OK(e) ? PROTO_STATUS_OK : PROTO_STATUS_ERROR; + proto_write_control_response(conn, req.request_id, status, NULL, 0); +} + +/* ------------------------------------------------------------------------- + * Transport callbacks + * ------------------------------------------------------------------------- */ + +static void on_frame(struct Transport_Conn *conn, + struct Transport_Frame *frame, void *userdata) +{ + struct Node *node = userdata; + + if (frame->message_type == PROTO_MSG_CONTROL_REQUEST) { + struct Proto_Request_Header hdr; + struct App_Error e = proto_read_request_header( + frame->payload, frame->payload_length, &hdr); + if (!APP_IS_OK(e)) { + free(frame->payload); + return; + } + + switch (hdr.command) { + case PROTO_CMD_ENUM_DEVICES: + handle_enum_devices(node, conn, hdr.request_id); + break; + case PROTO_CMD_ENUM_CONTROLS: + handle_enum_controls(node, conn, + frame->payload, frame->payload_length); + break; + case PROTO_CMD_GET_CONTROL: + handle_get_control(node, conn, + frame->payload, frame->payload_length); + break; + case PROTO_CMD_SET_CONTROL: + handle_set_control(node, conn, + frame->payload, frame->payload_length); + break; + default: + proto_write_control_response(conn, hdr.request_id, + PROTO_STATUS_UNKNOWN_CMD, NULL, 0); + break; + } + } + + free(frame->payload); +} + +static void on_connect(struct Transport_Conn *conn, void *userdata) { + (void)conn; (void)userdata; + printf("peer connected\n"); +} + +static void on_disconnect(struct Transport_Conn *conn, void *userdata) { + (void)conn; (void)userdata; + printf("peer disconnected\n"); +} + +/* ------------------------------------------------------------------------- + * Config schema + * ------------------------------------------------------------------------- */ + +static const struct Config_Flag_Def function_flag_defs[] = { + { "source", DISCOVERY_FLAG_SOURCE }, + { "relay", DISCOVERY_FLAG_RELAY }, + { "sink", DISCOVERY_FLAG_SINK }, + { "controller", DISCOVERY_FLAG_CONTROLLER }, + { NULL, 0 } +}; + +static const struct Config_Def schema[] = { + { "node", "name", CONFIG_STRING, "unnamed:0", NULL }, + { "node", "site_id", CONFIG_UINT16, "0", NULL }, + { "node", "tcp_port", CONFIG_UINT16, "8000", NULL }, + { "node", "function", CONFIG_FLAGS, "source", function_flag_defs }, + { "discovery", "interval_ms", CONFIG_UINT32, "5000", NULL }, + { "discovery", "timeout_intervals", CONFIG_UINT32, "3", NULL }, + { "transport", "max_connections", CONFIG_UINT32, "16", NULL }, + { "transport", "max_payload", CONFIG_UINT32, "16777216", NULL }, + { NULL } +}; + +/* ------------------------------------------------------------------------- + * Entry point + * ------------------------------------------------------------------------- */ + +static void usage(void) { + fprintf(stderr, + "usage: video-node \n" + " video-node --defaults\n"); +} + +int main(int argc, char **argv) { + if (argc < 2) { usage(); return 1; } + + struct Node node; + memset(&node, 0, sizeof(node)); + + /* Load config */ + struct App_Error e; + if (strcmp(argv[1], "--defaults") == 0) { + e = config_defaults(&node.config, schema); + } else { + e = config_load(&node.config, argv[1], schema); + } + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + + uint16_t tcp_port = config_get_u16(node.config, "node", "tcp_port"); + uint16_t site_id = config_get_u16(node.config, "node", "site_id"); + uint32_t func = config_get_flags(node.config, "node", "function"); + const char *name = config_get_str(node.config, "node", "name"); + uint32_t interval = config_get_u32(node.config, "discovery", "interval_ms"); + uint32_t timeout_i = config_get_u32(node.config, "discovery", "timeout_intervals"); + uint32_t max_conn = config_get_u32(node.config, "transport", "max_connections"); + uint32_t max_pay = config_get_u32(node.config, "transport", "max_payload"); + + printf("node: %s port=%u site=%u\n", name, tcp_port, site_id); + + /* Enumerate devices */ + printf("scanning devices...\n"); + build_device_list(&node.devices); + printf("found %d media device(s), %d video node(s)\n", + node.devices.media_count, node.devices.vnode_count); + + /* Start transport server */ + struct Transport_Server_Config srv_cfg = { + .port = tcp_port, + .max_connections = (int)max_conn, + .max_payload = max_pay, + .on_frame = on_frame, + .on_connect = on_connect, + .on_disconnect = on_disconnect, + .userdata = &node, + }; + + e = transport_server_create(&node.server, &srv_cfg); + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + + e = transport_server_start(node.server); + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + + /* Start discovery */ + struct Discovery_Config disc_cfg = { + .site_id = site_id, + .tcp_port = tcp_port, + .function_flags = (uint16_t)func, + .name = name, + .interval_ms = interval, + .timeout_intervals= timeout_i, + .on_peer_found = NULL, + .on_peer_lost = NULL, + }; + + e = discovery_create(&node.discovery, &disc_cfg); + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + + e = discovery_start(node.discovery); + if (!APP_IS_OK(e)) { app_error_print(&e); return 1; } + + printf("ready\n"); + pause(); + return 0; +}