Compare commits
34 Commits
4e40223478
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 92ba1adf29 | |||
| f3a6be0701 | |||
| a5198e84d2 | |||
| 780f45b46f | |||
| b4facf04be | |||
| 8fa2f33bad | |||
| 7777292dfd | |||
| 87b9800e41 | |||
| 86f135792f | |||
| 44090c1d6d | |||
| 8c4cd69443 | |||
| 1066f793e2 | |||
| 15f4a0f560 | |||
| ae2cc51626 | |||
| 835cbbafba | |||
| 961933e714 | |||
| 2481c3bae4 | |||
| 8460841e8e | |||
| 30ad5fbeae | |||
| d6fe653a2e | |||
| ba2c3cb6cd | |||
| 54d48c9c8e | |||
| 7808d832be | |||
| f8ecade810 | |||
| 996397d615 | |||
| edf2208e08 | |||
| b7e87ceb46 | |||
| f5764940e6 | |||
| 32d31cbd1e | |||
| 28216999e0 | |||
| a2f438bbbb | |||
| 6747c9e00d | |||
| 6c9e0ce7dc | |||
| 639a84b1b9 |
2
Makefile
2
Makefile
@@ -16,6 +16,8 @@ modules:
|
||||
$(MAKE) -C src/modules/protocol
|
||||
$(MAKE) -C src/modules/test_image
|
||||
$(MAKE) -C src/modules/xorg
|
||||
$(MAKE) -C src/modules/reconciler
|
||||
$(MAKE) -C src/modules/ingest
|
||||
|
||||
cli: modules
|
||||
$(MAKE) -C dev/cli
|
||||
|
||||
26
README.md
26
README.md
@@ -15,11 +15,18 @@ Designed to run on resource-constrained hardware (Raspberry Pi capturing raw MJP
|
||||
|
||||
- [docs/cli/media_ctrl_cli.md](docs/cli/media_ctrl_cli.md) — media device and topology tool
|
||||
- [docs/cli/v4l2_ctrl_cli.md](docs/cli/v4l2_ctrl_cli.md) — V4L2 camera control tool
|
||||
- `test_image_cli` — generate test patterns and write PPM output for visual inspection
|
||||
- `xorg_cli` — display a test pattern in the viewer window; exercises scale/anchor modes and text overlays
|
||||
- `v4l2_view_cli` — live camera viewer; auto-selects highest-FPS format, displays FPS overlay
|
||||
- `stream_send_cli` — capture MJPEG from V4L2, connect to a receiver over TCP, stream VIDEO_FRAME messages with per-stream stats
|
||||
- `stream_recv_cli` — listen for incoming TCP stream, display received MJPEG frames with fps/Mbps overlay
|
||||
- [docs/cli/transport_cli.md](docs/cli/transport_cli.md) — send/receive framed messages, inspect transport frame headers
|
||||
- [docs/cli/discovery_cli.md](docs/cli/discovery_cli.md) — announce and discover peers over UDP multicast; print found/lost events
|
||||
- [docs/cli/config_cli.md](docs/cli/config_cli.md) — load an INI config file and print the resolved values after applying schema defaults
|
||||
- [docs/cli/protocol_cli.md](docs/cli/protocol_cli.md) — send and receive typed protocol messages; inspect frame payloads
|
||||
- [docs/cli/query_cli.md](docs/cli/query_cli.md) — wait for first discovered node, send ENUM_DEVICES, print results
|
||||
- [docs/cli/test_image_cli.md](docs/cli/test_image_cli.md) — generate test patterns and write PPM output for visual inspection
|
||||
- [docs/cli/xorg_cli.md](docs/cli/xorg_cli.md) — display a test pattern in the viewer window; exercises scale/anchor modes and text overlays
|
||||
- [docs/cli/v4l2_view_cli.md](docs/cli/v4l2_view_cli.md) — live camera viewer; auto-selects highest-FPS format, displays FPS overlay
|
||||
- [docs/cli/stream_send_cli.md](docs/cli/stream_send_cli.md) — capture MJPEG from V4L2, connect to a receiver over TCP, stream VIDEO_FRAME messages with per-stream stats
|
||||
- [docs/cli/stream_recv_cli.md](docs/cli/stream_recv_cli.md) — listen for incoming TCP stream, display received MJPEG frames with fps/Mbps overlay
|
||||
- [docs/cli/reconciler_cli.md](docs/cli/reconciler_cli.md) — simulated state machine experiment; validate the reconciler with fake resources before wiring into the node
|
||||
- [docs/cli/controller_cli.md](docs/cli/controller_cli.md) — interactive REPL; connects to nodes by peer index or host:port; enum-devices, enum-controls, get/set-control, start/stop-ingest, start/stop-display
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -34,7 +41,7 @@ docs/ documentation
|
||||
|
||||
## Status
|
||||
|
||||
Core modules and the video node binary are working. The node can be queried over the wire protocol for device enumeration and V4L2 camera control. The development web UI connects to live nodes for inspection and control. The xorg viewer sink is implemented (GLFW+OpenGL, all scale/anchor modes, bitmap font atlas text overlays). A V4L2 capture viewer (`v4l2_view_cli`) demonstrates live camera display without going through the node system. Frame ingest-to-wire and relay have not started.
|
||||
Core modules and the video node binary are working end-to-end. The node can be queried over the wire protocol for device enumeration and V4L2 camera control. V4L2 ingest is live — a source node captures MJPEG and streams it to a sink node which displays it in an xorg window. The node supports both source (START_INGEST) and display sink (START_DISPLAY) roles. A reconciler manages V4L2 device and transport connection state. The development web UI connects to live nodes for inspection and control. Relay, archive, and codec have not started.
|
||||
|
||||
| Module | Status | Notes |
|
||||
|---|---|---|
|
||||
@@ -46,13 +53,14 @@ Core modules and the video node binary are working. The node can be queried over
|
||||
| `transport` | done | Framed TCP stream, single-write send |
|
||||
| `discovery` | done | UDP multicast announcements, peer table, found/lost callbacks |
|
||||
| `protocol` | done | Typed write/read functions for all message types |
|
||||
| `node` | done | Video node binary — config, discovery, transport server, V4L2/media request handlers |
|
||||
| `dev/web` | done | Development web UI — connects to live nodes, V4L2 inspection and control |
|
||||
| `test_image` | done | Test pattern generator — colour bars, luminance ramp, grid; YUV420/BGRA output |
|
||||
| `xorg` | done | GLFW+OpenGL viewer sink — YUV420/BGRA/MJPEG input, all scale/anchor modes, bitmap font atlas text overlays; screen grab and XRandR queries not yet implemented |
|
||||
| `reconciler` | done | Generic wanted/current state machine reconciler — BFS pathfinding, event + periodic tick |
|
||||
| `ingest` | done | V4L2 capture loop — open device, negotiate MJPEG, MMAP buffers, capture thread with on_frame callback |
|
||||
| `node` | done | Video node binary — source role (START/STOP_INGEST) and display sink role (START/STOP_DISPLAY); multi-window xorg viewer; declarative reconciler for device and connection state |
|
||||
| `dev/web` | done | Development web UI — connects to live nodes, V4L2 inspection and control |
|
||||
| `frame_alloc` | not started | Per-frame allocation with byte budget and ref counting |
|
||||
| `relay` | not started | Input dispatch to output queues (low-latency and completeness modes) |
|
||||
| `ingest` | not started | V4L2 capture loop — dequeue buffers, emit frames |
|
||||
| `archive` | not started | Write frames to disk, control messages to binary log |
|
||||
| `codec` | not started | Per-frame encode/decode (MJPEG, QOI, ZSTD-raw, VA-API H.264) |
|
||||
| `web node` | not started | Node.js peer — binary protocol socket side + HTTP/WebSocket to browser |
|
||||
|
||||
@@ -77,9 +77,10 @@ Errors are returned as `struct App_Error` values. Functions that can fail return
|
||||
/* modules/common/error.h */
|
||||
|
||||
typedef enum Error_Code {
|
||||
ERR_NONE = 0,
|
||||
ERR_SYSCALL = 1, /* errno is meaningful */
|
||||
ERR_INVALID = 2,
|
||||
ERR_NONE = 0,
|
||||
ERR_SYSCALL = 1, /* errno is meaningful */
|
||||
ERR_INVALID = 2,
|
||||
ERR_NOT_FOUND = 3,
|
||||
} Error_Code;
|
||||
|
||||
struct Syscall_Error_Detail {
|
||||
@@ -87,7 +88,8 @@ struct Syscall_Error_Detail {
|
||||
};
|
||||
|
||||
struct Invalid_Error_Detail {
|
||||
/* fields added as concrete cases arise */
|
||||
int config_line; /* source line number, or 0 if not applicable */
|
||||
const char *message; /* static string describing what was wrong */
|
||||
};
|
||||
|
||||
struct App_Error {
|
||||
@@ -110,16 +112,17 @@ struct App_Error {
|
||||
#define APP_IS_OK(e) \
|
||||
((e).code == ERR_NONE)
|
||||
|
||||
#define APP_ERROR(error_code, detail_field, ...) \
|
||||
((struct App_Error){ \
|
||||
.code = (error_code), \
|
||||
.file = __FILE__, \
|
||||
.line = __LINE__, \
|
||||
.detail = { .detail_field = { __VA_ARGS__ } } \
|
||||
})
|
||||
|
||||
#define APP_SYSCALL_ERROR() \
|
||||
APP_ERROR(ERR_SYSCALL, syscall, .err_no = errno)
|
||||
/* sets ERR_SYSCALL + captures errno */
|
||||
|
||||
#define APP_INVALID_ERROR() \
|
||||
/* sets ERR_INVALID, no message */
|
||||
|
||||
#define APP_INVALID_ERROR_MSG(cfg_line, msg) \
|
||||
/* sets ERR_INVALID with config_line and message */
|
||||
|
||||
#define APP_NOT_FOUND_ERROR() \
|
||||
/* sets ERR_NOT_FOUND */
|
||||
```
|
||||
|
||||
### Presentation
|
||||
@@ -140,11 +143,13 @@ video-setup/
|
||||
modules/ - translation units; each has a .c and a Makefile
|
||||
common/ - shared types (error, base definitions); no external dependencies
|
||||
<module>/
|
||||
node/ - video node entry point and integration (later)
|
||||
node/ - video node binary (source + display sink roles)
|
||||
include/ - public headers (.h files)
|
||||
dev/ - development aids; not part of the final deliverable
|
||||
cli/ - exploratory CLI drivers, one per module
|
||||
web/ - development web UI (Node.js/Express)
|
||||
experiments/ - freeform experiments
|
||||
tools/ - build-time code generators (e.g. gen_font_atlas)
|
||||
tests/ - automated tests (later)
|
||||
Makefile - top-level build
|
||||
```
|
||||
|
||||
@@ -12,6 +12,7 @@ CONFIG_OBJ = $(BUILD)/config/config.o
|
||||
PROTOCOL_OBJ = $(BUILD)/protocol/protocol.o
|
||||
TEST_IMAGE_OBJ = $(BUILD)/test_image/test_image.o
|
||||
XORG_OBJ = $(BUILD)/xorg/xorg.o
|
||||
RECONCILER_OBJ = $(BUILD)/reconciler/reconciler.o
|
||||
|
||||
CLI_SRCS = \
|
||||
media_ctrl_cli.c \
|
||||
@@ -25,7 +26,9 @@ CLI_SRCS = \
|
||||
xorg_cli.c \
|
||||
v4l2_view_cli.c \
|
||||
stream_send_cli.c \
|
||||
stream_recv_cli.c
|
||||
stream_recv_cli.c \
|
||||
reconciler_cli.c \
|
||||
controller_cli.c
|
||||
|
||||
CLI_OBJS = $(CLI_SRCS:%.c=$(CLI_BUILD)/%.o)
|
||||
|
||||
@@ -43,19 +46,24 @@ all: \
|
||||
$(CLI_BUILD)/xorg_cli \
|
||||
$(CLI_BUILD)/v4l2_view_cli \
|
||||
$(CLI_BUILD)/stream_send_cli \
|
||||
$(CLI_BUILD)/stream_recv_cli
|
||||
$(CLI_BUILD)/stream_recv_cli \
|
||||
$(CLI_BUILD)/reconciler_cli \
|
||||
$(CLI_BUILD)/controller_cli
|
||||
|
||||
# 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
|
||||
$(TEST_IMAGE_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/test_image
|
||||
$(XORG_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/xorg
|
||||
# 'force' ensures the sub-make is always invoked so it can check source timestamps itself.
|
||||
.PHONY: force
|
||||
$(COMMON_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/common
|
||||
$(MEDIA_CTRL_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/media_ctrl
|
||||
$(V4L2_CTRL_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/v4l2_ctrl
|
||||
$(SERIAL_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/serial
|
||||
$(TRANSPORT_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/transport
|
||||
$(DISCOVERY_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/discovery
|
||||
$(CONFIG_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/config
|
||||
$(PROTOCOL_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/protocol
|
||||
$(TEST_IMAGE_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/test_image
|
||||
$(XORG_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/xorg
|
||||
$(RECONCILER_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/reconciler
|
||||
|
||||
# Compile each CLI source to its own .o (generates .d alongside).
|
||||
$(CLI_BUILD)/%.o: %.c | $(CLI_BUILD)
|
||||
@@ -98,6 +106,12 @@ $(CLI_BUILD)/stream_send_cli: $(CLI_BUILD)/stream_send_cli.o $(COMMON_OBJ) $(SER
|
||||
$(CLI_BUILD)/stream_recv_cli: $(CLI_BUILD)/stream_recv_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) $(PROTOCOL_OBJ) $(XORG_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ -lpthread $(PKG_LDFLAGS)
|
||||
|
||||
$(CLI_BUILD)/reconciler_cli: $(CLI_BUILD)/reconciler_cli.o $(RECONCILER_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^
|
||||
|
||||
$(CLI_BUILD)/controller_cli: $(CLI_BUILD)/controller_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) $(DISCOVERY_OBJ) $(PROTOCOL_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ -lpthread -lreadline
|
||||
|
||||
$(CLI_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
@@ -116,6 +130,8 @@ clean:
|
||||
$(CLI_BUILD)/xorg_cli \
|
||||
$(CLI_BUILD)/v4l2_view_cli \
|
||||
$(CLI_BUILD)/stream_send_cli \
|
||||
$(CLI_BUILD)/stream_recv_cli
|
||||
$(CLI_BUILD)/stream_recv_cli \
|
||||
$(CLI_BUILD)/reconciler_cli \
|
||||
$(CLI_BUILD)/controller_cli
|
||||
|
||||
-include $(CLI_OBJS:%.o=%.d)
|
||||
|
||||
671
dev/cli/controller_cli.c
Normal file
671
dev/cli/controller_cli.c
Normal file
@@ -0,0 +1,671 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
#include <semaphore.h>
|
||||
#include <pthread.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <readline/readline.h>
|
||||
#include <readline/history.h>
|
||||
|
||||
#include "transport.h"
|
||||
#include "protocol.h"
|
||||
#include "discovery.h"
|
||||
#include "error.h"
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Discovery peer table
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define MAX_PEERS 16
|
||||
|
||||
struct Peer_Entry {
|
||||
char host[64];
|
||||
uint16_t port;
|
||||
char name[DISCOVERY_MAX_NAME_LEN + 1];
|
||||
};
|
||||
|
||||
static struct Peer_Entry peer_table[MAX_PEERS];
|
||||
static int peer_count = 0;
|
||||
static pthread_mutex_t peer_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static void on_peer_found(const struct Discovery_Peer *peer, void *ud)
|
||||
{
|
||||
(void)ud;
|
||||
pthread_mutex_lock(&peer_mutex);
|
||||
if (peer_count < MAX_PEERS) {
|
||||
struct in_addr a;
|
||||
a.s_addr = peer->addr;
|
||||
inet_ntop(AF_INET, &a, peer_table[peer_count].host,
|
||||
sizeof(peer_table[0].host));
|
||||
peer_table[peer_count].port = peer->tcp_port;
|
||||
strncpy(peer_table[peer_count].name, peer->name, DISCOVERY_MAX_NAME_LEN);
|
||||
peer_table[peer_count].name[DISCOVERY_MAX_NAME_LEN] = '\0';
|
||||
peer_count++;
|
||||
/* Print inline — readline will redraw the prompt */
|
||||
printf("\n[discovered %d] %s %s:%u\n",
|
||||
peer_count - 1,
|
||||
peer_table[peer_count - 1].name,
|
||||
peer_table[peer_count - 1].host,
|
||||
peer->tcp_port);
|
||||
rl_on_new_line();
|
||||
rl_redisplay();
|
||||
}
|
||||
pthread_mutex_unlock(&peer_mutex);
|
||||
}
|
||||
|
||||
static void on_peer_lost(const struct Discovery_Peer *peer, void *ud)
|
||||
{
|
||||
(void)ud;
|
||||
struct in_addr a;
|
||||
a.s_addr = peer->addr;
|
||||
char host[64];
|
||||
inet_ntop(AF_INET, &a, host, sizeof(host));
|
||||
|
||||
pthread_mutex_lock(&peer_mutex);
|
||||
for (int i = 0; i < peer_count; i++) {
|
||||
if (strcmp(peer_table[i].host, host) == 0 &&
|
||||
peer_table[i].port == peer->tcp_port) {
|
||||
printf("\n[lost] %s %s:%u\n",
|
||||
peer_table[i].name, host, peer->tcp_port);
|
||||
rl_on_new_line();
|
||||
rl_redisplay();
|
||||
memmove(&peer_table[i], &peer_table[i + 1],
|
||||
(size_t)(peer_count - i - 1) * sizeof(peer_table[0]));
|
||||
peer_count--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&peer_mutex);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Shared state between REPL and transport read thread
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
struct Ctrl_State {
|
||||
sem_t sem;
|
||||
uint16_t pending_cmd;
|
||||
uint16_t last_status;
|
||||
int32_t last_value; /* GET_CONTROL response */
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Response display helpers — reused across commands
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void caps_str(uint32_t caps, char *buf, size_t len)
|
||||
{
|
||||
static const struct { uint32_t bit; const char *name; } flags[] = {
|
||||
{ 0x00000001u, "video-capture" },
|
||||
{ 0x00000002u, "video-output" },
|
||||
{ 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_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 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;
|
||||
int *idx = ud;
|
||||
char caps[128];
|
||||
caps_str(dcaps, caps, sizeof(caps));
|
||||
printf(" [%d] video %.*s entity=%.*s type=0x%08x caps=[%s]%s\n",
|
||||
*idx,
|
||||
(int)path_len, path,
|
||||
(int)ename_len, ename,
|
||||
etype, caps,
|
||||
is_capture ? " [capture]" : "");
|
||||
(*idx)++;
|
||||
}
|
||||
|
||||
static void on_standalone(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *name, uint8_t name_len,
|
||||
void *ud)
|
||||
{
|
||||
int *idx = ud;
|
||||
printf(" [%d] standalone %.*s card=%.*s\n",
|
||||
*idx,
|
||||
(int)path_len, path,
|
||||
(int)name_len, name);
|
||||
(*idx)++;
|
||||
}
|
||||
|
||||
static const char *scale_mode_name(uint8_t s)
|
||||
{
|
||||
switch (s) {
|
||||
case 0: return "stretch";
|
||||
case 1: return "fit";
|
||||
case 2: return "fill";
|
||||
case 3: return "1:1";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
static void on_display(
|
||||
uint16_t device_id,
|
||||
uint16_t stream_id,
|
||||
int16_t win_x, int16_t win_y,
|
||||
uint16_t win_w, uint16_t win_h,
|
||||
uint8_t scale_mode, uint8_t anchor,
|
||||
void *ud)
|
||||
{
|
||||
(void)ud;
|
||||
printf(" [%u] display stream=%u pos=%d,%d size=%ux%u scale_mode=%s anchor=%s\n",
|
||||
device_id, stream_id, win_x, win_y, win_w, win_h,
|
||||
scale_mode_name(scale_mode),
|
||||
anchor == 0 ? "center" : "topleft");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
static void on_menu_item(
|
||||
uint32_t index,
|
||||
const char *name, uint8_t name_len,
|
||||
int64_t int_value,
|
||||
void *ud)
|
||||
{
|
||||
(void)ud;
|
||||
printf(" menu %u %.*s val=%lld\n",
|
||||
index,
|
||||
(int)name_len, name,
|
||||
(long long)int_value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Transport callbacks
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void on_frame(struct Transport_Conn *conn,
|
||||
struct Transport_Frame *frame, void *userdata)
|
||||
{
|
||||
(void)conn;
|
||||
struct Ctrl_State *cs = userdata;
|
||||
|
||||
if (frame->message_type != PROTO_MSG_CONTROL_RESPONSE) {
|
||||
free(frame->payload);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (cs->pending_cmd) {
|
||||
case PROTO_CMD_ENUM_DEVICES: {
|
||||
struct Proto_Response_Header hdr;
|
||||
int dev_idx = 0;
|
||||
struct App_Error e = proto_read_enum_devices_response(
|
||||
frame->payload, frame->payload_length, &hdr,
|
||||
on_media_device, on_video_node, on_standalone, on_display, &dev_idx);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); }
|
||||
else if (hdr.status != PROTO_STATUS_OK) {
|
||||
fprintf(stderr, "ENUM_DEVICES: status=%u\n", hdr.status);
|
||||
}
|
||||
cs->last_status = 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, on_menu_item, NULL);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); }
|
||||
else if (hdr.status != PROTO_STATUS_OK) {
|
||||
fprintf(stderr, "ENUM_CONTROLS: status=%u\n", hdr.status);
|
||||
}
|
||||
cs->last_status = hdr.status;
|
||||
break;
|
||||
}
|
||||
case PROTO_CMD_GET_CONTROL: {
|
||||
struct Proto_Get_Control_Resp resp;
|
||||
struct App_Error e = proto_read_get_control_response(
|
||||
frame->payload, frame->payload_length, &resp);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); }
|
||||
else if (resp.status == PROTO_STATUS_OK) {
|
||||
printf(" value = %d\n", resp.value);
|
||||
} else {
|
||||
fprintf(stderr, "GET_CONTROL: status=%u\n", resp.status);
|
||||
}
|
||||
cs->last_status = resp.status;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
/* Generic response: just read request_id + status */
|
||||
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); }
|
||||
else if (hdr.status != PROTO_STATUS_OK) {
|
||||
fprintf(stderr, "command 0x%04x: status=%u\n",
|
||||
cs->pending_cmd, hdr.status);
|
||||
} else {
|
||||
printf(" ok\n");
|
||||
}
|
||||
cs->last_status = APP_IS_OK(e) ? hdr.status : PROTO_STATUS_ERROR;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
free(frame->payload);
|
||||
sem_post(&cs->sem);
|
||||
}
|
||||
|
||||
static void on_disconnect(struct Transport_Conn *conn, void *userdata)
|
||||
{
|
||||
(void)conn; (void)userdata;
|
||||
printf("\ndisconnected from node\n");
|
||||
rl_on_new_line();
|
||||
rl_redisplay();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Request helpers
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static uint16_t next_req_id(uint16_t *counter)
|
||||
{
|
||||
return ++(*counter);
|
||||
}
|
||||
|
||||
/* Send a request, set pending_cmd, wait for response */
|
||||
#define SEND_AND_WAIT(cs, cmd, send_expr) do { \
|
||||
(cs)->pending_cmd = (cmd); \
|
||||
struct App_Error _e = (send_expr); \
|
||||
if (!APP_IS_OK(_e)) { app_error_print(&_e); break; } \
|
||||
sem_wait(&(cs)->sem); \
|
||||
} while (0)
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* REPL command implementations
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void cmd_enum_devices(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req)
|
||||
{
|
||||
printf("devices:\n");
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_ENUM_DEVICES,
|
||||
proto_write_enum_devices(conn, next_req_id(req)));
|
||||
}
|
||||
|
||||
static void cmd_enum_controls(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
const char *idx_str)
|
||||
{
|
||||
int idx = atoi(idx_str);
|
||||
printf("controls for device %d:\n", idx);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_ENUM_CONTROLS,
|
||||
proto_write_enum_controls(conn, next_req_id(req), (uint16_t)idx));
|
||||
}
|
||||
|
||||
static void cmd_get_control(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
const char *idx_str, const char *id_str)
|
||||
{
|
||||
int idx = atoi(idx_str);
|
||||
uint32_t id = (uint32_t)strtoul(id_str, NULL, 0);
|
||||
printf("get control 0x%08x on device %d:\n", id, idx);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_GET_CONTROL,
|
||||
proto_write_get_control(conn, next_req_id(req), (uint16_t)idx, id));
|
||||
}
|
||||
|
||||
static void cmd_set_control(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
const char *idx_str, const char *id_str, const char *val_str)
|
||||
{
|
||||
int idx = atoi(idx_str);
|
||||
uint32_t id = (uint32_t)strtoul(id_str, NULL, 0);
|
||||
int32_t val = (int32_t)atoi(val_str);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_SET_CONTROL,
|
||||
proto_write_set_control(conn, next_req_id(req), (uint16_t)idx, id, val));
|
||||
}
|
||||
|
||||
static void cmd_start_ingest(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
int ntok, char *tokens[])
|
||||
{
|
||||
/* Required: stream_id device dest_host dest_port
|
||||
* Optional: format width height fps_n fps_d */
|
||||
if (ntok < 5) {
|
||||
printf("usage: start-ingest <stream_id> <device> <dest_host> <dest_port>"
|
||||
" [format] [width] [height] [fps_n] [fps_d]\n"
|
||||
" format: 0=auto 1=mjpeg (default 0)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t stream_id = (uint16_t)atoi(tokens[1]);
|
||||
const char *device = tokens[2];
|
||||
const char *host = tokens[3];
|
||||
uint16_t port = (uint16_t)atoi(tokens[4]);
|
||||
|
||||
uint16_t format = ntok > 5 ? (uint16_t)atoi(tokens[5]) : 0;
|
||||
uint16_t width = ntok > 6 ? (uint16_t)atoi(tokens[6]) : 0;
|
||||
uint16_t height = ntok > 7 ? (uint16_t)atoi(tokens[7]) : 0;
|
||||
uint16_t fps_n = ntok > 8 ? (uint16_t)atoi(tokens[8]) : 0;
|
||||
uint16_t fps_d = ntok > 9 ? (uint16_t)atoi(tokens[9]) : 1;
|
||||
|
||||
printf("start-ingest: stream=%u device=%s dest=%s:%u"
|
||||
" format=%u %ux%u fps=%u/%u\n",
|
||||
stream_id, device, host, port, format, width, height, fps_n, fps_d);
|
||||
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_START_INGEST,
|
||||
proto_write_start_ingest(conn, next_req_id(req),
|
||||
stream_id, format, width, height, fps_n, fps_d,
|
||||
PROTO_TRANSPORT_ENCAPSULATED, device, host, port));
|
||||
}
|
||||
|
||||
static void cmd_stop_ingest(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
const char *sid_str)
|
||||
{
|
||||
uint16_t stream_id = (uint16_t)atoi(sid_str);
|
||||
printf("stop-ingest: stream=%u\n", stream_id);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_STOP_INGEST,
|
||||
proto_write_stop_ingest(conn, next_req_id(req), stream_id));
|
||||
}
|
||||
|
||||
static void cmd_start_display(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
int ntok, char *tokens[])
|
||||
{
|
||||
/* Required: stream_id
|
||||
* Optional: win_x win_y win_w win_h no_signal_fps */
|
||||
if (ntok < 2) {
|
||||
printf("usage: start-display <stream_id> [win_x] [win_y] [win_w] [win_h] [no_signal_fps]\n");
|
||||
return;
|
||||
}
|
||||
uint16_t stream_id = (uint16_t)atoi(tokens[1]);
|
||||
int16_t win_x = ntok > 2 ? (int16_t)atoi(tokens[2]) : 0;
|
||||
int16_t win_y = ntok > 3 ? (int16_t)atoi(tokens[3]) : 0;
|
||||
uint16_t win_w = ntok > 4 ? (uint16_t)atoi(tokens[4]) : 0;
|
||||
uint16_t win_h = ntok > 5 ? (uint16_t)atoi(tokens[5]) : 0;
|
||||
uint8_t no_signal_fps = ntok > 6 ? (uint8_t)atoi(tokens[6]) : 0;
|
||||
printf("start-display: stream=%u pos=%d,%d size=%ux%u no_signal_fps=%u\n",
|
||||
stream_id, win_x, win_y, win_w, win_h,
|
||||
no_signal_fps > 0 ? no_signal_fps : 15);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_START_DISPLAY,
|
||||
proto_write_start_display(conn, next_req_id(req),
|
||||
stream_id, win_x, win_y, win_w, win_h,
|
||||
PROTO_DISPLAY_SCALE_FIT, PROTO_DISPLAY_ANCHOR_CENTER,
|
||||
no_signal_fps));
|
||||
}
|
||||
|
||||
static void cmd_stop_display(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
const char *sid_str)
|
||||
{
|
||||
uint16_t stream_id = (uint16_t)atoi(sid_str);
|
||||
printf("stop-display: stream=%u\n", stream_id);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_STOP_DISPLAY,
|
||||
proto_write_stop_display(conn, next_req_id(req), stream_id));
|
||||
}
|
||||
|
||||
static void cmd_help(void)
|
||||
{
|
||||
printf("commands:\n"
|
||||
" peers list discovered nodes\n"
|
||||
" connect [idx|host:port] connect to peer (no arg = first discovered)\n"
|
||||
" enum-devices\n"
|
||||
" enum-controls <device_index>\n"
|
||||
" get-control <device_index> <control_id_hex>\n"
|
||||
" set-control <device_index> <control_id_hex> <value>\n"
|
||||
" start-ingest <stream_id> <device> <dest_host> <dest_port>"
|
||||
" [format] [width] [height] [fps_n] [fps_d]\n"
|
||||
" stop-ingest <stream_id>\n"
|
||||
" start-display <stream_id> [win_x] [win_y] [win_w] [win_h] [no_signal_fps]\n"
|
||||
" stop-display <stream_id>\n"
|
||||
" help\n"
|
||||
" quit / exit\n");
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Entry point
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void usage(void)
|
||||
{
|
||||
fprintf(stderr,
|
||||
"usage: controller_cli [--host HOST] [--port PORT]\n"
|
||||
"\n"
|
||||
" Interactive controller for a video node.\n"
|
||||
" --host HOST connect directly on startup\n"
|
||||
" --port PORT TCP port (default 8000; used with --host)\n"
|
||||
"\n"
|
||||
" Without --host: starts discovery and waits for nodes.\n"
|
||||
" Use 'connect' in the REPL to connect to a discovered node.\n");
|
||||
}
|
||||
|
||||
/* Attempt to connect/reconnect; prints result. Returns new conn or NULL. */
|
||||
static struct Transport_Conn *do_connect(struct Ctrl_State *cs,
|
||||
const char *host, uint16_t port,
|
||||
struct Transport_Conn *old_conn)
|
||||
{
|
||||
if (old_conn) { transport_conn_close(old_conn); }
|
||||
/* Reset state — drain stale semaphore posts from the old connection */
|
||||
cs->pending_cmd = 0;
|
||||
while (sem_trywait(&cs->sem) == 0) { /* drain */ }
|
||||
struct Transport_Conn *conn;
|
||||
struct App_Error e = transport_connect(&conn, host, port,
|
||||
TRANSPORT_DEFAULT_MAX_PAYLOAD, on_frame, on_disconnect, cs);
|
||||
if (!APP_IS_OK(e)) {
|
||||
app_error_print(&e);
|
||||
return NULL;
|
||||
}
|
||||
printf("connected to %s:%u\n", host, port);
|
||||
return conn;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
const char *init_host = NULL;
|
||||
uint16_t init_port = 8000;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--host") == 0 && i + 1 < argc) {
|
||||
init_host = argv[++i];
|
||||
} else if (strcmp(argv[i], "--port") == 0 && i + 1 < argc) {
|
||||
init_port = (uint16_t)atoi(argv[++i]);
|
||||
} else {
|
||||
usage(); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Start discovery (always — useful even when --host given, for 'peers') */
|
||||
struct Discovery *disc = NULL;
|
||||
struct Discovery_Config dcfg = {0};
|
||||
dcfg.site_id = 0;
|
||||
dcfg.tcp_port = 0;
|
||||
dcfg.function_flags = DISCOVERY_FLAG_CONTROLLER;
|
||||
dcfg.name = "controller_cli";
|
||||
dcfg.on_peer_found = on_peer_found;
|
||||
dcfg.on_peer_lost = on_peer_lost;
|
||||
if (!APP_IS_OK(discovery_create(&disc, &dcfg)) ||
|
||||
!APP_IS_OK(discovery_start(disc))) {
|
||||
fprintf(stderr, "warning: discovery failed to start\n");
|
||||
disc = NULL;
|
||||
}
|
||||
|
||||
struct Ctrl_State cs;
|
||||
memset(&cs, 0, sizeof(cs));
|
||||
sem_init(&cs.sem, 0, 0);
|
||||
|
||||
struct Transport_Conn *conn = NULL;
|
||||
|
||||
if (init_host) {
|
||||
conn = do_connect(&cs, init_host, init_port, NULL);
|
||||
if (!conn) { return 1; }
|
||||
} else {
|
||||
printf("listening for nodes — type 'peers' to list, 'connect' to connect\n");
|
||||
}
|
||||
|
||||
cmd_help();
|
||||
printf("\n");
|
||||
|
||||
/* REPL */
|
||||
uint16_t req_id = 0;
|
||||
char line[512];
|
||||
|
||||
while (1) {
|
||||
char *rl_line = readline(conn ? "> " : "(no node) > ");
|
||||
if (!rl_line) { break; }
|
||||
if (*rl_line) { add_history(rl_line); }
|
||||
strncpy(line, rl_line, sizeof(line) - 1);
|
||||
line[sizeof(line) - 1] = '\0';
|
||||
free(rl_line);
|
||||
|
||||
/* Tokenise (up to 12 tokens) */
|
||||
char *tokens[12];
|
||||
int ntok = 0;
|
||||
char *p = line;
|
||||
while (*p && ntok < 12) {
|
||||
while (*p == ' ' || *p == '\t') { p++; }
|
||||
if (!*p) { break; }
|
||||
tokens[ntok++] = p;
|
||||
while (*p && *p != ' ' && *p != '\t') { p++; }
|
||||
if (*p) { *p++ = '\0'; }
|
||||
}
|
||||
if (ntok == 0) { continue; }
|
||||
|
||||
const char *cmd = tokens[0];
|
||||
|
||||
if (strcmp(cmd, "quit") == 0 || strcmp(cmd, "exit") == 0) {
|
||||
break;
|
||||
|
||||
} else if (strcmp(cmd, "help") == 0) {
|
||||
cmd_help();
|
||||
|
||||
} else if (strcmp(cmd, "peers") == 0) {
|
||||
pthread_mutex_lock(&peer_mutex);
|
||||
if (peer_count == 0) {
|
||||
printf("no peers discovered yet\n");
|
||||
} else {
|
||||
for (int i = 0; i < peer_count; i++) {
|
||||
printf(" [%d] %s %s:%u\n", i,
|
||||
peer_table[i].name,
|
||||
peer_table[i].host,
|
||||
peer_table[i].port);
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&peer_mutex);
|
||||
|
||||
} else if (strcmp(cmd, "connect") == 0) {
|
||||
char host[64];
|
||||
uint16_t port = 8000;
|
||||
|
||||
if (ntok < 2) {
|
||||
/* No argument — connect to first discovered peer */
|
||||
pthread_mutex_lock(&peer_mutex);
|
||||
int ok = peer_count > 0;
|
||||
if (ok) {
|
||||
strncpy(host, peer_table[0].host, sizeof(host) - 1);
|
||||
host[sizeof(host) - 1] = '\0';
|
||||
port = peer_table[0].port;
|
||||
}
|
||||
pthread_mutex_unlock(&peer_mutex);
|
||||
if (!ok) {
|
||||
printf("no peers discovered yet — try 'peers'\n");
|
||||
continue;
|
||||
}
|
||||
} else if (strchr(tokens[1], ':')) {
|
||||
/* host:port */
|
||||
char *colon = strchr(tokens[1], ':');
|
||||
size_t hlen = (size_t)(colon - tokens[1]);
|
||||
if (hlen >= sizeof(host)) { hlen = sizeof(host) - 1; }
|
||||
memcpy(host, tokens[1], hlen);
|
||||
host[hlen] = '\0';
|
||||
port = (uint16_t)atoi(colon + 1);
|
||||
} else {
|
||||
/* numeric index into peer table */
|
||||
int idx = atoi(tokens[1]);
|
||||
pthread_mutex_lock(&peer_mutex);
|
||||
int ok = idx >= 0 && idx < peer_count;
|
||||
if (ok) {
|
||||
strncpy(host, peer_table[idx].host, sizeof(host) - 1);
|
||||
host[sizeof(host) - 1] = '\0';
|
||||
port = peer_table[idx].port;
|
||||
}
|
||||
pthread_mutex_unlock(&peer_mutex);
|
||||
if (!ok) {
|
||||
printf("index %d out of range — try 'peers'\n", idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
conn = do_connect(&cs, host, port, conn);
|
||||
|
||||
} else if (!conn) {
|
||||
printf("not connected — use 'connect' to connect to a node\n");
|
||||
|
||||
} else if (strcmp(cmd, "enum-devices") == 0) {
|
||||
cmd_enum_devices(conn, &cs, &req_id);
|
||||
} else if (strcmp(cmd, "enum-controls") == 0) {
|
||||
if (ntok < 2) { printf("usage: enum-controls <device_index>\n"); }
|
||||
else { cmd_enum_controls(conn, &cs, &req_id, tokens[1]); }
|
||||
} else if (strcmp(cmd, "get-control") == 0) {
|
||||
if (ntok < 3) { printf("usage: get-control <device_index> <control_id>\n"); }
|
||||
else { cmd_get_control(conn, &cs, &req_id, tokens[1], tokens[2]); }
|
||||
} else if (strcmp(cmd, "set-control") == 0) {
|
||||
if (ntok < 4) { printf("usage: set-control <device_index> <control_id> <value>\n"); }
|
||||
else { cmd_set_control(conn, &cs, &req_id, tokens[1], tokens[2], tokens[3]); }
|
||||
} else if (strcmp(cmd, "start-ingest") == 0) {
|
||||
cmd_start_ingest(conn, &cs, &req_id, ntok, tokens);
|
||||
} else if (strcmp(cmd, "stop-ingest") == 0) {
|
||||
if (ntok < 2) { printf("usage: stop-ingest <stream_id>\n"); }
|
||||
else { cmd_stop_ingest(conn, &cs, &req_id, tokens[1]); }
|
||||
} else if (strcmp(cmd, "start-display") == 0) {
|
||||
cmd_start_display(conn, &cs, &req_id, ntok, tokens);
|
||||
} else if (strcmp(cmd, "stop-display") == 0) {
|
||||
if (ntok < 2) { printf("usage: stop-display <stream_id>\n"); }
|
||||
else { cmd_stop_display(conn, &cs, &req_id, tokens[1]); }
|
||||
} else {
|
||||
printf("unknown command: %s (type 'help' for commands)\n", cmd);
|
||||
}
|
||||
}
|
||||
|
||||
if (conn) { transport_conn_close(conn); }
|
||||
if (disc) { discovery_destroy(disc); }
|
||||
sem_destroy(&cs.sem);
|
||||
return 0;
|
||||
}
|
||||
@@ -195,7 +195,7 @@ static void on_frame(struct Transport_Conn *conn,
|
||||
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);
|
||||
on_media_device, on_video_node, on_standalone, NULL, 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);
|
||||
|
||||
456
dev/cli/reconciler_cli.c
Normal file
456
dev/cli/reconciler_cli.c
Normal file
@@ -0,0 +1,456 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
#include "reconciler.h"
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Simulated resource userdata
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
struct Sim_State {
|
||||
const char *name;
|
||||
int fail_next;
|
||||
};
|
||||
|
||||
static int sim_action(struct Sim_State *s, const char *action_name) {
|
||||
printf(" [%s] %s\n", s->name, action_name);
|
||||
if (s->fail_next) {
|
||||
s->fail_next = 0;
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* device transitions */
|
||||
static int device_open(void *ud) { return sim_action((struct Sim_State *)ud, "opening device"); }
|
||||
static int device_close(void *ud) { return sim_action((struct Sim_State *)ud, "closing device"); }
|
||||
static int device_start(void *ud) { return sim_action((struct Sim_State *)ud, "starting capture"); }
|
||||
static int device_stop(void *ud) { return sim_action((struct Sim_State *)ud, "stopping capture"); }
|
||||
|
||||
/* transport transitions */
|
||||
static int transport_connect(void *ud) { return sim_action((struct Sim_State *)ud, "connecting transport"); }
|
||||
static int transport_disconnect(void *ud) { return sim_action((struct Sim_State *)ud, "disconnecting transport"); }
|
||||
|
||||
/* stream transitions */
|
||||
static int stream_activate(void *ud) { return sim_action((struct Sim_State *)ud, "activating stream"); }
|
||||
static int stream_deactivate(void *ud) { return sim_action((struct Sim_State *)ud, "deactivating stream"); }
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Log callback
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
static void on_log(
|
||||
const struct Rec_Resource *res,
|
||||
int from, int to, int success,
|
||||
void *userdata)
|
||||
{
|
||||
(void)userdata;
|
||||
|
||||
const char *from_name = reconciler_state_name(res, from);
|
||||
const char *to_name = reconciler_state_name(res, to);
|
||||
|
||||
if (from_name != NULL && to_name != NULL) {
|
||||
printf(" [%s] %s -> %s ... %s\n",
|
||||
reconciler_get_name(res),
|
||||
from_name, to_name,
|
||||
success ? "ok" : "FAILED");
|
||||
} else {
|
||||
printf(" [%s] %d -> %d ... %s\n",
|
||||
reconciler_get_name(res),
|
||||
from, to,
|
||||
success ? "ok" : "FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Helpers
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
static const char *status_label(Rec_Status s) {
|
||||
switch (s) {
|
||||
case REC_STATUS_STABLE: return "stable";
|
||||
case REC_STATUS_WORKING: return "working";
|
||||
case REC_STATUS_BLOCKED: return "blocked";
|
||||
case REC_STATUS_NO_PATH: return "no_path";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
/*
|
||||
* Find the first unsatisfied dep for a resource.
|
||||
* Returns NULL if none (or resource is not blocked).
|
||||
*/
|
||||
static void print_blocked_reason(const struct Rec_Resource *res) {
|
||||
/* We need access to internals — expose via a helper approach.
|
||||
* Since we can't access internal deps from outside the module,
|
||||
* we rely on reconciler_get_status returning BLOCKED and print
|
||||
* a generic message. The CLI has access to the sim resources
|
||||
* directly so we can check ourselves using the public API. */
|
||||
(void)res;
|
||||
printf(" (dependency unsatisfied)");
|
||||
}
|
||||
|
||||
static void print_state(const struct Rec_Resource *res, int state) {
|
||||
const char *name = reconciler_state_name(res, state);
|
||||
if (name != NULL) {
|
||||
printf("%s(%d)", name, state);
|
||||
} else {
|
||||
printf("%d", state);
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Blocked dependency introspection
|
||||
*
|
||||
* We track resource/dep relationships here in the CLI so we can print
|
||||
* informative blocked messages without exposing internals from the module.
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
#define CLI_MAX_DEPS 8
|
||||
|
||||
struct Cli_Dep {
|
||||
const struct Rec_Resource *resource;
|
||||
int blocked_below;
|
||||
const struct Rec_Resource *dep;
|
||||
int dep_min_state;
|
||||
};
|
||||
|
||||
static struct Cli_Dep cli_deps[CLI_MAX_DEPS];
|
||||
static int cli_dep_count = 0;
|
||||
|
||||
static void cli_add_dep(
|
||||
const struct Rec_Resource *resource,
|
||||
int blocked_below,
|
||||
const struct Rec_Resource *dep,
|
||||
int dep_min_state)
|
||||
{
|
||||
if (cli_dep_count >= CLI_MAX_DEPS) {
|
||||
return;
|
||||
}
|
||||
struct Cli_Dep *d = &cli_deps[cli_dep_count++];
|
||||
d->resource = resource;
|
||||
d->blocked_below = blocked_below;
|
||||
d->dep = dep;
|
||||
d->dep_min_state = dep_min_state;
|
||||
}
|
||||
|
||||
static void print_blocked_info(const struct Rec_Resource *res) {
|
||||
int wanted = reconciler_get_wanted(res);
|
||||
int current = reconciler_get_current(res);
|
||||
(void)current;
|
||||
|
||||
for (int i = 0; i < cli_dep_count; i++) {
|
||||
struct Cli_Dep *d = &cli_deps[i];
|
||||
if (d->resource != res) {
|
||||
continue;
|
||||
}
|
||||
if (wanted < d->blocked_below) {
|
||||
continue;
|
||||
}
|
||||
if (reconciler_get_current(d->dep) < d->dep_min_state) {
|
||||
printf(" [blocked: %s < ", reconciler_get_name(d->dep));
|
||||
print_state(d->dep, d->dep_min_state);
|
||||
printf("]");
|
||||
return;
|
||||
}
|
||||
}
|
||||
print_blocked_reason(res);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Command implementations
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
#define MAX_RESOURCES 8
|
||||
|
||||
static struct Rec_Resource *all_resources[MAX_RESOURCES];
|
||||
static int resource_count = 0;
|
||||
|
||||
static struct Rec_Resource *find_resource(const char *name) {
|
||||
for (int i = 0; i < resource_count; i++) {
|
||||
if (strcmp(reconciler_get_name(all_resources[i]), name) == 0) {
|
||||
return all_resources[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int parse_state(const struct Rec_Resource *res, const char *token) {
|
||||
/* Try numeric first. */
|
||||
char *end;
|
||||
long n = strtol(token, &end, 10);
|
||||
if (*end == '\0') {
|
||||
return (int)n;
|
||||
}
|
||||
|
||||
/* Try case-insensitive name match. */
|
||||
int state_count = 0;
|
||||
/* Iterate states 0..N-1 using reconciler_state_name. We don't know
|
||||
* state_count without internal access, so scan until NULL. */
|
||||
for (int i = 0; i < 64; i++) {
|
||||
const char *sname = reconciler_state_name(res, i);
|
||||
if (sname == NULL) {
|
||||
break;
|
||||
}
|
||||
state_count++;
|
||||
/* Case-insensitive compare. */
|
||||
int match = 1;
|
||||
size_t tlen = strlen(token);
|
||||
size_t slen = strlen(sname);
|
||||
if (tlen != slen) {
|
||||
match = 0;
|
||||
} else {
|
||||
for (size_t j = 0; j < tlen; j++) {
|
||||
if (tolower((unsigned char)token[j]) != tolower((unsigned char)sname[j])) {
|
||||
match = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
(void)state_count;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void cmd_status(void) {
|
||||
for (int i = 0; i < resource_count; i++) {
|
||||
const struct Rec_Resource *res = all_resources[i];
|
||||
int current = reconciler_get_current(res);
|
||||
int wanted = reconciler_get_wanted(res);
|
||||
Rec_Status status = reconciler_get_status(res);
|
||||
|
||||
printf(" %-12s ", reconciler_get_name(res));
|
||||
print_state(res, current);
|
||||
printf(" wanted ");
|
||||
print_state(res, wanted);
|
||||
printf(" [%s]", status_label(status));
|
||||
|
||||
if (status == REC_STATUS_BLOCKED) {
|
||||
print_blocked_info(res);
|
||||
}
|
||||
|
||||
printf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
static void cmd_want(const char *name, const char *state_token) {
|
||||
struct Rec_Resource *res = find_resource(name);
|
||||
if (res == NULL) {
|
||||
printf("unknown resource: %s\n", name);
|
||||
return;
|
||||
}
|
||||
|
||||
int state = parse_state(res, state_token);
|
||||
if (state < 0) {
|
||||
printf("unknown state: %s\n", state_token);
|
||||
return;
|
||||
}
|
||||
|
||||
reconciler_set_wanted(res, state);
|
||||
printf(" %s wanted -> ", name);
|
||||
print_state(res, state);
|
||||
printf("\n");
|
||||
}
|
||||
|
||||
static void cmd_tick(struct Reconciler *r) {
|
||||
int n = reconciler_tick(r);
|
||||
if (n == 0) {
|
||||
printf(" (no transitions)\n");
|
||||
}
|
||||
}
|
||||
|
||||
static void cmd_run(struct Reconciler *r) {
|
||||
int max_ticks = 20;
|
||||
int tick = 0;
|
||||
|
||||
while (!reconciler_is_stable(r) && tick < max_ticks) {
|
||||
printf("tick %d:\n", tick + 1);
|
||||
int n = reconciler_tick(r);
|
||||
if (n == 0) {
|
||||
printf(" (no progress — stopping)\n");
|
||||
break;
|
||||
}
|
||||
tick++;
|
||||
}
|
||||
|
||||
if (reconciler_is_stable(r)) {
|
||||
printf("stable after %d tick(s)\n", tick);
|
||||
} else if (tick >= max_ticks) {
|
||||
printf("reached max ticks (%d) without stabilising\n", max_ticks);
|
||||
}
|
||||
}
|
||||
|
||||
static void cmd_fail(const char *name, struct Sim_State sim_states[], int sim_count) {
|
||||
for (int i = 0; i < sim_count; i++) {
|
||||
if (strcmp(sim_states[i].name, name) == 0) {
|
||||
sim_states[i].fail_next = 1;
|
||||
printf(" next action for %s will fail\n", name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
printf("unknown resource: %s\n", name);
|
||||
}
|
||||
|
||||
static void cmd_help(void) {
|
||||
printf("commands:\n");
|
||||
printf(" status print all resources with current/wanted state and status\n");
|
||||
printf(" want <name> <state> set wanted state (by number or name, case-insensitive)\n");
|
||||
printf(" tick run one reconciler tick\n");
|
||||
printf(" run tick until stable (max 20 ticks)\n");
|
||||
printf(" fail <name> make the next action for this resource fail\n");
|
||||
printf(" help show this help\n");
|
||||
printf(" quit / exit exit\n");
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Main
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
int main(void) {
|
||||
struct Reconciler *r = reconciler_create();
|
||||
reconciler_set_log(r, on_log, NULL);
|
||||
|
||||
/* Device resource. */
|
||||
static const struct Rec_Transition device_trans[] = {
|
||||
{0, 1, device_open},
|
||||
{1, 0, device_close},
|
||||
{1, 2, device_start},
|
||||
{2, 1, device_stop},
|
||||
{-1, -1, NULL}
|
||||
};
|
||||
static const char *device_states[] = {"CLOSED", "OPEN", "STREAMING"};
|
||||
|
||||
/* Transport resource. */
|
||||
static const struct Rec_Transition transport_trans[] = {
|
||||
{0, 1, transport_connect},
|
||||
{1, 0, transport_disconnect},
|
||||
{-1, -1, NULL}
|
||||
};
|
||||
static const char *transport_states[] = {"DISCONNECTED", "CONNECTED"};
|
||||
|
||||
/* Stream resource. */
|
||||
static const struct Rec_Transition stream_trans[] = {
|
||||
{0, 1, stream_activate},
|
||||
{1, 0, stream_deactivate},
|
||||
{-1, -1, NULL}
|
||||
};
|
||||
static const char *stream_states[] = {"INACTIVE", "ACTIVE"};
|
||||
|
||||
/* Sim userdata — indexed to match resource order. */
|
||||
static struct Sim_State sim_states[] = {
|
||||
{"device", 0},
|
||||
{"transport", 0},
|
||||
{"stream", 0},
|
||||
};
|
||||
|
||||
struct Rec_Resource *device = reconciler_add_resource(r,
|
||||
"device", device_trans, 3, device_states, 0, &sim_states[0]);
|
||||
|
||||
struct Rec_Resource *transport = reconciler_add_resource(r,
|
||||
"transport", transport_trans, 2, transport_states, 0, &sim_states[1]);
|
||||
|
||||
struct Rec_Resource *stream = reconciler_add_resource(r,
|
||||
"stream", stream_trans, 2, stream_states, 0, &sim_states[2]);
|
||||
|
||||
/* Dependencies. */
|
||||
/* transport cannot reach CONNECTED(1) unless device >= OPEN(1). */
|
||||
reconciler_add_dep(transport, 1, device, 1);
|
||||
cli_add_dep(transport, 1, device, 1);
|
||||
|
||||
/* stream cannot reach ACTIVE(1) unless transport >= CONNECTED(1). */
|
||||
reconciler_add_dep(stream, 1, transport, 1);
|
||||
cli_add_dep(stream, 1, transport, 1);
|
||||
|
||||
/* stream cannot reach ACTIVE(1) unless device >= STREAMING(2). */
|
||||
reconciler_add_dep(stream, 1, device, 2);
|
||||
cli_add_dep(stream, 1, device, 2);
|
||||
|
||||
/* Register resources for lookup. */
|
||||
all_resources[resource_count++] = device;
|
||||
all_resources[resource_count++] = transport;
|
||||
all_resources[resource_count++] = stream;
|
||||
|
||||
/* Welcome. */
|
||||
printf("reconciler_cli — interactive declarative state machine demo\n\n");
|
||||
cmd_help();
|
||||
printf("\n");
|
||||
cmd_status();
|
||||
printf("\n");
|
||||
|
||||
/* REPL. */
|
||||
char line[256];
|
||||
while (1) {
|
||||
printf("> ");
|
||||
fflush(stdout);
|
||||
|
||||
if (fgets(line, sizeof(line), stdin) == NULL) {
|
||||
break;
|
||||
}
|
||||
|
||||
/* Strip trailing newline. */
|
||||
size_t len = strlen(line);
|
||||
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
|
||||
line[--len] = '\0';
|
||||
}
|
||||
|
||||
/* Tokenise. */
|
||||
char *tokens[4];
|
||||
int ntok = 0;
|
||||
char *p = line;
|
||||
|
||||
while (*p != '\0' && ntok < 4) {
|
||||
while (*p == ' ' || *p == '\t') {
|
||||
p++;
|
||||
}
|
||||
if (*p == '\0') {
|
||||
break;
|
||||
}
|
||||
tokens[ntok++] = p;
|
||||
while (*p != '\0' && *p != ' ' && *p != '\t') {
|
||||
p++;
|
||||
}
|
||||
if (*p != '\0') {
|
||||
*p++ = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
if (ntok == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const char *cmd = tokens[0];
|
||||
|
||||
if (strcmp(cmd, "quit") == 0 || strcmp(cmd, "exit") == 0) {
|
||||
break;
|
||||
} else if (strcmp(cmd, "help") == 0) {
|
||||
cmd_help();
|
||||
} else if (strcmp(cmd, "status") == 0) {
|
||||
cmd_status();
|
||||
} else if (strcmp(cmd, "tick") == 0) {
|
||||
cmd_tick(r);
|
||||
} else if (strcmp(cmd, "run") == 0) {
|
||||
cmd_run(r);
|
||||
} else if (strcmp(cmd, "want") == 0) {
|
||||
if (ntok < 3) {
|
||||
printf("usage: want <name> <state>\n");
|
||||
} else {
|
||||
cmd_want(tokens[1], tokens[2]);
|
||||
}
|
||||
} else if (strcmp(cmd, "fail") == 0) {
|
||||
if (ntok < 2) {
|
||||
printf("usage: fail <name>\n");
|
||||
} else {
|
||||
cmd_fail(tokens[1], sim_states, 3);
|
||||
}
|
||||
} else {
|
||||
printf("unknown command: %s (type 'help' for commands)\n", cmd);
|
||||
}
|
||||
}
|
||||
|
||||
reconciler_destroy(r);
|
||||
return 0;
|
||||
}
|
||||
80
docs/cli/config_cli.md
Normal file
80
docs/cli/config_cli.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# config_cli
|
||||
|
||||
A development tool for loading and inspecting INI configuration files against the node's schema. Useful for verifying that a config file parses correctly and seeing what values would be applied.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Load a config file
|
||||
|
||||
```sh
|
||||
./config_cli <file>
|
||||
```
|
||||
|
||||
Parses the file and prints all resolved values. Values absent from the file are filled with schema defaults.
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
./config_cli /etc/video-node/node.cfg
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
[node]
|
||||
name = camera:0
|
||||
site_id = 0
|
||||
tcp_port = 8000
|
||||
function = source
|
||||
|
||||
[discovery]
|
||||
interval_ms = 5000
|
||||
timeout_intervals = 3
|
||||
|
||||
[transport]
|
||||
max_connections = 16
|
||||
```
|
||||
|
||||
### Print schema defaults
|
||||
|
||||
```sh
|
||||
./config_cli --defaults
|
||||
```
|
||||
|
||||
Prints all keys with their default values and types, without reading any file.
|
||||
|
||||
---
|
||||
|
||||
## Config Schema
|
||||
|
||||
| Section | Key | Type | Default |
|
||||
|---|---|---|---|
|
||||
| `node` | `name` | string | `unnamed:0` |
|
||||
| `node` | `site_id` | uint16 | `0` |
|
||||
| `node` | `tcp_port` | uint16 | `0` (auto) |
|
||||
| `node` | `function` | flags | `source` |
|
||||
| `discovery` | `interval_ms` | uint32 | `5000` |
|
||||
| `discovery` | `timeout_intervals` | uint32 | `3` |
|
||||
| `transport` | `max_connections` | uint32 | `16` |
|
||||
|
||||
`function` accepts a comma-separated list of role flags: `source`, `relay`, `sink`, `controller`.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`config_cli` exercises the `config` module used by the video node at startup. Both the node binary and `config_cli` share the same schema definition, so this tool is an accurate preview of how the node will interpret a config file.
|
||||
172
docs/cli/controller_cli.md
Normal file
172
docs/cli/controller_cli.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# controller_cli
|
||||
|
||||
An interactive REPL for controlling video nodes. Connects to a running node over TCP and lets you enumerate devices and controls, adjust camera parameters, start and stop ingest streams, and open and close display windows. Uses readline for line editing and history.
|
||||
|
||||
> **Note:** `controller_cli` is a temporary development tool. The long-term replacement is a dedicated `controller` binary that maintains simultaneous connections to all discovered nodes rather than switching between them.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`. Requires `libreadline`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./controller_cli [--host HOST] [--port PORT]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--host HOST` | — | Connect to this host on startup |
|
||||
| `--port PORT` | `8000` | TCP port to use with `--host` |
|
||||
|
||||
Without `--host`, the tool starts in discovery mode and waits for nodes to appear. Connect to a discovered node from the REPL using the `connect` command.
|
||||
|
||||
---
|
||||
|
||||
## REPL Commands
|
||||
|
||||
### Discovery
|
||||
|
||||
```
|
||||
peers
|
||||
```
|
||||
|
||||
List all currently discovered nodes with their index, name, host, and port.
|
||||
|
||||
```
|
||||
connect [idx | host:port]
|
||||
```
|
||||
|
||||
Connect to a node. With no argument, connects to the first discovered peer. Examples:
|
||||
|
||||
```
|
||||
connect # first discovered peer
|
||||
connect 1 # peer at index 1 in the peers list
|
||||
connect 192.168.1.42:8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Device enumeration
|
||||
|
||||
```
|
||||
enum-devices
|
||||
```
|
||||
|
||||
List all devices on the connected node: media controller devices and their video nodes (with device index), standalone V4L2 devices, and active display windows. Each entry shows its flat device index `[N]` for use in control commands.
|
||||
|
||||
```
|
||||
enum-controls <device_index>
|
||||
```
|
||||
|
||||
List all controls for a device. For V4L2 devices this returns camera parameters; for display windows it returns display controls.
|
||||
|
||||
**Display device controls:**
|
||||
|
||||
| ID | Name | Range | Description |
|
||||
|---|---|---|---|
|
||||
| `0x00D00001` | Scale | 0–3 | 0=stretch 1=fit 2=fill 3=1:1 |
|
||||
| `0x00D00002` | Anchor | 0–1 | 0=center 1=topleft |
|
||||
| `0x00D00003` | No-signal FPS | 1–60 | No-signal animation frame rate |
|
||||
|
||||
---
|
||||
|
||||
### Control get/set
|
||||
|
||||
```
|
||||
get-control <device_index> <control_id_hex>
|
||||
set-control <device_index> <control_id_hex> <value>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
get-control 0 0x00980900 # read Brightness on device 0
|
||||
set-control 0 0x00980900 200 # set Brightness to 200
|
||||
set-control 3 0x00D00001 1 # set Scale to 'fit' on display device 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Ingest (source node)
|
||||
|
||||
```
|
||||
start-ingest <stream_id> <device_path> <dest_host> <dest_port>
|
||||
[format] [width] [height] [fps_n] [fps_d]
|
||||
```
|
||||
|
||||
Tell the connected node to open a V4L2 device and stream to `dest_host:dest_port`. Format, resolution, and frame rate default to auto-select (best MJPEG) when omitted.
|
||||
|
||||
```
|
||||
stop-ingest <stream_id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Display (sink node)
|
||||
|
||||
```
|
||||
start-display <stream_id> [win_x] [win_y] [win_w] [win_h] [no_signal_fps]
|
||||
```
|
||||
|
||||
Tell the connected node to open a display window for `stream_id`. Position and size default to `0,0 1280×720`. No-signal FPS defaults to 15.
|
||||
|
||||
```
|
||||
stop-display <stream_id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Other
|
||||
|
||||
```
|
||||
help show command list
|
||||
quit exit
|
||||
exit exit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example session
|
||||
|
||||
```
|
||||
$ ./controller_cli
|
||||
listening for nodes...
|
||||
[discovered 0] camera:0 192.168.1.42:8000
|
||||
> connect
|
||||
connected to 192.168.1.42:8000
|
||||
> enum-devices
|
||||
devices:
|
||||
media /dev/media0 driver=unicam model=unicam ... (1 video node)
|
||||
[0] video /dev/video0 entity=unicam-image ... [capture]
|
||||
> start-ingest 1 /dev/video0 192.168.1.55 8001
|
||||
ok
|
||||
> connect 1
|
||||
[discovered 1] display:0 192.168.1.55:8000
|
||||
connected to 192.168.1.55:8000
|
||||
> start-display 1
|
||||
ok
|
||||
> enum-devices
|
||||
devices:
|
||||
[3] display stream=1 pos=0,0 size=1280x720 scale=stretch anchor=center
|
||||
> set-control 3 0x00D00001 1
|
||||
ok
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`controller_cli` is the primary human interface for the video routing system during development. It exercises the full control channel: discovery, TCP transport, and every protocol command type. It is the reference implementation for how a controller interacts with nodes.
|
||||
|
||||
See also: [`query_cli.md`](query_cli.md) for a simpler read-only query; [`stream_send_cli.md`](stream_send_cli.md) / [`stream_recv_cli.md`](stream_recv_cli.md) for testing streams without a full node.
|
||||
53
docs/cli/discovery_cli.md
Normal file
53
docs/cli/discovery_cli.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# discovery_cli
|
||||
|
||||
A development tool for testing the discovery layer. Announces a node on the local network via UDP multicast and prints peers as they appear and disappear.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
discovery_cli <name> <tcp_port> [flags]
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
|---|---|
|
||||
| `name` | Node name in `namespace:instance` form, e.g. `v4l2:microscope` |
|
||||
| `tcp_port` | The TCP port this node listens on for transport connections |
|
||||
| `flags` | Comma-separated role list: `source`, `relay`, `sink`, `controller` (default: `source`) |
|
||||
|
||||
Example — announce a source node and watch for peers:
|
||||
|
||||
```sh
|
||||
./discovery_cli camera:0 8000 source
|
||||
```
|
||||
|
||||
Example output as peers are found and lost:
|
||||
|
||||
```
|
||||
found: unnamed:0 192.168.1.42:8001 site=0 flags=source
|
||||
found: display:0 192.168.1.55:8002 site=0 flags=sink
|
||||
lost: unnamed:0 192.168.1.42:8001
|
||||
```
|
||||
|
||||
Run two instances on the same machine (different ports) to verify mutual discovery.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`discovery_cli` exercises the `discovery` module, which the video node uses to find peers on the LAN without configuration. The node starts a discovery listener on startup; controllers use discovered peer tables to locate nodes before connecting.
|
||||
|
||||
See also: [`query_cli.md`](query_cli.md) for a combined discovery + protocol query.
|
||||
64
docs/cli/protocol_cli.md
Normal file
64
docs/cli/protocol_cli.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# protocol_cli
|
||||
|
||||
A development tool for testing the protocol layer in isolation. Runs a server that decodes and prints all incoming control messages, or a client that connects and sends a sample STREAM_OPEN request.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Server mode
|
||||
|
||||
```sh
|
||||
./protocol_cli --server [port]
|
||||
```
|
||||
|
||||
Default port: `8000`. Listens for connections and prints a decoded description of every received frame.
|
||||
|
||||
Example output on an incoming STREAM_OPEN:
|
||||
|
||||
```
|
||||
CONTROL_REQUEST request_id=1 STREAM_OPEN stream_id=1 format=0x0001 pixel_format=0x0000 origin=0x0001
|
||||
```
|
||||
|
||||
Recognised message types and commands:
|
||||
|
||||
| Type | Description |
|
||||
|---|---|
|
||||
| `VIDEO_FRAME` | stream_id + compressed payload |
|
||||
| `STREAM_EVENT` | stream_id + event code (INTERRUPTED / RESUMED) |
|
||||
| `CONTROL_REQUEST` | request_id + command (STREAM_OPEN, STREAM_CLOSE, ENUM_DEVICES, ENUM_CONTROLS, GET_CONTROL, SET_CONTROL, ENUM_MONITORS) |
|
||||
| `CONTROL_RESPONSE` | request_id + status (OK, ERROR, UNKNOWN_CMD, INVALID_PARAMS, NOT_FOUND) |
|
||||
|
||||
### Client mode
|
||||
|
||||
```sh
|
||||
./protocol_cli --client <host> <port>
|
||||
```
|
||||
|
||||
Connects to the server and sends a single STREAM_OPEN request, then waits for the response.
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
./protocol_cli --client 127.0.0.1 8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`protocol_cli` exercises the `protocol` module that all nodes use to encode and decode wire messages. Pairing the server with a running node lets you inspect raw control traffic; pairing the client with a running node lets you inject individual commands for debugging.
|
||||
|
||||
See also: [`transport_cli.md`](transport_cli.md) for raw frame-level testing without protocol parsing.
|
||||
64
docs/cli/query_cli.md
Normal file
64
docs/cli/query_cli.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# query_cli
|
||||
|
||||
An integration smoke test that combines discovery and protocol. Waits for a video node to appear on the local network, then sends an ENUM_DEVICES query and prints the results. Optionally enumerates controls on a specific device.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./query_cli [--timeout ms] [--controls device_index]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--timeout ms` | `5000` | How long to wait for a node to appear (milliseconds) |
|
||||
| `--controls idx` | — | After enumeration, also query controls for this device index |
|
||||
|
||||
Example — query the first node found within 5 s:
|
||||
|
||||
```sh
|
||||
./query_cli
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
discovered: camera:0 192.168.1.42:8000
|
||||
querying devices...
|
||||
media /dev/media0 driver=unicam model=unicam bus=platform:fe801000.csi (1 video node)
|
||||
[0] video /dev/video0 entity=unicam-image type=0x00010001 caps=[video-capture] [capture]
|
||||
[1] standalone /dev/video1 card=USB Camera
|
||||
```
|
||||
|
||||
Example with controls:
|
||||
|
||||
```sh
|
||||
./query_cli --controls 1
|
||||
```
|
||||
|
||||
```
|
||||
ctrl id=0x00980900 Brightness int min=0 max=255 step=1 default=128 current=127
|
||||
ctrl id=0x00980901 Contrast int min=0 max=255 step=1 default=128 current=128
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`query_cli` is an end-to-end integration test: it exercises discovery, transport, and protocol in one shot. It is the simplest way to verify that a node is reachable and responding correctly to control requests before using `controller_cli` for full interaction.
|
||||
|
||||
See also: [`discovery_cli.md`](discovery_cli.md), [`controller_cli.md`](controller_cli.md).
|
||||
83
docs/cli/reconciler_cli.md
Normal file
83
docs/cli/reconciler_cli.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# reconciler_cli
|
||||
|
||||
An interactive REPL for exploring the reconciler module. Sets up a simulated three-resource state machine (device, transport, stream) with declared dependencies and lets you drive reconciliation manually — useful for understanding reconciler behaviour before wiring it into the node.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./reconciler_cli
|
||||
```
|
||||
|
||||
Drops into an interactive prompt:
|
||||
|
||||
```
|
||||
reconciler> _
|
||||
```
|
||||
|
||||
### Demo resources
|
||||
|
||||
Three resources are pre-configured:
|
||||
|
||||
| Resource | States | Notes |
|
||||
|---|---|---|
|
||||
| `device` | CLOSED → OPEN → STREAMING | Simulates a V4L2 device |
|
||||
| `transport` | DISCONNECTED → CONNECTED | Depends on device=OPEN |
|
||||
| `stream` | INACTIVE → ACTIVE | Depends on transport=CONNECTED and device=STREAMING |
|
||||
|
||||
Dependencies are checked before each transition: the reconciler will not advance a resource until all its prerequisites are met. Blocked resources show the unmet dependency in the status output.
|
||||
|
||||
### Commands
|
||||
|
||||
```
|
||||
status print all resources with current/wanted state and transition status
|
||||
want <name> <state> set the desired state (by number or case-insensitive name)
|
||||
tick run one reconciler tick
|
||||
run tick until stable (max 20 ticks) or a failure occurs
|
||||
fail <name> make the next action for this resource fail (simulates an error)
|
||||
help show commands
|
||||
quit / exit exit
|
||||
```
|
||||
|
||||
### Example session
|
||||
|
||||
```
|
||||
reconciler> want device STREAMING
|
||||
reconciler> want transport CONNECTED
|
||||
reconciler> want stream ACTIVE
|
||||
reconciler> run
|
||||
[device] CLOSED → OPEN ok
|
||||
[device] OPEN → STREAMING ok
|
||||
[transport] DISCONNECTED → CONNECTED ok
|
||||
[stream] INACTIVE → ACTIVE ok
|
||||
reconciler> status
|
||||
device current=STREAMING wanted=STREAMING
|
||||
transport current=CONNECTED wanted=CONNECTED
|
||||
stream current=ACTIVE wanted=ACTIVE
|
||||
reconciler> fail transport
|
||||
reconciler> tick
|
||||
[transport] CONNECTED → DISCONNECTED error (simulated)
|
||||
reconciler> status
|
||||
device current=STREAMING wanted=STREAMING
|
||||
transport current=DISCONNECTED wanted=CONNECTED (will retry)
|
||||
stream current=ACTIVE wanted=ACTIVE (blocked: transport != CONNECTED)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
The reconciler module manages device open/close and transport connect/disconnect in the video node. `reconciler_cli` lets you exercise the BFS pathfinding, dependency checking, and event-driven tick logic without any real hardware — the same code paths that run in the node on every incoming command or timer event.
|
||||
67
docs/cli/stream_recv_cli.md
Normal file
67
docs/cli/stream_recv_cli.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# stream_recv_cli
|
||||
|
||||
Listens for an incoming TCP stream of `VIDEO_FRAME` protocol messages and displays the frames in an X11 window with a per-stream fps/Mbps overlay. Pair with [`stream_send_cli`](stream_send_cli.md) to test end-to-end video transport without a full node.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
Requires GLFW, OpenGL, and libjpeg-turbo.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./stream_recv_cli [--port PORT] [--stream-id N]
|
||||
[--scale stretch|fit|fill|1:1]
|
||||
[--anchor center|topleft]
|
||||
[--x N] [--y N]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--port PORT` | `7700` | TCP port to listen on |
|
||||
| `--stream-id N` | `0` | Filter by stream ID; `0` accepts any stream |
|
||||
| `--scale` | `fit` | Frame scaling in window |
|
||||
| `--anchor` | `center` | Frame alignment in window |
|
||||
| `--x N` | `0` | Window X position |
|
||||
| `--y N` | `0` | Window Y position |
|
||||
|
||||
Press **Q** or **Escape** to close the window.
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
./stream_recv_cli --port 7700 --scale fit
|
||||
```
|
||||
|
||||
### Statistics overlay
|
||||
|
||||
Updated every 0.5 seconds:
|
||||
|
||||
```
|
||||
stream 1: 30.1 fps 18.3 Mbps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture note
|
||||
|
||||
The tool is multi-threaded: transport receive runs on a background thread and deposits frames into a shared slot (mutex + condition variable); the main thread owns the GLFW/OpenGL context and pulls from that slot on each render cycle. This is the same pattern used by the node's display sink.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`stream_recv_cli` tests the sink end of the display pipeline: `VIDEO_FRAME` receive → MJPEG decode → xorg render. The node's display sink (started via `START_DISPLAY`) performs the same operation, driven by the control channel.
|
||||
|
||||
See also: [`stream_send_cli.md`](stream_send_cli.md) for the send side; [`controller_cli.md`](controller_cli.md) to use the full node pipeline.
|
||||
64
docs/cli/stream_send_cli.md
Normal file
64
docs/cli/stream_send_cli.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# stream_send_cli
|
||||
|
||||
Captures MJPEG from a V4L2 device and streams it to a receiver over TCP as `VIDEO_FRAME` protocol messages. Prints per-stream throughput statistics. Pair with [`stream_recv_cli`](stream_recv_cli.md) to test an end-to-end stream without running a full node.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./stream_send_cli [--device PATH] [--host HOST] [--port PORT] [--stream-id N]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--device PATH` | `/dev/video0` | V4L2 capture device |
|
||||
| `--host HOST` | `127.0.0.1` | Receiver hostname or IP |
|
||||
| `--port PORT` | `7700` | Receiver TCP port |
|
||||
| `--stream-id N` | `1` | Stream ID embedded in each `VIDEO_FRAME` message |
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
# Terminal 1 — start a receiver
|
||||
./stream_recv_cli --port 7700
|
||||
|
||||
# Terminal 2 — start the sender
|
||||
./stream_send_cli --device /dev/video0 --host 127.0.0.1 --port 7700 --stream-id 1
|
||||
```
|
||||
|
||||
### Statistics output
|
||||
|
||||
Printed to stderr every 0.5 seconds:
|
||||
|
||||
```
|
||||
stream 1: 30.2 fps 18.4 Mbps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The tool opens the device and selects the highest-FPS MJPEG mode automatically.
|
||||
- Frames are sent as-is (raw MJPEG from the kernel) wrapped in `VIDEO_FRAME` messages — no re-encoding.
|
||||
- The connection is outbound: `stream_send_cli` connects to the receiver, not the other way around. This mirrors the node's START_INGEST behaviour.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`stream_send_cli` tests the source end of the ingest pipeline: V4L2 capture → transport send → `VIDEO_FRAME` messages. The node's ingest module performs the same operation, but driven by `START_INGEST` commands over the control channel.
|
||||
|
||||
See also: [`stream_recv_cli.md`](stream_recv_cli.md) for the receive side; [`v4l2_view_cli.md`](v4l2_view_cli.md) for local-only viewing.
|
||||
70
docs/cli/test_image_cli.md
Normal file
70
docs/cli/test_image_cli.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# test_image_cli
|
||||
|
||||
A development tool for generating test images and writing them to PPM files. Used to verify the `test_image` module's pattern generators and pixel format output without needing a camera or display.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./test_image_cli [--pattern bars|ramp|grid]
|
||||
[--width N] [--height N]
|
||||
[--format yuv420|yuv422|bgra]
|
||||
--out FILE.ppm
|
||||
```
|
||||
|
||||
All options are optional except `--out`.
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--pattern` | `bars` | Test pattern to generate |
|
||||
| `--width N` | `1280` | Image width in pixels |
|
||||
| `--height N` | `720` | Image height in pixels |
|
||||
| `--format` | `yuv420` | Internal pixel format before PPM conversion |
|
||||
| `--out FILE` | required | Output file path |
|
||||
|
||||
### Patterns
|
||||
|
||||
| Name | Description |
|
||||
|---|---|
|
||||
| `bars` | Colour bars (SMPTE-style) |
|
||||
| `ramp` | Luminance ramp from black to white |
|
||||
| `grid` | Crosshatch grid |
|
||||
|
||||
### Formats
|
||||
|
||||
| Name | Description |
|
||||
|---|---|
|
||||
| `yuv420` | Planar YUV 4:2:0 |
|
||||
| `yuv422` | Packed YUV 4:2:2 |
|
||||
| `bgra` | Packed BGRA 8-bit |
|
||||
|
||||
The output is always written as a PPM (RGB) file regardless of format; the internal format affects how the pattern is generated and converted.
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
./test_image_cli --pattern bars --width 1920 --height 1080 --format yuv420 --out bars.ppm
|
||||
```
|
||||
|
||||
Open with any image viewer that supports PPM (e.g. `feh`, `eog`, GIMP).
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`test_image_cli` exercises the `test_image` module used by development tools to inject synthetic frames without a camera. The same module drives the xorg test pattern display in `xorg_cli`.
|
||||
|
||||
See also: [`xorg_cli.md`](xorg_cli.md) for live window rendering.
|
||||
69
docs/cli/transport_cli.md
Normal file
69
docs/cli/transport_cli.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# transport_cli
|
||||
|
||||
A development tool for exercising the transport layer. Starts a framed-TCP server (which echoes received frames) or a client that connects and sends a sequence of test frames.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Server mode
|
||||
|
||||
Listen on a port and echo back every frame received:
|
||||
|
||||
```sh
|
||||
./transport_cli server <port> [max_connections]
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
./transport_cli server 8000
|
||||
```
|
||||
|
||||
Output (per received frame):
|
||||
|
||||
```
|
||||
received frame: type=0x0001 len=8
|
||||
```
|
||||
|
||||
### Client mode
|
||||
|
||||
Connect to a server and send three test frames:
|
||||
|
||||
```sh
|
||||
./transport_cli client <host> <port>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
./transport_cli client 127.0.0.1 8000
|
||||
```
|
||||
|
||||
Each frame carries a fixed payload with a counter:
|
||||
|
||||
```
|
||||
frame 0: payload = deadbeef 00000000
|
||||
frame 1: payload = deadbeef 00000001
|
||||
frame 2: payload = deadbeef 00000002
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`transport_cli` exercises the `transport` module, which provides the framed TCP stream used by all node-to-node communication. The frame header (message type + payload length) and single-write send are the building blocks for the protocol layer.
|
||||
|
||||
See also: [`protocol_cli.md`](protocol_cli.md) for typed message-level testing.
|
||||
69
docs/cli/v4l2_view_cli.md
Normal file
69
docs/cli/v4l2_view_cli.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# v4l2_view_cli
|
||||
|
||||
A live camera viewer. Opens a V4L2 capture device, selects the best available format, and displays the video stream in an X11 window with a real-time FPS and format overlay. Bypasses the node system entirely — useful for verifying a camera works before wiring it into a node.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
Requires GLFW, OpenGL, libjpeg-turbo, and a V4L2-capable kernel.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./v4l2_view_cli [--device PATH]
|
||||
[--width N --height N]
|
||||
[--format mjpeg|yuyv]
|
||||
[--scale stretch|fit|fill|1:1]
|
||||
[--anchor center|topleft]
|
||||
[--x N] [--y N]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--device PATH` | `/dev/video0` | V4L2 device to open |
|
||||
| `--width N` | auto | Capture width; if omitted, selects highest-FPS mode |
|
||||
| `--height N` | auto | Capture height |
|
||||
| `--format` | auto | Prefer `mjpeg` or `yuyv`; auto selects best available |
|
||||
| `--scale` | `fit` | Frame scaling in window |
|
||||
| `--anchor` | `center` | Frame alignment in window |
|
||||
| `--x N` | `0` | Window X position |
|
||||
| `--y N` | `0` | Window Y position |
|
||||
|
||||
Press **Q** or **Escape** to close the window.
|
||||
|
||||
### Auto format selection
|
||||
|
||||
Without `--width`/`--height`, the tool selects the format with the highest frame rate, and within that the largest resolution. This is the same logic the node's ingest module uses.
|
||||
|
||||
### Overlay
|
||||
|
||||
Every 0.5 seconds the overlay updates with:
|
||||
|
||||
```
|
||||
MJPEG 1280x720 @ 30.0 fps
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
./v4l2_view_cli --device /dev/video0 --scale fit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`v4l2_view_cli` is a standalone sanity-check tool. It exercises the same V4L2 format enumeration, mmap capture, MJPEG decode, and xorg rendering path that the node's ingest + display pipeline uses — but without any transport or protocol overhead.
|
||||
|
||||
See also: [`stream_send_cli.md`](stream_send_cli.md) to capture and send over the network; [`xorg_cli.md`](xorg_cli.md) for static test patterns.
|
||||
73
docs/cli/xorg_cli.md
Normal file
73
docs/cli/xorg_cli.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# xorg_cli
|
||||
|
||||
A development tool for testing the xorg viewer sink. Opens an X11 window using GLFW/OpenGL, renders a test pattern at the chosen scale and anchor, and displays a text overlay showing the current date. The window stays open until the user presses Q, Escape, or closes it.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
Requires the GLFW, OpenGL, and libjpeg-turbo libraries. If compiled without `HAVE_GLFW`, the binary will print an error and exit.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./xorg_cli [--pattern bars|ramp|grid]
|
||||
[--width N] [--height N]
|
||||
[--format yuv420|bgra]
|
||||
[--scale stretch|fit|fill|1:1]
|
||||
[--anchor center|topleft]
|
||||
[--x N] [--y N]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--pattern` | `bars` | Test pattern to render |
|
||||
| `--width N` | `1280` | Window width in pixels |
|
||||
| `--height N` | `720` | Window height in pixels |
|
||||
| `--format` | `yuv420` | Frame pixel format |
|
||||
| `--scale` | `stretch` | How the frame fills the window |
|
||||
| `--anchor` | `center` | Frame alignment within the window |
|
||||
| `--x N` | `0` | Window X position on screen |
|
||||
| `--y N` | `0` | Window Y position on screen |
|
||||
|
||||
### Scale modes
|
||||
|
||||
| Mode | Description |
|
||||
|---|---|
|
||||
| `stretch` | Fill the window, ignoring aspect ratio |
|
||||
| `fit` | Largest rect that fits, preserving aspect ratio (black bars) |
|
||||
| `fill` | Smallest rect that covers, preserving aspect ratio (crops edges) |
|
||||
| `1:1` | Native pixel size, no scaling |
|
||||
|
||||
### Anchor modes
|
||||
|
||||
Anchor applies when the frame does not fill the window (fit, fill, 1:1 modes):
|
||||
|
||||
| Mode | Description |
|
||||
|---|---|
|
||||
| `center` | Centre the frame in the window |
|
||||
| `topleft` | Align frame to the top-left corner |
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
./xorg_cli --pattern grid --scale fit --anchor center --width 1920 --height 1080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`xorg_cli` exercises the `xorg` module used by the video node for its display sink role. The same viewer, scale/anchor logic, and text overlay system are used by `stream_recv_cli` and the node's `START_DISPLAY` command.
|
||||
|
||||
See also: [`v4l2_view_cli.md`](v4l2_view_cli.md) for live camera feed display; [`stream_recv_cli.md`](stream_recv_cli.md) for network stream display.
|
||||
@@ -43,11 +43,81 @@ A node may set multiple bits — a relay that also archives sets both `RELAY` an
|
||||
|
||||
### Behaviour
|
||||
|
||||
- Nodes send announcements periodically (e.g. every 5 s) and immediately on startup
|
||||
- Nodes send announcements periodically (default every 5 s) and immediately on startup via multicast
|
||||
- No daemon — the node process itself sends and listens; no background service required
|
||||
- On receiving an announcement, the control plane records the peer (address, port, name, function) and can initiate a transport connection if needed
|
||||
- A node going silent for a configured number of announcement intervals is considered offline
|
||||
- Announcements are informational only — the hub validates identity at connection time
|
||||
- On receiving an announcement the node records the peer (address, port, name, capabilities) and can initiate a transport connection if needed
|
||||
- A peer that goes silent for `timeout_intervals × interval_ms` is considered offline and removed from the peer table
|
||||
- Announcements are informational only — identity is validated at TCP connection time
|
||||
|
||||
#### Startup — new node joins the network
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant N as New Node
|
||||
participant MC as Multicast group
|
||||
participant A as Node A
|
||||
participant B as Node B
|
||||
|
||||
N->>MC: announce (multicast)
|
||||
MC-->>A: receives announce
|
||||
MC-->>B: receives announce
|
||||
A->>N: announce (unicast reply)
|
||||
B->>N: announce (unicast reply)
|
||||
Note over N,B: All parties now know each other.<br/>Subsequent keepalives are multicast only.
|
||||
```
|
||||
|
||||
Each node that hears a new peer sends a **unicast reply** directly to that peer. This allows the new node to populate its peer table within one round-trip rather than waiting up to `interval_ms` for other nodes' next scheduled broadcast.
|
||||
|
||||
#### Steady-state keepalive
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Node A
|
||||
participant MC as Multicast group
|
||||
participant B as Node B
|
||||
participant C as Node C
|
||||
|
||||
loop every interval_ms
|
||||
A->>MC: announce (multicast)
|
||||
MC-->>B: receives — updates last_seen_ms, no reply
|
||||
MC-->>C: receives — updates last_seen_ms, no reply
|
||||
end
|
||||
```
|
||||
|
||||
Known peers update their `last_seen_ms` timestamp and do nothing else. No reply is sent, so there is no amplification.
|
||||
|
||||
#### Node loss — timeout
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Node A
|
||||
participant B as Node B (offline)
|
||||
|
||||
Note over B: Node B stops sending
|
||||
loop timeout_intervals × interval_ms elapses
|
||||
A->>A: check_timeouts() — not yet expired
|
||||
end
|
||||
A->>A: check_timeouts() — expired, remove B
|
||||
A->>A: on_peer_lost(B) callback
|
||||
```
|
||||
|
||||
#### Node restart — known limitation
|
||||
|
||||
The current implementation attempts to detect a restart by checking whether `site_id` changed for a known `(addr, port)` entry. In practice this **does not work**: `site_id` is a static configuration value and will be the same before and after a restart. A restarted node will therefore simply be treated as a continuing keepalive and will not receive an immediate unicast reply — it will have to wait up to `interval_ms` for the next scheduled multicast broadcast from its peers.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant R as Restarted Node
|
||||
participant MC as Multicast group
|
||||
participant A as Node A
|
||||
|
||||
Note over R: Node restarts — same addr, port, site_id
|
||||
R->>MC: announce (multicast)
|
||||
MC-->>A: receives — site_id unchanged, treated as keepalive
|
||||
Note over A: No unicast reply sent. R waits up to interval_ms<br/>to learn about A via A's next scheduled multicast.
|
||||
```
|
||||
|
||||
**What needs to change:** a **boot nonce** (random `u32` generated at startup, not configured) should be added to the announcement payload. A change in boot nonce for a known peer unambiguously signals a restart and triggers an immediate unicast reply. This requires a wire format version bump and updates to the peer table struct, announcement builder, and receive logic.
|
||||
|
||||
### No Avahi/Bonjour Dependency
|
||||
|
||||
|
||||
110
docs/protocol.md
110
docs/protocol.md
@@ -99,6 +99,10 @@ packet-beta
|
||||
| `0x0005` | `GET_CONTROL` | Get a V4L2 control value |
|
||||
| `0x0006` | `SET_CONTROL` | Set a V4L2 control value |
|
||||
| `0x0007` | `ENUM_MONITORS` | List X11 monitors (XRandR) on the remote node |
|
||||
| `0x0008` | `START_INGEST` | Set wanted state: open V4L2 device, connect outbound, begin streaming |
|
||||
| `0x0009` | `STOP_INGEST` | Set wanted state: stop ingest stream and disconnect |
|
||||
| `0x000A` | `START_DISPLAY` | Open a viewer window on the sink node and display incoming frames for the given stream |
|
||||
| `0x000B` | `STOP_DISPLAY` | Close the viewer window for the given stream |
|
||||
|
||||
### `CONTROL_RESPONSE` (0x0003)
|
||||
|
||||
@@ -412,3 +416,109 @@ packet-beta
|
||||
**Response** — no extra fields beyond request_id and status.
|
||||
|
||||
For `MENU` and `INTEGER_MENU` controls, `value` must be a valid menu item `index` as returned by `ENUM_CONTROLS`.
|
||||
|
||||
### `START_INGEST` (0x0008)
|
||||
|
||||
Sets wanted state on a source node: open the specified V4L2 device, configure the stream format, and connect outbound to the given sink.
|
||||
|
||||
**Request**:
|
||||
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 16}}}%%
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "command = 0x0008"
|
||||
32-47: "stream_id"
|
||||
48-63: "format"
|
||||
64-79: "width"
|
||||
80-95: "height"
|
||||
96-111: "fps_n"
|
||||
112-127: "fps_d"
|
||||
128-143: "dest_port"
|
||||
144-159: "transport_mode"
|
||||
160-167: "device_path_len"
|
||||
168-175: "device_path …"
|
||||
```
|
||||
|
||||
Followed by `dest_host` str8.
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `stream_id` | ID assigned by the controller; used in all subsequent `VIDEO_FRAME` messages |
|
||||
| `format` | Codec format code (see [Codec Formats](#codec-formats)); `0` = auto-select best MJPEG |
|
||||
| `width` | Capture width in pixels; `0` = auto-select |
|
||||
| `height` | Capture height in pixels; `0` = auto-select |
|
||||
| `fps_n` | Frame rate numerator; `0` = auto-select |
|
||||
| `fps_d` | Frame rate denominator |
|
||||
| `dest_port` | TCP port of the sink node to connect to |
|
||||
| `transport_mode` | `0x0001` = encapsulated (framed); `0x0002` = opaque (raw byte stream) |
|
||||
| `device_path` | str8 — path to the V4L2 device, e.g. `/dev/video0` |
|
||||
| `dest_host` | str8 — hostname or IP of the sink node |
|
||||
|
||||
**Response** — no extra fields beyond request_id and status. `OK` means the wanted state was accepted; the node will reconcile asynchronously.
|
||||
|
||||
### `STOP_INGEST` (0x0009)
|
||||
|
||||
Sets wanted state: stop the ingest stream and disconnect from the sink.
|
||||
|
||||
**Request**:
|
||||
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 16}}}%%
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "command = 0x0009"
|
||||
32-47: "stream_id"
|
||||
```
|
||||
|
||||
**Response** — no extra fields beyond request_id and status.
|
||||
|
||||
### `START_DISPLAY` (0x000A)
|
||||
|
||||
Opens a viewer window on a sink node and routes incoming `VIDEO_FRAME` messages for `stream_id` to it.
|
||||
|
||||
**Request**:
|
||||
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 16}}}%%
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "command = 0x000A"
|
||||
32-47: "stream_id"
|
||||
48-63: "win_x (i16)"
|
||||
64-79: "win_y (i16)"
|
||||
80-95: "win_w"
|
||||
96-111: "win_h"
|
||||
112-119: "scale"
|
||||
120-127: "anchor"
|
||||
128-135: "no_signal_fps"
|
||||
136-143: "reserved"
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `stream_id` | Stream to display; must match incoming `VIDEO_FRAME` stream_id |
|
||||
| `win_x`, `win_y` | Window screen position (signed; for multi-monitor placement) |
|
||||
| `win_w`, `win_h` | Window size in pixels; `0` = default (1280×720) |
|
||||
| `scale` | `0`=stretch `1`=fit `2`=fill `3`=1:1 |
|
||||
| `anchor` | `0`=center `1`=topleft |
|
||||
| `no_signal_fps` | Frame rate of no-signal animation (0 = default 15 fps) |
|
||||
|
||||
**Response** — no extra fields beyond request_id and status. `OK` means the display slot was reserved; the window opens asynchronously on the main thread.
|
||||
|
||||
### `STOP_DISPLAY` (0x000B)
|
||||
|
||||
Closes the viewer window for the given stream.
|
||||
|
||||
**Request**:
|
||||
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 16}}}%%
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "command = 0x000B"
|
||||
32-47: "stream_id"
|
||||
```
|
||||
|
||||
**Response** — no extra fields beyond request_id and status.
|
||||
|
||||
|
||||
10
docs/xorg.md
10
docs/xorg.md
@@ -107,6 +107,16 @@ A `framebuffer_size_callback` registered on the window calls `render()` synchron
|
||||
|
||||
Threading note: the GL context must be used from the thread that created it. In the video node, incoming frames arrive on a network receive thread. A frame queue between the receive thread and the render thread (which owns the GL context) is the correct model — the render thread drains the queue each poll iteration rather than having the network thread call push functions directly.
|
||||
|
||||
### Multiple windows
|
||||
|
||||
GLFW supports multiple windows from the same thread. `glfwCreateWindow` can be called repeatedly; each call returns an independent window handle with its own GL context. The video node uses this to display several streams simultaneously (one window per active `Display_Slot`).
|
||||
|
||||
**`glfwPollEvents` is global.** It drains the event queue for all windows at once, not just the one associated with the viewer it is called through. When iterating over multiple display slots and calling `xorg_viewer_handle_events` on each, only the first call does real work; subsequent calls are no-ops because the queue is already empty. This is harmless but worth knowing: if the loop is ever restructured so that event polling is conditional or short-circuited, all windows need at least one `glfwPollEvents` call per iteration or they will stop responding to input.
|
||||
|
||||
**Each window has its own GL context.** `glfwMakeContextCurrent` must be called before any GL operations to ensure calls go to the right context. The push functions (`push_yuv420`, `push_bgra`, `push_mjpeg`) and `poll` do this automatically. Code that calls GL functions directly must make the correct context current first.
|
||||
|
||||
**`glfwInit`/`glfwTerminate` are ref-counted** in the xorg module. The first `xorg_viewer_open` call initialises GLFW; `glfwTerminate` is deferred until the last viewer is closed. Do not call `glfwTerminate` directly — use `xorg_viewer_close` and let the ref count manage it.
|
||||
|
||||
### Renderer: Vulkan (future alternative)
|
||||
|
||||
A Vulkan renderer is planned as an alternative to the OpenGL one. GLFW's surface creation API is renderer-agnostic, so the window management and input handling code is shared. Only the renderer backend changes.
|
||||
|
||||
66
include/ingest.h
Normal file
66
include/ingest.h
Normal file
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include "error.h"
|
||||
|
||||
typedef struct Ingest_Handle Ingest_Handle;
|
||||
|
||||
/*
|
||||
* Called from the capture thread for each dequeued frame.
|
||||
* data points into the mmap'd buffer — valid only for the duration of the call.
|
||||
* Do not free data; copy if you need to retain it beyond the callback.
|
||||
*/
|
||||
typedef void (*Ingest_Frame_Fn)(
|
||||
const uint8_t *data, uint32_t len,
|
||||
int width, int height, uint32_t pixfmt,
|
||||
void *userdata);
|
||||
|
||||
/*
|
||||
* Called from the capture thread when a fatal error terminates the capture loop.
|
||||
* After this callback returns, the thread exits and the handle is in a stopped
|
||||
* state (equivalent to after ingest_stop). msg is a static string.
|
||||
*/
|
||||
typedef void (*Ingest_Error_Fn)(const char *msg, void *userdata);
|
||||
|
||||
struct Ingest_Config {
|
||||
const char *device; /* e.g. "/dev/video0" */
|
||||
uint32_t pixfmt; /* V4L2_PIX_FMT_MJPEG etc.; 0 = auto (best MJPEG) */
|
||||
int width; /* 0 = auto */
|
||||
int height; /* 0 = auto */
|
||||
Ingest_Frame_Fn on_frame;
|
||||
Ingest_Error_Fn on_error; /* may be NULL */
|
||||
void *userdata;
|
||||
};
|
||||
|
||||
/*
|
||||
* Open the V4L2 device, negotiate format, allocate MMAP buffers.
|
||||
* Does NOT start streaming. on_frame must not be NULL.
|
||||
*/
|
||||
struct App_Error ingest_open(const struct Ingest_Config *cfg, Ingest_Handle **out);
|
||||
|
||||
/*
|
||||
* Enable streaming and start the capture thread.
|
||||
* Must be called on a handle in the OPEN (not streaming) state.
|
||||
*/
|
||||
struct App_Error ingest_start(Ingest_Handle *h);
|
||||
|
||||
/*
|
||||
* Signal the capture thread to stop and block until it exits.
|
||||
* Disables streaming. The handle returns to the OPEN state and can be
|
||||
* restarted with ingest_start or released with ingest_close.
|
||||
*/
|
||||
struct App_Error ingest_stop(Ingest_Handle *h);
|
||||
|
||||
/*
|
||||
* Release MMAP buffers and close the device fd.
|
||||
* Must be called only when the handle is not streaming (before ingest_start
|
||||
* or after ingest_stop).
|
||||
*/
|
||||
void ingest_close(Ingest_Handle *h);
|
||||
|
||||
/* Query the negotiated format — valid after a successful ingest_open. */
|
||||
int ingest_width(const Ingest_Handle *h);
|
||||
int ingest_height(const Ingest_Handle *h);
|
||||
uint32_t ingest_pixfmt(const Ingest_Handle *h);
|
||||
int ingest_fps_n(const Ingest_Handle *h);
|
||||
int ingest_fps_d(const Ingest_Handle *h);
|
||||
@@ -24,6 +24,10 @@
|
||||
#define PROTO_CMD_GET_CONTROL 0x0005u
|
||||
#define PROTO_CMD_SET_CONTROL 0x0006u
|
||||
#define PROTO_CMD_ENUM_MONITORS 0x0007u
|
||||
#define PROTO_CMD_START_INGEST 0x0008u
|
||||
#define PROTO_CMD_STOP_INGEST 0x0009u
|
||||
#define PROTO_CMD_START_DISPLAY 0x000Au
|
||||
#define PROTO_CMD_STOP_DISPLAY 0x000Bu
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Response status codes (carried in CONTROL_RESPONSE payload offset 2)
|
||||
@@ -66,6 +70,13 @@
|
||||
#define PROTO_PIXEL_YUV420P 0x0004u
|
||||
#define PROTO_PIXEL_YUV422 0x0005u
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Transport mode codes (START_INGEST transport_mode field)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define PROTO_TRANSPORT_ENCAPSULATED 0x0001u /* framed: message_type + payload_length header */
|
||||
#define PROTO_TRANSPORT_OPAQUE 0x0002u /* raw byte stream, no frame boundaries */
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Origin codes (STREAM_OPEN origin field; informational only)
|
||||
* ------------------------------------------------------------------------- */
|
||||
@@ -136,6 +147,29 @@ struct Proto_Standalone_Device_Info {
|
||||
const char *name;
|
||||
};
|
||||
|
||||
/*
|
||||
* An active display window (video sink role).
|
||||
* device_id is the flat device index (follows all V4L2 devices).
|
||||
* stream_id is the stream being displayed; win_* are current geometry.
|
||||
*/
|
||||
struct Proto_Display_Device_Info {
|
||||
uint16_t device_id;
|
||||
uint16_t stream_id;
|
||||
int16_t win_x, win_y;
|
||||
uint16_t win_w, win_h;
|
||||
uint8_t scale_mode;
|
||||
uint8_t anchor;
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Display device pseudo-control IDs — used in ENUM_CONTROLS / GET_CONTROL /
|
||||
* SET_CONTROL for display device indices returned by ENUM_DEVICES.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define PROTO_DISPLAY_CTRL_SCALE_MODE 0x00D00001u /* int 0-3: stretch/fit/fill/1:1 */
|
||||
#define PROTO_DISPLAY_CTRL_ANCHOR 0x00D00002u /* int 0-1: center/topleft */
|
||||
#define PROTO_DISPLAY_CTRL_NO_SIGNAL_FPS 0x00D00003u /* int 1-60: no-signal animation fps */
|
||||
|
||||
struct Proto_Monitor_Info {
|
||||
int32_t x, y;
|
||||
uint32_t width, height;
|
||||
@@ -196,6 +230,68 @@ struct Proto_Set_Control_Req {
|
||||
int32_t value;
|
||||
};
|
||||
|
||||
/*
|
||||
* START_INGEST: controller tells a source node to open a V4L2 device and
|
||||
* connect outbound to a sink at dest_host:dest_port.
|
||||
* format/width/height/fps_n/fps_d of 0 mean auto-select.
|
||||
* Strings point into the caller's payload buffer; not NUL-terminated.
|
||||
*/
|
||||
struct Proto_Start_Ingest {
|
||||
uint16_t request_id;
|
||||
uint16_t stream_id;
|
||||
uint16_t format; /* PROTO_FORMAT_* code; 0 = auto (best MJPEG) */
|
||||
uint16_t width; /* 0 = auto */
|
||||
uint16_t height; /* 0 = auto */
|
||||
uint16_t fps_n; /* 0 = auto */
|
||||
uint16_t fps_d;
|
||||
uint16_t dest_port;
|
||||
uint16_t transport_mode; /* PROTO_TRANSPORT_ENCAPSULATED or PROTO_TRANSPORT_OPAQUE */
|
||||
const char *device_path;
|
||||
uint8_t device_path_len;
|
||||
const char *dest_host;
|
||||
uint8_t dest_host_len;
|
||||
};
|
||||
|
||||
struct Proto_Stop_Ingest {
|
||||
uint16_t request_id;
|
||||
uint16_t stream_id;
|
||||
};
|
||||
|
||||
/*
|
||||
* START_DISPLAY: controller tells a sink node to open a viewer window and
|
||||
* display incoming VIDEO_FRAME messages for the given stream_id.
|
||||
* win_x/win_y are screen-space window position (signed: multi-monitor).
|
||||
* win_w/win_h of 0 mean use a default size.
|
||||
* scale_mode: 0=stretch 1=fit 2=fill 3=1:1 (PROTO_DISPLAY_SCALE_*)
|
||||
* anchor: 0=center 1=topleft (PROTO_DISPLAY_ANCHOR_*)
|
||||
*/
|
||||
struct Proto_Start_Display {
|
||||
uint16_t request_id;
|
||||
uint16_t stream_id;
|
||||
int16_t win_x;
|
||||
int16_t win_y;
|
||||
uint16_t win_w;
|
||||
uint16_t win_h;
|
||||
uint8_t scale_mode;
|
||||
uint8_t anchor;
|
||||
uint8_t no_signal_fps; /* 0 = default (15); no-signal animation frame rate */
|
||||
/* 1 byte reserved */
|
||||
};
|
||||
|
||||
struct Proto_Stop_Display {
|
||||
uint16_t request_id;
|
||||
uint16_t stream_id;
|
||||
};
|
||||
|
||||
/* Scale/anchor constants for Proto_Start_Display */
|
||||
#define PROTO_DISPLAY_SCALE_STRETCH 0u
|
||||
#define PROTO_DISPLAY_SCALE_FIT 1u
|
||||
#define PROTO_DISPLAY_SCALE_FILL 2u
|
||||
#define PROTO_DISPLAY_SCALE_1_1 3u
|
||||
|
||||
#define PROTO_DISPLAY_ANCHOR_CENTER 0u
|
||||
#define PROTO_DISPLAY_ANCHOR_TOPLEFT 1u
|
||||
|
||||
struct Proto_Response_Header {
|
||||
uint16_t request_id;
|
||||
uint16_t status;
|
||||
@@ -253,6 +349,28 @@ struct App_Error proto_write_set_control(struct Transport_Conn *conn,
|
||||
struct App_Error proto_write_enum_monitors(struct Transport_Conn *conn,
|
||||
uint16_t request_id);
|
||||
|
||||
/* CONTROL_REQUEST: START_INGEST */
|
||||
struct App_Error proto_write_start_ingest(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t stream_id,
|
||||
uint16_t format, uint16_t width, uint16_t height,
|
||||
uint16_t fps_n, uint16_t fps_d,
|
||||
uint16_t transport_mode,
|
||||
const char *device_path, const char *dest_host, uint16_t dest_port);
|
||||
|
||||
/* CONTROL_REQUEST: STOP_INGEST */
|
||||
struct App_Error proto_write_stop_ingest(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t stream_id);
|
||||
|
||||
/* CONTROL_REQUEST: START_DISPLAY */
|
||||
struct App_Error proto_write_start_display(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t stream_id,
|
||||
int16_t win_x, int16_t win_y, uint16_t win_w, uint16_t win_h,
|
||||
uint8_t scale_mode, uint8_t anchor, uint8_t no_signal_fps);
|
||||
|
||||
/* CONTROL_REQUEST: STOP_DISPLAY */
|
||||
struct App_Error proto_write_stop_display(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t stream_id);
|
||||
|
||||
/*
|
||||
* CONTROL_RESPONSE: generic.
|
||||
* payload/payload_len are the command-specific bytes after request_id+status.
|
||||
@@ -267,7 +385,8 @@ struct App_Error proto_write_control_response(struct Transport_Conn *conn,
|
||||
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);
|
||||
const struct Proto_Standalone_Device_Info *standalone, uint16_t standalone_count,
|
||||
const struct Proto_Display_Device_Info *displays, uint16_t display_count);
|
||||
|
||||
/* CONTROL_RESPONSE: ENUM_CONTROLS */
|
||||
struct App_Error proto_write_enum_controls_response(struct Transport_Conn *conn,
|
||||
@@ -325,6 +444,22 @@ struct App_Error proto_read_set_control_req(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Set_Control_Req *out);
|
||||
|
||||
struct App_Error proto_read_start_ingest(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Start_Ingest *out);
|
||||
|
||||
struct App_Error proto_read_stop_ingest(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Stop_Ingest *out);
|
||||
|
||||
struct App_Error proto_read_start_display(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Start_Display *out);
|
||||
|
||||
struct App_Error proto_read_stop_display(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Stop_Display *out);
|
||||
|
||||
/*
|
||||
* Read the common 4-byte response header (request_id + status).
|
||||
* For responses with no extra fields (STREAM_OPEN, STREAM_CLOSE, SET_CONTROL),
|
||||
@@ -370,6 +505,13 @@ struct App_Error proto_read_enum_devices_response(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *name, uint8_t name_len,
|
||||
void *userdata),
|
||||
void (*on_display)(
|
||||
uint16_t device_id,
|
||||
uint16_t stream_id,
|
||||
int16_t win_x, int16_t win_y,
|
||||
uint16_t win_w, uint16_t win_h,
|
||||
uint8_t scale_mode, uint8_t anchor,
|
||||
void *userdata),
|
||||
void *userdata);
|
||||
|
||||
/*
|
||||
|
||||
108
include/reconciler.h
Normal file
108
include/reconciler.h
Normal file
@@ -0,0 +1,108 @@
|
||||
#pragma once
|
||||
|
||||
/*
|
||||
* Generic declarative state machine reconciler.
|
||||
*
|
||||
* Each managed resource is described as a directed graph of states
|
||||
* with labelled transitions. The reconciler finds the shortest path
|
||||
* (BFS) from a resource's current state to its wanted state and
|
||||
* executes one transition per tick.
|
||||
*
|
||||
* Dependencies between resources prevent a resource from advancing
|
||||
* past a threshold state until a prerequisite resource reaches a
|
||||
* minimum state.
|
||||
*
|
||||
* Usage:
|
||||
* struct Reconciler *r = reconciler_create();
|
||||
*
|
||||
* static const struct Rec_Transition dev_trans[] = {
|
||||
* {0, 1, open_device},
|
||||
* {1, 0, close_device},
|
||||
* {1, 2, start_capture},
|
||||
* {2, 1, stop_capture},
|
||||
* {-1, -1, NULL}
|
||||
* };
|
||||
* static const char *dev_states[] = {"CLOSED", "OPEN", "STREAMING"};
|
||||
* struct Rec_Resource *dev = reconciler_add_resource(r, "device",
|
||||
* dev_trans, 3, dev_states, 0, &my_device);
|
||||
*
|
||||
* reconciler_set_wanted(dev, 2);
|
||||
* while (!reconciler_is_stable(r)) {
|
||||
* reconciler_tick(r);
|
||||
* }
|
||||
*/
|
||||
|
||||
/* Transition table entry. Sentinel: {-1, -1, NULL}.
|
||||
* action: return 1 on success, 0 on failure.
|
||||
* On failure the resource stays in 'from' state. */
|
||||
struct Rec_Transition {
|
||||
int from;
|
||||
int to;
|
||||
int (*action)(void *userdata);
|
||||
};
|
||||
|
||||
typedef enum {
|
||||
REC_STATUS_STABLE, /* current == wanted */
|
||||
REC_STATUS_WORKING, /* current != wanted, next transition is eligible */
|
||||
REC_STATUS_BLOCKED, /* current != wanted, a dependency is unsatisfied */
|
||||
REC_STATUS_NO_PATH, /* current != wanted, no transition path exists */
|
||||
} Rec_Status;
|
||||
|
||||
struct Reconciler;
|
||||
struct Rec_Resource;
|
||||
|
||||
/* Optional log callback — called after each transition attempt. */
|
||||
typedef void (*Rec_Log_Fn)(
|
||||
const struct Rec_Resource *res,
|
||||
int from, int to, int success,
|
||||
void *userdata);
|
||||
|
||||
struct Reconciler *reconciler_create(void);
|
||||
void reconciler_destroy(struct Reconciler *r);
|
||||
|
||||
/* Set a log callback. Called after every transition attempt. */
|
||||
void reconciler_set_log(struct Reconciler *r, Rec_Log_Fn fn, void *userdata);
|
||||
|
||||
/* Add a resource.
|
||||
* transitions: caller-owned, sentinel-terminated {-1,-1,NULL}.
|
||||
* state_names: optional array of state_count strings; NULL for numeric display.
|
||||
* initial_state: sets both current and wanted initially. */
|
||||
struct Rec_Resource *reconciler_add_resource(
|
||||
struct Reconciler *r,
|
||||
const char *name,
|
||||
const struct Rec_Transition *transitions,
|
||||
int state_count,
|
||||
const char **state_names,
|
||||
int initial_state,
|
||||
void *userdata);
|
||||
|
||||
/* Add a dependency: resource cannot reach state >= blocked_below
|
||||
* unless dep is currently in state >= dep_min_state. */
|
||||
void reconciler_add_dep(
|
||||
struct Rec_Resource *resource,
|
||||
int blocked_below,
|
||||
struct Rec_Resource *dep,
|
||||
int dep_min_state);
|
||||
|
||||
void reconciler_set_wanted(struct Rec_Resource *r, int wanted_state);
|
||||
|
||||
/*
|
||||
* Force current state without executing a transition.
|
||||
* Use when an external event pushes a resource into a new state —
|
||||
* e.g. a transport connection drops unexpectedly, or a device error
|
||||
* causes the capture thread to exit. The reconciler will drive back
|
||||
* toward wanted state on the next tick.
|
||||
*/
|
||||
void reconciler_force_current(struct Rec_Resource *r, int state);
|
||||
int reconciler_get_current(const struct Rec_Resource *r);
|
||||
int reconciler_get_wanted(const struct Rec_Resource *r);
|
||||
const char *reconciler_get_name(const struct Rec_Resource *r);
|
||||
const char *reconciler_state_name(const struct Rec_Resource *r, int state);
|
||||
Rec_Status reconciler_get_status(const struct Rec_Resource *r);
|
||||
|
||||
/* Run one reconciliation pass over all resources.
|
||||
* Returns number of transitions attempted (success or failure). */
|
||||
int reconciler_tick(struct Reconciler *r);
|
||||
|
||||
/* Returns 1 if all resources have current == wanted. */
|
||||
int reconciler_is_stable(const struct Reconciler *r);
|
||||
@@ -51,9 +51,15 @@ struct Transport_Server_Config {
|
||||
struct App_Error transport_server_create(struct Transport_Server **out,
|
||||
struct Transport_Server_Config *config);
|
||||
|
||||
/* Bind, listen, and spawn the accept thread. */
|
||||
/* Bind, listen, and spawn the accept thread.
|
||||
* If config.port is 0, the OS assigns a free port; use
|
||||
* transport_server_get_port() afterwards to retrieve it. */
|
||||
struct App_Error transport_server_start(struct Transport_Server *server);
|
||||
|
||||
/* Return the port the server is actually listening on.
|
||||
* Valid after a successful transport_server_start(). */
|
||||
uint16_t transport_server_get_port(const struct Transport_Server *server);
|
||||
|
||||
/*
|
||||
* Stop accepting new connections and free the server.
|
||||
* Active connections continue until they disconnect naturally.
|
||||
|
||||
@@ -67,6 +67,14 @@ void xorg_viewer_set_overlay_text(Xorg_Viewer *v, int idx, int x, int y,
|
||||
/* Remove all text overlays. */
|
||||
void xorg_viewer_clear_overlays(Xorg_Viewer *v);
|
||||
|
||||
/*
|
||||
* Render one frame of animated analog-TV noise with a centred "NO SIGNAL"
|
||||
* label. time is seconds (e.g. glfwGetTime()); noise_res is cells per axis
|
||||
* (lower = coarser, default 80 when 0 is passed).
|
||||
* Call at low frame rate (~15 fps) when the viewer has no incoming stream.
|
||||
*/
|
||||
void xorg_viewer_render_no_signal(Xorg_Viewer *v, float time, float noise_res);
|
||||
|
||||
/*
|
||||
* Process pending window events.
|
||||
* Returns false when the user has closed the window.
|
||||
|
||||
40
planning.md
40
planning.md
@@ -15,14 +15,18 @@ video-setup/
|
||||
src/
|
||||
modules/
|
||||
common/ - shared definitions (error types, base types)
|
||||
config/ - INI file loader with schema-driven defaults, typed getters
|
||||
media_ctrl/ - Linux Media Controller API (topology, pad formats, links)
|
||||
v4l2_ctrl/ - V4L2 camera controls (enumerate, get, set)
|
||||
serial/ - little-endian binary serialization primitives
|
||||
transport/ - framed TCP stream, single-write send
|
||||
discovery/ - UDP multicast announcements, peer table, found/lost callbacks
|
||||
protocol/ - typed write_*/read_* message functions
|
||||
test_image/ - test pattern generator (colour bars, ramp, grid; YUV420/BGRA)
|
||||
xorg/ - GLFW+OpenGL viewer sink; stub for headless builds
|
||||
node/ - video node entry point and top-level integration (later)
|
||||
reconciler/ - generic wanted/current state machine reconciler
|
||||
ingest/ - V4L2 capture loop, MMAP buffers, on_frame callback
|
||||
node/ - video node binary (source + display sink roles)
|
||||
include/ - public headers
|
||||
dev/
|
||||
cli/ - exploratory CLI drivers, one per module
|
||||
@@ -55,13 +59,13 @@ Modules are listed in intended build order. Each depends only on modules above i
|
||||
| 5 | `transport` | done | Encapsulated transport — frame header, TCP stream abstraction, single-write send |
|
||||
| 6 | `discovery` | done | UDP multicast announcements, peer table, found/lost callbacks |
|
||||
| 7 | `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 |
|
||||
| 8 | `test_image` | done | Test pattern generator — colour bars, luminance ramp, grid crosshatch; YUV420/BGRA output |
|
||||
| 9 | `xorg` | done | GLFW+OpenGL viewer sink — YUV420/BGRA/MJPEG display, all scale/anchor modes, bitmap font atlas text overlays; XRandR queries and screen grab not yet implemented |
|
||||
| 10 | `reconciler` | not started | Generic wanted/current state machine reconciler — resource state graphs, BFS pathfinding, event + periodic tick; used by node to manage V4L2 devices, transport connections, and future resources (codec processes etc.) |
|
||||
| 11 | `frame_alloc` | not started | Per-frame allocation with bookkeeping (byte budget, ref counting) |
|
||||
| 12 | `relay` | not started | Input dispatch to output queues (low-latency and completeness modes) |
|
||||
| 13 | `ingest` | not started | V4L2 capture loop — dequeue buffers, emit one encapsulated frame per buffer |
|
||||
| 9 | `xorg` | done | GLFW+OpenGL viewer sink — YUV420/BGRA/MJPEG display, all scale/anchor modes, bitmap font atlas text overlays; XRandR queries and screen grab not yet implemented; viewer controls (zoom, pan, scale policy) not yet exposed remotely |
|
||||
| 10 | `reconciler` | done | Generic wanted/current state machine reconciler — resource state graphs, BFS pathfinding, event + periodic tick; used by node to manage V4L2 devices, transport connections, and future resources (codec processes etc.) |
|
||||
| 11 | `ingest` | done | V4L2 capture loop — open device, negotiate MJPEG format, MMAP buffers, capture thread with on_frame callback; start/stop lifecycle managed by reconciler |
|
||||
| — | `node` | done | Video node binary — config, discovery, transport server, V4L2/media control request handlers; display sink role (START_DISPLAY/STOP_DISPLAY handlers, multi-window xorg viewer, declarative display slot reconciler) |
|
||||
| 12 | `frame_alloc` | not started | Per-frame allocation with bookkeeping (byte budget, ref counting) |
|
||||
| 13 | `relay` | not started | Input dispatch to output queues (low-latency and completeness modes) |
|
||||
| 14 | `archive` | not started | Write frames to disk, control messages to binary log |
|
||||
| 15 | `codec` | not started | Per-frame encode/decode — MJPEG (libjpeg-turbo), QOI, ZSTD-raw, VA-API H.264 intra; used by screen grab source and archive |
|
||||
| 16 | `web node` | not started | Node.js/Express peer — speaks binary protocol on socket side, HTTP/WebSocket to browser; `protocol.mjs` mirrors C protocol module |
|
||||
@@ -80,12 +84,26 @@ Each module gets a corresponding CLI driver that exercises its API and serves as
|
||||
| `media_ctrl_cli` | `media_ctrl` | List media devices, show topology, configure pad formats |
|
||||
| `v4l2_ctrl_cli` | `v4l2_ctrl` | List controls, get/set values — lightweight `v4l2-ctl` equivalent |
|
||||
| `transport_cli` | `transport` | Send/receive framed messages, inspect headers |
|
||||
| `discovery_cli` | `discovery` | Announce and discover peers over UDP multicast; print found/lost events |
|
||||
| `config_cli` | `config` | Load an INI config file and print the resolved values after applying schema defaults |
|
||||
| `protocol_cli` | `protocol` | Send and receive typed protocol messages; inspect frame payloads |
|
||||
| `query_cli` | `discovery` + `protocol` | Wait for first discovered node, send ENUM_DEVICES, print results — integration smoke test |
|
||||
| `test_image_cli` | `test_image` | Generate test patterns, write PPM for visual inspection |
|
||||
| `xorg_cli` | `xorg` | Display test pattern in viewer window; exercises scale/anchor modes and text overlays |
|
||||
| `v4l2_view_cli` | V4L2 + `xorg` | Live camera viewer — auto-selects highest-FPS format, FPS/format overlay; bypasses node system |
|
||||
| `stream_send_cli` | V4L2 + `transport` + `protocol` | Capture MJPEG from V4L2, connect to receiver, send VIDEO_FRAME messages; prints fps/Mbps stats |
|
||||
| `stream_recv_cli` | `transport` + `protocol` + `xorg` | Listen for incoming VIDEO_FRAME stream, display in viewer; fps/Mbps overlay; threaded transport→GL handoff |
|
||||
| `reconciler_cli` | `reconciler` | Simulated state machine experiment — define resources with fake transitions, drive reconciler via CLI commands; validates the generic reconciler before wiring into the node |
|
||||
| `controller_cli` | `transport` + `protocol` + `discovery` | Interactive controller REPL — connects to nodes by peer index or host:port; supports enum-devices, enum-controls, get/set-control, start-ingest, stop-ingest, start-display, stop-display; readline + discovery integration; **temporary dev tool** — will be superseded by a dedicated `controller` binary that holds simultaneous connections to all peers |
|
||||
|
||||
### Header-only utilities (`include/`)
|
||||
|
||||
Not modules (no `.c` or Makefile) but public interfaces used across CLI tools and the node:
|
||||
|
||||
| Header | Used by | Notes |
|
||||
|---|---|---|
|
||||
| `stream_stats.h` | `stream_send_cli`, `stream_recv_cli` | Per-stream rolling fps/Mbps stats; single-header, no dependencies |
|
||||
| `v4l2_fmt.h` | `ingest`, `v4l2_view_cli` | V4L2 format enumeration — (pixfmt, size, fps) combinations, best-format selection |
|
||||
|
||||
### Web UI (`dev/web/`)
|
||||
|
||||
@@ -112,3 +130,11 @@ These are open questions tracked in `architecture.md` that do not need to be res
|
||||
- Transport for relay edges (TCP / UDP / shared memory)
|
||||
- Node discovery mechanism
|
||||
- Hard vs soft byte budget limits
|
||||
- Cooperative capture release: if a capture source has no live downstream targets for a configurable time window, stop capture and release the device. Intended as a resource-conservation policy rather than an immediate reaction to disconnect events. Requires the node to track downstream liveness (e.g. last successful send timestamp per output) and implement a reaper timer.
|
||||
- Unified device model: active display windows should be registered as devices alongside V4L2 cameras, using the same ENUM_DEVICES / ENUM_CONTROLS / GET_CONTROL / SET_CONTROL protocol. START_DISPLAY would return a device_id for the opened window; controls (scale, anchor, position, size, zoom, pan) are then addressable as (device_id, control_id) pairs like any other device. Requires a device_type field in ENUM_DEVICES responses so controllers can distinguish V4L2 devices from display windows. Future device types: codec processes, screen grab sources. This extends naturally to shader-based post-processing and other viewer state as controls.
|
||||
- Display viewer free pan/zoom mode: the current anchor system (center/topleft) only covers fixed alignment. A "free" mode should allow the controller (or the user via mouse/keyboard in the window) to set arbitrary pan offset and zoom level independently of the scale mode. The xorg viewer would need pan_x/pan_y (normalised or pixel offsets) and zoom_factor controls alongside the existing scale/anchor. This is a prerequisite for use cases like microscope inspection where the user needs to freely navigate a high-resolution source.
|
||||
- controller_cli is a temporary dev tool; the long-term replacement is a dedicated `controller` binary outside `dev/cli/` that maintains simultaneous connections to all discovered nodes (not switching between them). Commands address a specific node by peer index. This mirrors the web UI's model of administering the whole network rather than one node at a time. The `connect` / active-connection model in the current controller_cli is an interim design choice that should not be carried forward.
|
||||
- start-ingest peer addressing: the `dest_host` + `dest_port` in START_INGEST is awkward to type manually and requires the caller to know the target's TCP port. Should accept a peer ID (index from the discovered peer table on the node) so the node can resolve the address itself. Requires the node to run discovery and expose its peer table.
|
||||
- Connection multiplexing: currently each ingest stream opens its own outbound TCP connection to the destination. Multiple streams between the same two peers should share one connection, with stream_id used to demultiplex frames. This is the priority/encapsulation scheme described in the architecture — high-priority and low-latency frames from different streams travel over the same socket rather than competing across separate sockets.
|
||||
- Discovery boot nonce: the announcement payload needs a `boot_nonce` field (random u32 generated at startup, not configured). The current restart detection uses `site_id` change as a proxy, but `site_id` is static config and does not change on restart, so restarts are not detected and the restarted node waits up to `interval_ms` for peers to reply. Adding a boot nonce gives a reliable restart signal: a nonce change for a known (addr, port) entry triggers an immediate unicast reply. Requires a wire format version bump, peer table struct update, and changes to the announcement builder and receive logic.
|
||||
- Control grouping: controls should be organizable into named groups for both display organisation (collapsible sections in a UI) and protocol semantics (enumerate controls within a group, set a group of related controls atomically). Relevant for display devices where scale_mode, anchor, position, and size are logically related, and for cameras where white balance, exposure, and gain belong together. The current flat list of (control_id, name, type, value) tuples does not capture this.
|
||||
|
||||
@@ -56,11 +56,11 @@ static uint64_t now_ms(void) {
|
||||
return (uint64_t)ts.tv_sec * 1000u + (uint64_t)ts.tv_nsec / 1000000u;
|
||||
}
|
||||
|
||||
static int find_peer(struct Discovery *d, uint32_t addr, const char *name) {
|
||||
static int find_peer(struct Discovery *d, uint32_t addr, uint16_t tcp_port) {
|
||||
for (int i = 0; i < DISCOVERY_MAX_PEERS; i++) {
|
||||
if (d->peers[i].active
|
||||
&& d->peers[i].info.addr == addr
|
||||
&& strcmp(d->peers[i].info.name, name) == 0) {
|
||||
&& d->peers[i].info.addr == addr
|
||||
&& d->peers[i].info.tcp_port == tcp_port) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
@@ -76,19 +76,14 @@ static int find_slot(struct Discovery *d) {
|
||||
|
||||
/* -- send ------------------------------------------------------------------ */
|
||||
|
||||
static void send_announcement(struct Discovery *d) {
|
||||
size_t name_len = strlen(d->config.name);
|
||||
static size_t build_announcement(struct Discovery *d, uint8_t *buf) {
|
||||
size_t name_len = strlen(d->config.name);
|
||||
if (name_len > DISCOVERY_MAX_NAME_LEN) { name_len = DISCOVERY_MAX_NAME_LEN; }
|
||||
|
||||
uint32_t payload_len = (uint32_t)(ANN_FIXED_SIZE + name_len);
|
||||
size_t total = TRANSPORT_FRAME_HEADER_SIZE + payload_len;
|
||||
uint8_t buf[TRANSPORT_FRAME_HEADER_SIZE + ANN_FIXED_SIZE + DISCOVERY_MAX_NAME_LEN];
|
||||
|
||||
/* frame header */
|
||||
put_u16(buf, 0, 0x0010); /* message_type: DISCOVERY_ANNOUNCE */
|
||||
put_u16(buf, 0, 0x0010);
|
||||
put_u32(buf, 2, payload_len);
|
||||
|
||||
/* announcement payload */
|
||||
uint8_t *p = buf + TRANSPORT_FRAME_HEADER_SIZE;
|
||||
put_u8 (p, ANN_PROTOCOL_VERSION, DISCOVERY_PROTOCOL_VERSION);
|
||||
put_u16(p, ANN_SITE_ID, d->config.site_id);
|
||||
@@ -97,10 +92,26 @@ static void send_announcement(struct Discovery *d) {
|
||||
put_u8 (p, ANN_NAME_LEN, (uint8_t)name_len);
|
||||
memcpy(p + ANN_NAME, d->config.name, name_len);
|
||||
|
||||
return TRANSPORT_FRAME_HEADER_SIZE + payload_len;
|
||||
}
|
||||
|
||||
static void send_announcement(struct Discovery *d) {
|
||||
uint8_t buf[TRANSPORT_FRAME_HEADER_SIZE + ANN_FIXED_SIZE + DISCOVERY_MAX_NAME_LEN];
|
||||
size_t total = build_announcement(d, buf);
|
||||
sendto(d->sock, buf, total, 0,
|
||||
(struct sockaddr *)&d->mcast_addr, sizeof(d->mcast_addr));
|
||||
}
|
||||
|
||||
static void send_announcement_unicast(struct Discovery *d, uint32_t addr) {
|
||||
uint8_t buf[TRANSPORT_FRAME_HEADER_SIZE + ANN_FIXED_SIZE + DISCOVERY_MAX_NAME_LEN];
|
||||
size_t total = build_announcement(d, buf);
|
||||
struct sockaddr_in dest = {0};
|
||||
dest.sin_family = AF_INET;
|
||||
dest.sin_port = htons(DISCOVERY_PORT);
|
||||
dest.sin_addr.s_addr = addr;
|
||||
sendto(d->sock, buf, total, 0, (struct sockaddr *)&dest, sizeof(dest));
|
||||
}
|
||||
|
||||
/* -- timeout check --------------------------------------------------------- */
|
||||
|
||||
static void check_timeouts(struct Discovery *d) {
|
||||
@@ -198,19 +209,22 @@ static void *receive_thread_fn(void *arg) {
|
||||
|
||||
/* skip our own announcements */
|
||||
if (site_id == d->config.site_id
|
||||
&& strcmp(name, d->config.name) == 0) {
|
||||
&& tcp_port == d->config.tcp_port) {
|
||||
continue;
|
||||
}
|
||||
|
||||
uint32_t addr = src.sin_addr.s_addr;
|
||||
uint64_t ts = now_ms();
|
||||
int is_new = 0;
|
||||
int is_new = 0;
|
||||
int reannounce = 0;
|
||||
struct Discovery_Peer peer_copy;
|
||||
|
||||
pthread_mutex_lock(&d->peers_mutex);
|
||||
|
||||
int idx = find_peer(d, addr, name);
|
||||
int idx = find_peer(d, addr, tcp_port);
|
||||
if (idx >= 0) {
|
||||
/* detect restart: same addr+port but site_id changed */
|
||||
if (d->peers[idx].info.site_id != site_id) { reannounce = 1; }
|
||||
d->peers[idx].last_seen_ms = ts;
|
||||
d->peers[idx].info.site_id = site_id;
|
||||
d->peers[idx].info.tcp_port = tcp_port;
|
||||
@@ -233,11 +247,12 @@ static void *receive_thread_fn(void *arg) {
|
||||
|
||||
pthread_mutex_unlock(&d->peers_mutex);
|
||||
|
||||
/* 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 || reannounce) {
|
||||
/* new peer, or peer restarted (site_id changed) — reply directly
|
||||
* to that host so it learns about us without waiting up to interval_ms.
|
||||
* Use unicast rather than multicast to avoid disturbing other nodes. */
|
||||
send_announcement_unicast(d, addr);
|
||||
}
|
||||
|
||||
if (is_new && d->config.on_peer_found) {
|
||||
d->config.on_peer_found(&peer_copy, d->config.userdata);
|
||||
|
||||
19
src/modules/ingest/Makefile
Normal file
19
src/modules/ingest/Makefile
Normal file
@@ -0,0 +1,19 @@
|
||||
ROOT := $(abspath ../../..)
|
||||
include $(ROOT)/common.mk
|
||||
|
||||
MODULE_BUILD = $(BUILD)/ingest
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(MODULE_BUILD)/ingest.o
|
||||
|
||||
$(MODULE_BUILD)/ingest.o: ingest.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/ingest.o $(MODULE_BUILD)/ingest.d
|
||||
|
||||
-include $(MODULE_BUILD)/ingest.d
|
||||
292
src/modules/ingest/ingest.c
Normal file
292
src/modules/ingest/ingest.c
Normal file
@@ -0,0 +1,292 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/select.h>
|
||||
#include <pthread.h>
|
||||
#include <stdatomic.h>
|
||||
#include <linux/videodev2.h>
|
||||
|
||||
#include "ingest.h"
|
||||
#include "v4l2_fmt.h"
|
||||
#include "error.h"
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Internal types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define INGEST_N_BUFS 4
|
||||
|
||||
struct Mmap_Buf {
|
||||
void *start;
|
||||
size_t length;
|
||||
};
|
||||
|
||||
struct Ingest_Handle {
|
||||
int fd;
|
||||
struct Mmap_Buf bufs[INGEST_N_BUFS];
|
||||
int buf_count;
|
||||
|
||||
int width, height;
|
||||
uint32_t pixfmt;
|
||||
int fps_n, fps_d;
|
||||
|
||||
Ingest_Frame_Fn on_frame;
|
||||
Ingest_Error_Fn on_error;
|
||||
void *userdata;
|
||||
|
||||
pthread_t thread;
|
||||
atomic_int running; /* 1 = thread should keep going; 0 = stop */
|
||||
int started; /* 1 = pthread_create was called */
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Capture thread
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void *capture_thread(void *arg)
|
||||
{
|
||||
struct Ingest_Handle *h = arg;
|
||||
|
||||
while (atomic_load(&h->running)) {
|
||||
fd_set fds;
|
||||
FD_ZERO(&fds);
|
||||
FD_SET(h->fd, &fds);
|
||||
struct timeval tv = { 0, 100000 }; /* 100 ms — keeps stop latency short */
|
||||
|
||||
int r = select(h->fd + 1, &fds, NULL, NULL, &tv);
|
||||
if (r < 0) {
|
||||
if (errno == EINTR) { continue; }
|
||||
if (h->on_error) { h->on_error("select failed", h->userdata); }
|
||||
break;
|
||||
}
|
||||
if (r == 0) {
|
||||
continue; /* timeout — recheck running flag */
|
||||
}
|
||||
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_DQBUF, &buf) < 0) {
|
||||
if (errno == EAGAIN) { continue; }
|
||||
if (h->on_error) { h->on_error("VIDIOC_DQBUF failed", h->userdata); }
|
||||
break;
|
||||
}
|
||||
|
||||
h->on_frame(
|
||||
(const uint8_t *)h->bufs[buf.index].start,
|
||||
buf.bytesused,
|
||||
h->width, h->height, h->pixfmt,
|
||||
h->userdata);
|
||||
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_QBUF, &buf) < 0) {
|
||||
if (h->on_error) { h->on_error("VIDIOC_QBUF failed", h->userdata); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
atomic_store(&h->running, 0);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Public API
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
struct App_Error ingest_open(const struct Ingest_Config *cfg, Ingest_Handle **out)
|
||||
{
|
||||
struct Ingest_Handle *h = calloc(1, sizeof(*h));
|
||||
if (!h) { return APP_SYSCALL_ERROR(); }
|
||||
|
||||
h->fd = -1;
|
||||
h->on_frame = cfg->on_frame;
|
||||
h->on_error = cfg->on_error;
|
||||
h->userdata = cfg->userdata;
|
||||
atomic_init(&h->running, 0);
|
||||
|
||||
/* Open device */
|
||||
h->fd = open(cfg->device, O_RDWR | O_NONBLOCK);
|
||||
if (h->fd < 0) {
|
||||
free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
|
||||
/* Verify capture + streaming capability */
|
||||
struct v4l2_capability cap = {0};
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_QUERYCAP, &cap) < 0) {
|
||||
close(h->fd); free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE) ||
|
||||
!(cap.capabilities & V4L2_CAP_STREAMING)) {
|
||||
close(h->fd); free(h);
|
||||
return APP_INVALID_ERROR_MSG(0, "device does not support MJPEG streaming capture");
|
||||
}
|
||||
|
||||
/* Format selection */
|
||||
uint32_t want_pixfmt = cfg->pixfmt ? cfg->pixfmt : V4L2_PIX_FMT_MJPEG;
|
||||
|
||||
V4l2_Fmt_Option opts[V4L2_FMT_MAX_OPTS];
|
||||
int n = v4l2_enumerate_formats(h->fd, opts, V4L2_FMT_MAX_OPTS, want_pixfmt);
|
||||
if (n == 0) {
|
||||
close(h->fd); free(h);
|
||||
return APP_INVALID_ERROR_MSG(0, "no matching formats found on device");
|
||||
}
|
||||
|
||||
/* If caller specified exact w/h use that, otherwise auto-select best */
|
||||
const V4l2_Fmt_Option *chosen;
|
||||
if (cfg->width > 0 && cfg->height > 0) {
|
||||
chosen = NULL;
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (opts[i].w == cfg->width && opts[i].h == cfg->height) {
|
||||
if (!chosen || v4l2_fmt_fps_gt(&opts[i], chosen)) {
|
||||
chosen = &opts[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!chosen) {
|
||||
/* Exact size not found — fall back to best available */
|
||||
chosen = v4l2_select_best(opts, n);
|
||||
}
|
||||
} else {
|
||||
chosen = v4l2_select_best(opts, n);
|
||||
}
|
||||
|
||||
/* Apply format */
|
||||
struct v4l2_format fmt = {0};
|
||||
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
fmt.fmt.pix.pixelformat = chosen->pixfmt;
|
||||
fmt.fmt.pix.width = (uint32_t)chosen->w;
|
||||
fmt.fmt.pix.height = (uint32_t)chosen->h;
|
||||
fmt.fmt.pix.field = V4L2_FIELD_ANY;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_S_FMT, &fmt) < 0) {
|
||||
close(h->fd); free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
|
||||
h->width = (int)fmt.fmt.pix.width;
|
||||
h->height = (int)fmt.fmt.pix.height;
|
||||
h->pixfmt = fmt.fmt.pix.pixelformat;
|
||||
|
||||
/* Apply frame rate */
|
||||
{
|
||||
struct v4l2_streamparm parm = {0};
|
||||
parm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
parm.parm.capture.timeperframe.numerator = (uint32_t)chosen->fps_d;
|
||||
parm.parm.capture.timeperframe.denominator = (uint32_t)chosen->fps_n;
|
||||
v4l2_xioctl(h->fd, VIDIOC_S_PARM, &parm);
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_G_PARM, &parm) == 0 &&
|
||||
parm.parm.capture.timeperframe.denominator > 0) {
|
||||
h->fps_n = (int)parm.parm.capture.timeperframe.denominator;
|
||||
h->fps_d = (int)parm.parm.capture.timeperframe.numerator;
|
||||
} else {
|
||||
h->fps_n = chosen->fps_n;
|
||||
h->fps_d = chosen->fps_d;
|
||||
}
|
||||
}
|
||||
|
||||
/* Allocate MMAP buffers */
|
||||
struct v4l2_requestbuffers req = {0};
|
||||
req.count = INGEST_N_BUFS;
|
||||
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
req.memory = V4L2_MEMORY_MMAP;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_REQBUFS, &req) < 0) {
|
||||
close(h->fd); free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
|
||||
h->buf_count = (int)req.count;
|
||||
for (int i = 0; i < h->buf_count; i++) {
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
buf.index = (uint32_t)i;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_QUERYBUF, &buf) < 0) {
|
||||
/* Unmap already-mapped buffers before returning */
|
||||
for (int j = 0; j < i; j++) {
|
||||
munmap(h->bufs[j].start, h->bufs[j].length);
|
||||
}
|
||||
close(h->fd); free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
h->bufs[i].length = buf.length;
|
||||
h->bufs[i].start = mmap(NULL, buf.length,
|
||||
PROT_READ | PROT_WRITE, MAP_SHARED, h->fd, buf.m.offset);
|
||||
if (h->bufs[i].start == MAP_FAILED) {
|
||||
for (int j = 0; j < i; j++) {
|
||||
munmap(h->bufs[j].start, h->bufs[j].length);
|
||||
}
|
||||
close(h->fd); free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
}
|
||||
|
||||
*out = h;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error ingest_start(Ingest_Handle *h)
|
||||
{
|
||||
/* Queue all buffers */
|
||||
for (int i = 0; i < h->buf_count; i++) {
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
buf.index = (uint32_t)i;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_QBUF, &buf) < 0) {
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
}
|
||||
|
||||
/* Enable streaming */
|
||||
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_STREAMON, &type) < 0) {
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
|
||||
/* Start capture thread */
|
||||
atomic_store(&h->running, 1);
|
||||
if (pthread_create(&h->thread, NULL, capture_thread, h) != 0) {
|
||||
atomic_store(&h->running, 0);
|
||||
v4l2_xioctl(h->fd, VIDIOC_STREAMOFF, &type);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
h->started = 1;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error ingest_stop(Ingest_Handle *h)
|
||||
{
|
||||
if (!h->started) {
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
atomic_store(&h->running, 0);
|
||||
pthread_join(h->thread, NULL);
|
||||
h->started = 0;
|
||||
|
||||
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
v4l2_xioctl(h->fd, VIDIOC_STREAMOFF, &type);
|
||||
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
void ingest_close(Ingest_Handle *h)
|
||||
{
|
||||
if (!h) { return; }
|
||||
for (int i = 0; i < h->buf_count; i++) {
|
||||
if (h->bufs[i].start && h->bufs[i].start != MAP_FAILED) {
|
||||
munmap(h->bufs[i].start, h->bufs[i].length);
|
||||
}
|
||||
}
|
||||
if (h->fd >= 0) { close(h->fd); }
|
||||
free(h);
|
||||
}
|
||||
|
||||
int ingest_width(const Ingest_Handle *h) { return h->width; }
|
||||
int ingest_height(const Ingest_Handle *h) { return h->height; }
|
||||
uint32_t ingest_pixfmt(const Ingest_Handle *h) { return h->pixfmt; }
|
||||
int ingest_fps_n(const Ingest_Handle *h) { return h->fps_n; }
|
||||
int ingest_fps_d(const Ingest_Handle *h) { return h->fps_d; }
|
||||
@@ -51,6 +51,14 @@ static struct App_Error wbuf_u16(struct Wbuf *b, uint16_t v) {
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
static struct App_Error wbuf_i16(struct Wbuf *b, int16_t v) {
|
||||
struct App_Error e = wbuf_grow(b, 2);
|
||||
if (!APP_IS_OK(e)) { return e; }
|
||||
put_i16(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; }
|
||||
@@ -300,6 +308,54 @@ struct App_Error proto_write_enum_monitors(struct Transport_Conn *conn,
|
||||
return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 4);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_start_ingest(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t stream_id,
|
||||
uint16_t format, uint16_t width, uint16_t height,
|
||||
uint16_t fps_n, uint16_t fps_d,
|
||||
uint16_t transport_mode,
|
||||
const char *device_path, const char *dest_host, uint16_t dest_port)
|
||||
{
|
||||
size_t dp_len = device_path ? strlen(device_path) : 0;
|
||||
size_t dh_len = dest_host ? strlen(dest_host) : 0;
|
||||
uint8_t dp_n = dp_len > 255u ? 255u : (uint8_t)dp_len;
|
||||
uint8_t dh_n = dh_len > 255u ? 255u : (uint8_t)dh_len;
|
||||
|
||||
/* 20 bytes fixed + 1+dp_n (device_path str8) + 1+dh_n (dest_host str8) */
|
||||
uint32_t total = 20u + 1u + dp_n + 1u + dh_n;
|
||||
uint8_t *buf = malloc(total);
|
||||
if (!buf) { return APP_SYSCALL_ERROR(); }
|
||||
|
||||
uint32_t o = 0;
|
||||
put_u16(buf, o, request_id); o += 2;
|
||||
put_u16(buf, o, PROTO_CMD_START_INGEST); o += 2;
|
||||
put_u16(buf, o, stream_id); o += 2;
|
||||
put_u16(buf, o, format); o += 2;
|
||||
put_u16(buf, o, width); o += 2;
|
||||
put_u16(buf, o, height); o += 2;
|
||||
put_u16(buf, o, fps_n); o += 2;
|
||||
put_u16(buf, o, fps_d); o += 2;
|
||||
put_u16(buf, o, dest_port); o += 2;
|
||||
put_u16(buf, o, transport_mode); o += 2;
|
||||
put_u8 (buf, o, dp_n); o += 1;
|
||||
memcpy(buf + o, device_path, dp_n); o += dp_n;
|
||||
put_u8 (buf, o, dh_n); o += 1;
|
||||
memcpy(buf + o, dest_host, dh_n); o += dh_n;
|
||||
|
||||
struct App_Error e = transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, total);
|
||||
free(buf);
|
||||
return e;
|
||||
}
|
||||
|
||||
struct App_Error proto_write_stop_ingest(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_STOP_INGEST);
|
||||
put_u16(buf, 4, stream_id);
|
||||
return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 6);
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -318,7 +374,8 @@ struct App_Error proto_write_get_control_response(struct Transport_Conn *conn,
|
||||
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)
|
||||
const struct Proto_Standalone_Device_Info *standalone, uint16_t standalone_count,
|
||||
const struct Proto_Display_Device_Info *displays, uint16_t display_count)
|
||||
{
|
||||
struct Wbuf b;
|
||||
struct App_Error e = wbuf_init(&b, 128);
|
||||
@@ -354,6 +411,19 @@ struct App_Error proto_write_enum_devices_response(struct Transport_Conn *conn,
|
||||
e = wbuf_str8(&b, standalone[i].name); if (!APP_IS_OK(e)) { goto fail; }
|
||||
}
|
||||
|
||||
e = wbuf_u16(&b, display_count); if (!APP_IS_OK(e)) { goto fail; }
|
||||
for (uint16_t i = 0; i < display_count; i++) {
|
||||
const struct Proto_Display_Device_Info *d = &displays[i];
|
||||
e = wbuf_u16(&b, d->device_id); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u16(&b, d->stream_id); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_i16(&b, d->win_x); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_i16(&b, d->win_y); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u16(&b, d->win_w); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u16(&b, d->win_h); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u8 (&b, d->scale_mode); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u8 (&b, d->anchor); if (!APP_IS_OK(e)) { goto fail; }
|
||||
}
|
||||
|
||||
e = transport_send_frame(conn, PROTO_MSG_CONTROL_RESPONSE, b.data, b.len);
|
||||
fail:
|
||||
wbuf_free(&b);
|
||||
@@ -515,6 +585,106 @@ struct App_Error proto_read_set_control_req(
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_start_ingest(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Start_Ingest *out)
|
||||
{
|
||||
/* Fixed portion: request_id(2) cmd(2) stream_id(2) format(2) width(2)
|
||||
* height(2) fps_n(2) fps_d(2) dest_port(2) transport_mode(2) = 20 bytes,
|
||||
* then two str8 fields. */
|
||||
struct Cursor c;
|
||||
cur_init(&c, payload, length);
|
||||
|
||||
out->request_id = cur_u16(&c);
|
||||
/* skip command word at [2..3] */
|
||||
(void) cur_u16(&c);
|
||||
out->stream_id = cur_u16(&c);
|
||||
out->format = cur_u16(&c);
|
||||
out->width = cur_u16(&c);
|
||||
out->height = cur_u16(&c);
|
||||
out->fps_n = cur_u16(&c);
|
||||
out->fps_d = cur_u16(&c);
|
||||
out->dest_port = cur_u16(&c);
|
||||
out->transport_mode = cur_u16(&c);
|
||||
out->device_path = cur_str8(&c, &out->device_path_len);
|
||||
out->dest_host = cur_str8(&c, &out->dest_host_len);
|
||||
CUR_CHECK(c);
|
||||
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_stop_ingest(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Stop_Ingest *out)
|
||||
{
|
||||
if (length < 6) { return APP_INVALID_ERROR_MSG(0, "STOP_INGEST payload too short"); }
|
||||
out->request_id = get_u16(payload, 0);
|
||||
out->stream_id = get_u16(payload, 4);
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
/* START_DISPLAY: request_id(2) cmd(2) stream_id(2) win_x(2) win_y(2)
|
||||
* win_w(2) win_h(2) scale_mode(1) anchor(1) no_signal_fps(1) reserved(1) = 18 bytes */
|
||||
struct App_Error proto_write_start_display(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t stream_id,
|
||||
int16_t win_x, int16_t win_y, uint16_t win_w, uint16_t win_h,
|
||||
uint8_t scale_mode, uint8_t anchor, uint8_t no_signal_fps)
|
||||
{
|
||||
uint8_t buf[18];
|
||||
uint32_t o = 0;
|
||||
put_u16(buf, o, request_id); o += 2;
|
||||
put_u16(buf, o, PROTO_CMD_START_DISPLAY); o += 2;
|
||||
put_u16(buf, o, stream_id); o += 2;
|
||||
put_i16(buf, o, win_x); o += 2;
|
||||
put_i16(buf, o, win_y); o += 2;
|
||||
put_u16(buf, o, win_w); o += 2;
|
||||
put_u16(buf, o, win_h); o += 2;
|
||||
put_u8 (buf, o, scale_mode); o += 1;
|
||||
put_u8 (buf, o, anchor); o += 1;
|
||||
put_u8 (buf, o, no_signal_fps); o += 1;
|
||||
put_u8 (buf, o, 0); o += 1; /* reserved */
|
||||
(void)o;
|
||||
return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 18);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_stop_display(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_STOP_DISPLAY);
|
||||
put_u16(buf, 4, stream_id);
|
||||
return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 6);
|
||||
}
|
||||
|
||||
struct App_Error proto_read_start_display(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Start_Display *out)
|
||||
{
|
||||
if (length < 16) { return APP_INVALID_ERROR_MSG(0, "START_DISPLAY payload too short"); }
|
||||
out->request_id = get_u16(payload, 0);
|
||||
/* skip command word at [2..3] */
|
||||
out->stream_id = get_u16(payload, 4);
|
||||
out->win_x = get_i16(payload, 6);
|
||||
out->win_y = get_i16(payload, 8);
|
||||
out->win_w = get_u16(payload, 10);
|
||||
out->win_h = get_u16(payload, 12);
|
||||
out->scale_mode = get_u8 (payload, 14);
|
||||
out->anchor = get_u8 (payload, 15);
|
||||
out->no_signal_fps = length >= 18 ? get_u8(payload, 16) : 0;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_stop_display(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Stop_Display *out)
|
||||
{
|
||||
if (length < 6) { return APP_INVALID_ERROR_MSG(0, "STOP_DISPLAY 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_response_header(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Response_Header *out)
|
||||
@@ -557,6 +727,12 @@ struct App_Error proto_read_enum_devices_response(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *name, uint8_t name_len,
|
||||
void *userdata),
|
||||
void (*on_display)(
|
||||
uint16_t stream_id,
|
||||
int16_t win_x, int16_t win_y,
|
||||
uint16_t win_w, uint16_t win_h,
|
||||
uint8_t scale_mode, uint8_t anchor,
|
||||
void *userdata),
|
||||
void *userdata)
|
||||
{
|
||||
struct Cursor c;
|
||||
@@ -611,6 +787,27 @@ struct App_Error proto_read_enum_devices_response(
|
||||
if (on_standalone) { on_standalone(path, path_len, name, name_len, userdata); }
|
||||
}
|
||||
|
||||
/* Display section — optional; absent in messages from older nodes */
|
||||
if (c.ok && c.pos + 2 <= c.len) {
|
||||
uint16_t display_count = cur_u16(&c);
|
||||
CUR_CHECK(c);
|
||||
for (uint16_t i = 0; i < display_count; i++) {
|
||||
uint16_t device_id = cur_u16(&c);
|
||||
uint16_t stream_id = cur_u16(&c);
|
||||
int16_t win_x = (int16_t)cur_u16(&c);
|
||||
int16_t win_y = (int16_t)cur_u16(&c);
|
||||
uint16_t win_w = cur_u16(&c);
|
||||
uint16_t win_h = cur_u16(&c);
|
||||
uint8_t scale_mode = cur_u8(&c);
|
||||
uint8_t anchor = cur_u8(&c);
|
||||
CUR_CHECK(c);
|
||||
if (on_display) {
|
||||
on_display(device_id, stream_id, win_x, win_y,
|
||||
win_w, win_h, scale_mode, anchor, userdata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
@@ -635,6 +832,8 @@ struct App_Error proto_read_enum_controls_response(
|
||||
|
||||
header_out->request_id = cur_u16(&c);
|
||||
header_out->status = cur_u16(&c);
|
||||
CUR_CHECK(c);
|
||||
if (header_out->status != PROTO_STATUS_OK) { return APP_OK; }
|
||||
uint16_t count = cur_u16(&c);
|
||||
CUR_CHECK(c);
|
||||
|
||||
|
||||
19
src/modules/reconciler/Makefile
Normal file
19
src/modules/reconciler/Makefile
Normal file
@@ -0,0 +1,19 @@
|
||||
ROOT := $(abspath ../../..)
|
||||
include $(ROOT)/common.mk
|
||||
|
||||
MODULE_BUILD = $(BUILD)/reconciler
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(MODULE_BUILD)/reconciler.o
|
||||
|
||||
$(MODULE_BUILD)/reconciler.o: reconciler.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/reconciler.o $(MODULE_BUILD)/reconciler.d
|
||||
|
||||
-include $(MODULE_BUILD)/reconciler.d
|
||||
278
src/modules/reconciler/reconciler.c
Normal file
278
src/modules/reconciler/reconciler.c
Normal file
@@ -0,0 +1,278 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "reconciler.h"
|
||||
|
||||
#define REC_MAX_RESOURCES 32
|
||||
#define REC_MAX_STATES 16
|
||||
#define REC_MAX_DEPS 8
|
||||
|
||||
struct Rec_Dep {
|
||||
struct Rec_Resource *dep;
|
||||
int dep_min_state;
|
||||
int blocked_below;
|
||||
};
|
||||
|
||||
struct Rec_Resource {
|
||||
char name[32];
|
||||
const struct Rec_Transition *transitions;
|
||||
int state_count;
|
||||
const char **state_names;
|
||||
int current_state;
|
||||
int wanted_state;
|
||||
void *userdata;
|
||||
struct Rec_Dep deps[REC_MAX_DEPS];
|
||||
int dep_count;
|
||||
};
|
||||
|
||||
struct Reconciler {
|
||||
struct Rec_Resource resources[REC_MAX_RESOURCES];
|
||||
int count;
|
||||
Rec_Log_Fn log_fn;
|
||||
void *log_userdata;
|
||||
};
|
||||
|
||||
struct Reconciler *reconciler_create(void) {
|
||||
struct Reconciler *r = calloc(1, sizeof(struct Reconciler));
|
||||
return r;
|
||||
}
|
||||
|
||||
void reconciler_destroy(struct Reconciler *r) {
|
||||
free(r);
|
||||
}
|
||||
|
||||
void reconciler_set_log(struct Reconciler *r, Rec_Log_Fn fn, void *userdata) {
|
||||
r->log_fn = fn;
|
||||
r->log_userdata = userdata;
|
||||
}
|
||||
|
||||
struct Rec_Resource *reconciler_add_resource(
|
||||
struct Reconciler *r,
|
||||
const char *name,
|
||||
const struct Rec_Transition *transitions,
|
||||
int state_count,
|
||||
const char **state_names,
|
||||
int initial_state,
|
||||
void *userdata)
|
||||
{
|
||||
if (r->count >= REC_MAX_RESOURCES) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
struct Rec_Resource *res = &r->resources[r->count++];
|
||||
memset(res, 0, sizeof(*res));
|
||||
strncpy(res->name, name, sizeof(res->name) - 1);
|
||||
res->transitions = transitions;
|
||||
res->state_count = state_count;
|
||||
res->state_names = state_names;
|
||||
res->current_state = initial_state;
|
||||
res->wanted_state = initial_state;
|
||||
res->userdata = userdata;
|
||||
res->dep_count = 0;
|
||||
return res;
|
||||
}
|
||||
|
||||
void reconciler_add_dep(
|
||||
struct Rec_Resource *resource,
|
||||
int blocked_below,
|
||||
struct Rec_Resource *dep,
|
||||
int dep_min_state)
|
||||
{
|
||||
if (resource->dep_count >= REC_MAX_DEPS) {
|
||||
return;
|
||||
}
|
||||
|
||||
struct Rec_Dep *d = &resource->deps[resource->dep_count++];
|
||||
d->dep = dep;
|
||||
d->dep_min_state = dep_min_state;
|
||||
d->blocked_below = blocked_below;
|
||||
}
|
||||
|
||||
void reconciler_set_wanted(struct Rec_Resource *r, int wanted_state) {
|
||||
r->wanted_state = wanted_state;
|
||||
}
|
||||
|
||||
void reconciler_force_current(struct Rec_Resource *r, int state) {
|
||||
r->current_state = state;
|
||||
}
|
||||
|
||||
int reconciler_get_current(const struct Rec_Resource *r) {
|
||||
return r->current_state;
|
||||
}
|
||||
|
||||
int reconciler_get_wanted(const struct Rec_Resource *r) {
|
||||
return r->wanted_state;
|
||||
}
|
||||
|
||||
const char *reconciler_get_name(const struct Rec_Resource *r) {
|
||||
return r->name;
|
||||
}
|
||||
|
||||
const char *reconciler_state_name(const struct Rec_Resource *r, int state) {
|
||||
if (r->state_names != NULL && state >= 0 && state < r->state_count) {
|
||||
return r->state_names[state];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/*
|
||||
* BFS over the transition graph to find the shortest path from
|
||||
* current_state to wanted_state. Returns the first transition on
|
||||
* that path, or NULL if no path exists (or already stable).
|
||||
*/
|
||||
static const struct Rec_Transition *find_next_transition(const struct Rec_Resource *res) {
|
||||
if (res->current_state == res->wanted_state) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* prev[s] = index of transition in res->transitions that leads into state s,
|
||||
* or -1 if not yet visited. */
|
||||
int prev_trans[REC_MAX_STATES];
|
||||
int visited[REC_MAX_STATES];
|
||||
for (int i = 0; i < REC_MAX_STATES; i++) {
|
||||
prev_trans[i] = -1;
|
||||
visited[i] = 0;
|
||||
}
|
||||
|
||||
/* BFS queue — state indices. */
|
||||
int queue[REC_MAX_STATES];
|
||||
int head = 0;
|
||||
int tail = 0;
|
||||
|
||||
visited[res->current_state] = 1;
|
||||
queue[tail++] = res->current_state;
|
||||
|
||||
int found = 0;
|
||||
|
||||
while (head < tail && !found) {
|
||||
int cur = queue[head++];
|
||||
|
||||
for (int i = 0; ; i++) {
|
||||
const struct Rec_Transition *t = &res->transitions[i];
|
||||
if (t->from == -1 && t->to == -1 && t->action == NULL) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (t->from != cur) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int next = t->to;
|
||||
if (next < 0 || next >= REC_MAX_STATES) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (visited[next]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited[next] = 1;
|
||||
prev_trans[next] = i;
|
||||
queue[tail++] = next;
|
||||
|
||||
if (next == res->wanted_state) {
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Walk back from wanted_state to find the first step. */
|
||||
int state = res->wanted_state;
|
||||
int first_trans_idx = prev_trans[state];
|
||||
|
||||
while (1) {
|
||||
int ti = prev_trans[state];
|
||||
if (ti == -1) {
|
||||
break;
|
||||
}
|
||||
int from_state = res->transitions[ti].from;
|
||||
if (from_state == res->current_state) {
|
||||
first_trans_idx = ti;
|
||||
break;
|
||||
}
|
||||
first_trans_idx = ti;
|
||||
state = from_state;
|
||||
}
|
||||
|
||||
return &res->transitions[first_trans_idx];
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns 1 if all dependencies allow the resource to enter next_state.
|
||||
* Returns 0 if any dependency blocks it.
|
||||
*/
|
||||
static int deps_allow(const struct Rec_Resource *res, int next_state) {
|
||||
for (int i = 0; i < res->dep_count; i++) {
|
||||
const struct Rec_Dep *d = &res->deps[i];
|
||||
if (next_state >= d->blocked_below && d->dep->current_state < d->dep_min_state) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
Rec_Status reconciler_get_status(const struct Rec_Resource *r) {
|
||||
if (r->current_state == r->wanted_state) {
|
||||
return REC_STATUS_STABLE;
|
||||
}
|
||||
|
||||
const struct Rec_Transition *t = find_next_transition(r);
|
||||
if (t == NULL) {
|
||||
return REC_STATUS_NO_PATH;
|
||||
}
|
||||
|
||||
if (!deps_allow(r, t->to)) {
|
||||
return REC_STATUS_BLOCKED;
|
||||
}
|
||||
|
||||
return REC_STATUS_WORKING;
|
||||
}
|
||||
|
||||
int reconciler_tick(struct Reconciler *r) {
|
||||
int attempted = 0;
|
||||
|
||||
for (int i = 0; i < r->count; i++) {
|
||||
struct Rec_Resource *res = &r->resources[i];
|
||||
|
||||
if (res->current_state == res->wanted_state) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const struct Rec_Transition *t = find_next_transition(res);
|
||||
if (t == NULL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!deps_allow(res, t->to)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int from = res->current_state;
|
||||
int to = t->to;
|
||||
int success = t->action(res->userdata);
|
||||
attempted++;
|
||||
|
||||
if (success) {
|
||||
res->current_state = to;
|
||||
}
|
||||
|
||||
if (r->log_fn != NULL) {
|
||||
r->log_fn(res, from, to, success, r->log_userdata);
|
||||
}
|
||||
}
|
||||
|
||||
return attempted;
|
||||
}
|
||||
|
||||
int reconciler_is_stable(const struct Reconciler *r) {
|
||||
for (int i = 0; i < r->count; i++) {
|
||||
if (r->resources[i].current_state != r->resources[i].wanted_state) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ struct Transport_Conn {
|
||||
|
||||
struct Transport_Server {
|
||||
int listen_fd;
|
||||
uint16_t bound_port; /* actual port after bind */
|
||||
struct Transport_Server_Config config;
|
||||
pthread_t accept_thread;
|
||||
pthread_mutex_t count_mutex;
|
||||
@@ -209,6 +210,15 @@ struct App_Error transport_server_start(struct Transport_Server *server) {
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
|
||||
/* Read back the actual port (matters when config.port == 0) */
|
||||
struct sockaddr_in bound = {0};
|
||||
socklen_t bound_len = sizeof(bound);
|
||||
if (getsockname(fd, (struct sockaddr *)&bound, &bound_len) == 0) {
|
||||
server->bound_port = ntohs(bound.sin_port);
|
||||
} else {
|
||||
server->bound_port = server->config.port;
|
||||
}
|
||||
|
||||
if (listen(fd, SOMAXCONN) < 0) {
|
||||
close(fd);
|
||||
return APP_SYSCALL_ERROR();
|
||||
@@ -235,6 +245,10 @@ void transport_server_destroy(struct Transport_Server *server) {
|
||||
free(server);
|
||||
}
|
||||
|
||||
uint16_t transport_server_get_port(const struct Transport_Server *server) {
|
||||
return server->bound_port;
|
||||
}
|
||||
|
||||
struct App_Error transport_connect(struct Transport_Conn **out,
|
||||
const char *host, uint16_t port,
|
||||
uint32_t max_payload,
|
||||
@@ -305,5 +319,11 @@ struct App_Error transport_send_frame(struct Transport_Conn *conn,
|
||||
}
|
||||
|
||||
void transport_conn_close(struct Transport_Conn *conn) {
|
||||
close(conn->fd);
|
||||
/* shutdown() rather than close(): signals EOF to the remote end and
|
||||
* unblocks the read thread without releasing the fd. The read thread
|
||||
* is the sole owner of the fd and will close() it when it exits.
|
||||
* Using close() here would create a race where the fd number could be
|
||||
* reused by the next transport_connect() before the detached read
|
||||
* thread calls its own close(), which would then close the wrong fd. */
|
||||
shutdown(conn->fd, SHUT_RDWR);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,23 @@
|
||||
#include "xorg.h"
|
||||
#include "font_atlas.h" /* generated: font_glyphs[], font_atlas_pixels[], FONT_ATLAS_W/H */
|
||||
|
||||
/* Reference count for glfwInit/glfwTerminate.
|
||||
* All xorg calls happen on the main thread, so no locking needed. */
|
||||
static int glfw_ref_count = 0;
|
||||
|
||||
static void glfw_acquire(void)
|
||||
{
|
||||
if (glfw_ref_count == 0) { glfwInit(); }
|
||||
glfw_ref_count++;
|
||||
}
|
||||
|
||||
static void glfw_release(void)
|
||||
{
|
||||
if (glfw_ref_count <= 0) { return; }
|
||||
glfw_ref_count--;
|
||||
if (glfw_ref_count == 0) { glfwTerminate(); }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shader sources — video */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -52,6 +69,31 @@ static const char *FRAG_YUV_SRC =
|
||||
" out_color = vec4(r, g, b, 1.0);\n"
|
||||
"}\n";
|
||||
|
||||
/*
|
||||
* Animated analog-TV noise — no textures, driven by u_time.
|
||||
* u_noise_res: cells per axis (lower = coarser pixels). Default ~80.
|
||||
* Uses the same VERT_SRC quad; u_uv_scale / u_uv_offset set to identity.
|
||||
*/
|
||||
static const char *FRAG_NOSIGNAL_SRC =
|
||||
"#version 330 core\n"
|
||||
"in vec2 v_uv;\n"
|
||||
"out vec4 out_color;\n"
|
||||
"uniform float u_time;\n"
|
||||
"uniform float u_noise_res;\n"
|
||||
"float hash(vec2 p) {\n"
|
||||
" p = fract(p * vec2(127.1, 311.7));\n"
|
||||
" p += dot(p, p + 19.19);\n"
|
||||
" return fract(p.x * p.y);\n"
|
||||
"}\n"
|
||||
"void main() {\n"
|
||||
" vec2 cell = floor(v_uv * u_noise_res) / u_noise_res;\n"
|
||||
" float tick = floor(u_time * 30.0);\n"
|
||||
" float n = hash(cell + tick * 0.017);\n"
|
||||
" float scan = 0.78 + 0.22 * sin(v_uv.y * u_noise_res * 6.2832 * 3.0);\n"
|
||||
" float luma = n * scan;\n"
|
||||
" out_color = vec4(luma * 0.72, luma * 0.80, luma * 0.62, 1.0);\n"
|
||||
"}\n";
|
||||
|
||||
/* Passthrough for BGRA — uploaded as GL_BGRA so driver swizzles to RGBA. */
|
||||
static const char *FRAG_RGB_SRC =
|
||||
"#version 330 core\n"
|
||||
@@ -172,6 +214,13 @@ struct Xorg_Viewer {
|
||||
Xorg_Anchor anchor;
|
||||
int frame_w, frame_h;
|
||||
|
||||
/* No-signal noise */
|
||||
GLuint prog_nosignal;
|
||||
GLint u_nosignal_uv_scale;
|
||||
GLint u_nosignal_uv_offset;
|
||||
GLint u_nosignal_time;
|
||||
GLint u_nosignal_res;
|
||||
|
||||
/* Solid rect (overlay background) */
|
||||
GLuint prog_rect;
|
||||
GLint u_rect_loc;
|
||||
@@ -326,10 +375,7 @@ static bool init_text_rendering(Xorg_Viewer *v)
|
||||
Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
|
||||
const char *title)
|
||||
{
|
||||
if (!glfwInit()) {
|
||||
fprintf(stderr, "xorg: glfwInit failed\n");
|
||||
return NULL;
|
||||
}
|
||||
glfw_acquire();
|
||||
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
||||
@@ -338,7 +384,7 @@ Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
|
||||
GLFWwindow *win = glfwCreateWindow(width, height, title, NULL, NULL);
|
||||
if (!win) {
|
||||
fprintf(stderr, "xorg: glfwCreateWindow failed\n");
|
||||
glfwTerminate();
|
||||
glfw_release();
|
||||
return NULL;
|
||||
}
|
||||
glfwSetWindowPos(win, x, y);
|
||||
@@ -350,14 +396,14 @@ Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
|
||||
if (glewInit() != GLEW_OK) {
|
||||
fprintf(stderr, "xorg: glewInit failed\n");
|
||||
glfwDestroyWindow(win);
|
||||
glfwTerminate();
|
||||
glfw_release();
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Xorg_Viewer *v = calloc(1, sizeof(*v));
|
||||
if (!v) {
|
||||
glfwDestroyWindow(win);
|
||||
glfwTerminate();
|
||||
glfw_release();
|
||||
return NULL;
|
||||
}
|
||||
v->window = win;
|
||||
@@ -383,6 +429,13 @@ Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
|
||||
v->u_uv_scale_rgb = glGetUniformLocation(v->prog_rgb, "u_uv_scale");
|
||||
v->u_uv_offset_rgb= glGetUniformLocation(v->prog_rgb, "u_uv_offset");
|
||||
|
||||
v->prog_nosignal = link_program(VERT_SRC, FRAG_NOSIGNAL_SRC);
|
||||
if (!v->prog_nosignal) { xorg_viewer_close(v); return NULL; }
|
||||
v->u_nosignal_uv_scale = glGetUniformLocation(v->prog_nosignal, "u_uv_scale");
|
||||
v->u_nosignal_uv_offset = glGetUniformLocation(v->prog_nosignal, "u_uv_offset");
|
||||
v->u_nosignal_time = glGetUniformLocation(v->prog_nosignal, "u_time");
|
||||
v->u_nosignal_res = glGetUniformLocation(v->prog_nosignal, "u_noise_res");
|
||||
|
||||
glGenVertexArrays(1, &v->vao);
|
||||
glGenTextures(4, v->tex);
|
||||
|
||||
@@ -727,6 +780,7 @@ bool xorg_viewer_push_yuv420(Xorg_Viewer *v,
|
||||
int width, int height)
|
||||
{
|
||||
if (!v) { return false; }
|
||||
glfwMakeContextCurrent(v->window);
|
||||
v->frame_w = width;
|
||||
v->frame_h = height;
|
||||
upload_yuv(v, y, width, height, cb, width / 2, height / 2, cr);
|
||||
@@ -737,6 +791,7 @@ bool xorg_viewer_push_bgra(Xorg_Viewer *v,
|
||||
const uint8_t *data, int width, int height)
|
||||
{
|
||||
if (!v) { return false; }
|
||||
glfwMakeContextCurrent(v->window);
|
||||
|
||||
v->frame_w = width;
|
||||
v->frame_h = height;
|
||||
@@ -759,6 +814,7 @@ bool xorg_viewer_push_mjpeg(Xorg_Viewer *v,
|
||||
return false;
|
||||
#else
|
||||
if (!v) { return false; }
|
||||
glfwMakeContextCurrent(v->window);
|
||||
|
||||
int w, h, subsamp, colorspace;
|
||||
if (tjDecompressHeader3(v->tj, data, (unsigned long)size,
|
||||
@@ -800,6 +856,58 @@ bool xorg_viewer_push_mjpeg(Xorg_Viewer *v,
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* No-signal screen */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Render one frame of animated analog-TV noise with a centred "NO SIGNAL"
|
||||
* label. time is seconds (e.g. from glfwGetTime()); noise_res is cells
|
||||
* per axis — lower = coarser pixels (default: 80).
|
||||
* Call at ~15 fps when the viewer has no live stream to display.
|
||||
*/
|
||||
void xorg_viewer_render_no_signal(Xorg_Viewer *v, float time, float noise_res)
|
||||
{
|
||||
if (!v) { return; }
|
||||
glfwMakeContextCurrent(v->window);
|
||||
|
||||
int fb_w, fb_h;
|
||||
glfwGetFramebufferSize(v->window, &fb_w, &fb_h);
|
||||
|
||||
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
glViewport(0, 0, fb_w, fb_h);
|
||||
|
||||
glUseProgram(v->prog_nosignal);
|
||||
glUniform2f(v->u_nosignal_uv_scale, 1.0f, 1.0f);
|
||||
glUniform2f(v->u_nosignal_uv_offset, 0.0f, 0.0f);
|
||||
glUniform1f(v->u_nosignal_time, time);
|
||||
glUniform1f(v->u_nosignal_res, noise_res > 0.0f ? noise_res : 80.0f);
|
||||
|
||||
glBindVertexArray(v->vao);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
glBindVertexArray(0);
|
||||
|
||||
/* Measure "NO SIGNAL" text width to centre it. */
|
||||
const char *label = "NO SIGNAL";
|
||||
int text_w = 0, max_ascent = 0;
|
||||
for (const char *p = label; *p; p++) {
|
||||
unsigned char cp = (unsigned char)*p;
|
||||
text_w += font_glyphs[cp].advance;
|
||||
if (font_glyphs[cp].bearing_y > max_ascent) {
|
||||
max_ascent = font_glyphs[cp].bearing_y;
|
||||
}
|
||||
}
|
||||
int tx = (fb_w - text_w) / 2;
|
||||
int ty = (fb_h - max_ascent) / 2;
|
||||
|
||||
xorg_viewer_set_overlay_text(v, 0, tx, ty, label, 1.0f, 1.0f, 1.0f);
|
||||
draw_text_overlays(v, fb_w, fb_h);
|
||||
xorg_viewer_clear_overlays(v);
|
||||
|
||||
glfwSwapBuffers(v->window);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Poll and close */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -809,6 +917,7 @@ bool xorg_viewer_poll(Xorg_Viewer *v)
|
||||
if (!v || glfwWindowShouldClose(v->window)) { return false; }
|
||||
glfwPollEvents();
|
||||
if (glfwWindowShouldClose(v->window)) { return false; }
|
||||
glfwMakeContextCurrent(v->window);
|
||||
render(v);
|
||||
return true;
|
||||
}
|
||||
@@ -832,13 +941,14 @@ void xorg_viewer_close(Xorg_Viewer *v)
|
||||
if (v->tex_atlas) { glDeleteTextures(1, &v->tex_atlas); }
|
||||
if (v->prog_text) { glDeleteProgram(v->prog_text); }
|
||||
if (v->prog_rect) { glDeleteProgram(v->prog_rect); }
|
||||
if (v->vao) { glDeleteVertexArrays(1, &v->vao); }
|
||||
if (v->tex[0]) { glDeleteTextures(4, v->tex); }
|
||||
if (v->prog_yuv) { glDeleteProgram(v->prog_yuv); }
|
||||
if (v->prog_rgb) { glDeleteProgram(v->prog_rgb); }
|
||||
if (v->vao) { glDeleteVertexArrays(1, &v->vao); }
|
||||
if (v->tex[0]) { glDeleteTextures(4, v->tex); }
|
||||
if (v->prog_yuv) { glDeleteProgram(v->prog_yuv); }
|
||||
if (v->prog_rgb) { glDeleteProgram(v->prog_rgb); }
|
||||
if (v->prog_nosignal) { glDeleteProgram(v->prog_nosignal); }
|
||||
if (v->window) {
|
||||
glfwDestroyWindow(v->window);
|
||||
glfwTerminate();
|
||||
glfw_release();
|
||||
}
|
||||
free(v);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ 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
|
||||
XORG_OBJ = $(BUILD)/xorg/xorg.o
|
||||
PROTOCOL_OBJ = $(BUILD)/protocol/protocol.o
|
||||
RECONCILER_OBJ = $(BUILD)/reconciler/reconciler.o
|
||||
INGEST_OBJ = $(BUILD)/ingest/ingest.o
|
||||
XORG_OBJ = $(BUILD)/xorg/xorg.o
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
@@ -20,21 +22,25 @@ 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) \
|
||||
$(XORG_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ -lpthread $(PKG_LDFLAGS)
|
||||
$(RECONCILER_OBJ) $(INGEST_OBJ) $(XORG_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ -lpthread -lm $(PKG_LDFLAGS)
|
||||
|
||||
$(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
|
||||
$(XORG_OBJ): ; $(MAKE) -C $(ROOT)/src/modules/xorg
|
||||
# 'force' ensures the sub-make is always invoked so it can check source timestamps itself.
|
||||
.PHONY: force
|
||||
$(COMMON_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/common
|
||||
$(MEDIA_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/media_ctrl
|
||||
$(V4L2_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/v4l2_ctrl
|
||||
$(SERIAL_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/serial
|
||||
$(TRANSPORT_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/transport
|
||||
$(DISCOVERY_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/discovery
|
||||
$(CONFIG_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/config
|
||||
$(PROTOCOL_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/protocol
|
||||
$(RECONCILER_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/reconciler
|
||||
$(INGEST_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/ingest
|
||||
$(XORG_OBJ): force ; $(MAKE) -C $(ROOT)/src/modules/xorg
|
||||
|
||||
$(NODE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
1256
src/node/main.c
1256
src/node/main.c
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user