Compare commits

...

34 Commits

Author SHA1 Message Date
92ba1adf29 docs: add discovery flow diagrams, document restart detection limitation
- Four Mermaid sequence diagrams: startup, steady-state keepalive, node
  loss/timeout, and node restart
- Explicitly document that the site_id-change restart heuristic does not work
  in practice (site_id is static config, not a runtime value)
- Describe what needs to change: a boot nonce (random u32 at startup)
- Add boot nonce as a deferred item in planning.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 04:39:52 +00:00
f3a6be0701 docs: update discovery behaviour — targeted unicast replies, not multicast
Document that immediate re-announcements go directly to the triggering peer
(unicast) rather than to the multicast group, and explain the two conditions
that trigger a reply: new peer and restarted peer (site_id change).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 04:35:28 +00:00
a5198e84d2 Discovery: reply unicast when responding to new/restarted peer
When we see a new peer or detect a restart (site_id change for known addr+port),
send the announcement directly to that host via unicast instead of broadcasting
to the multicast group. This avoids waking every other node on the subnet for a
reply that is only relevant to one machine.

The periodic multicast announcements continue unchanged for initial discovery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 04:31:56 +00:00
780f45b46f Fix discovery re-announce: gate on new peer or restarted peer, not every packet
The original unconditional cond_signal (every received packet) caused a
multicast storm: each node instantly reflected every announcement back as its
own, creating a tight loop at wire speed.

The previous fix (gate on is_new only) broke the restart case: a peer that
restarts with the same addr+port is already in the table so is_new stays 0,
meaning we'd wait up to interval_ms before that peer learned about us.

Correct fix: also signal when site_id changes for a known addr+port entry,
which reliably indicates a restart. Steady-state keepalive packets (same
site_id) no longer trigger re-announcement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 04:31:14 +00:00
b4facf04be Fix multicast storm: only re-announce on new peer, not every received packet
The announce thread was being woken (triggering a multicast send) on every
received announcement packet, including ones from already-known peers. With
two or more nodes this created a feedback loop: each incoming packet triggered
an outbound multicast which triggered another incoming packet on the peer, and
so on at full CPU/network speed.

Gate the cond_signal on is_new so we only fast-announce when a genuinely new
peer is seen. The periodic interval-based announcement continues to handle
keepalives and reconnections for existing peers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 04:29:50 +00:00
8fa2f33bad Rename scale → scale_mode in protocol/struct layer; add control grouping future note
- `Proto_Display_Device_Info.scale` → `scale_mode`
- `Proto_Start_Display.scale` → `scale_mode`
- `PROTO_DISPLAY_CTRL_SCALE` → `PROTO_DISPLAY_CTRL_SCALE_MODE`
- `proto_write_start_display` param and all callers updated
- `on_display` callback param and all sites updated
- `Display_Slot.scale` → `scale_mode` in node
- Control name "Scale" → "Scale Mode"
- planning.md: add control grouping deferred decision

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:54:22 +00:00
7777292dfd planning: revert free pan/zoom note to original wording
scale is scale_mode, zoom_factor is a separate multiplier — they compose
rather than conflict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:48:09 +00:00
87b9800e41 planning: clarify free mode replaces scale modes rather than composing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:47:41 +00:00
86f135792f planning: note free pan/zoom mode for display viewer
Current anchor system only handles fixed alignment; free mode needed
for arbitrary pan offset + zoom level, e.g. microscope inspection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:46:52 +00:00
44090c1d6d docs: write CLI docs for all 12 previously undocumented tools
Add docs/cli/ entries for:
  transport_cli, discovery_cli, config_cli, protocol_cli, query_cli,
  test_image_cli, xorg_cli, v4l2_view_cli, stream_send_cli,
  stream_recv_cli, reconciler_cli, controller_cli

Each doc covers: description, build instructions, full usage with all
options and defaults, example output, and a relationship note pointing
to related tools. controller_cli includes the display control IDs table
and notes its temporary status.

README.md: convert all CLI tool entries to links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:45:27 +00:00
8c4cd69443 Display device controls; device IDs in enum-devices; fix non-OK parse
Display controls (enum/get/set):
- Add PROTO_DISPLAY_CTRL_SCALE/ANCHOR/NO_SIGNAL_FPS constants to protocol.h
- handle_enum_controls: if device index maps to an active display slot,
  return the three display controls (scale, anchor, no_signal_fps)
- handle_get_control: read display control values from slot under mutex
- handle_set_control: write display control values to slot under mutex;
  scale/anchor are applied to the viewer by display_loop_tick each tick

Device IDs in enum-devices output:
- Proto_Display_Device_Info gains device_id field (wire format +2 bytes)
- handle_enum_devices computes device_id = total_v4l2 + display_index
- on_video_node/on_standalone callbacks take int* userdata to print [idx]
- on_display prints [device_id] from the wire field

Bug fix — protocol error on invalid device index:
- proto_read_enum_controls_response: early-return APP_OK after reading
  status if status != OK; error responses have no count/data fields, so
  the CUR_CHECK on count was failing with "payload too short"

Helpers added to main.c:
- count_v4l2_devices(): sum of media vnodes + standalone
- find_display_by_device_idx(): maps flat index to Display_Slot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:02:42 +00:00
1066f793e2 docs: sync docs with code; fix Makefile modules target
Makefile:
- Add reconciler and ingest to the `modules` target; they were only built
  as side-effects of `make node`, making `make modules` incomplete

planning.md:
- Add 4 missing CLI drivers: discovery_cli, config_cli, protocol_cli,
  query_cli (all existed in code and dev/cli/Makefile but were absent)
- Add header-only utilities table: stream_stats.h, v4l2_fmt.h

README.md:
- Add transport_cli, discovery_cli, config_cli, protocol_cli, query_cli
  to CLI tools list

conventions.md:
- Add ERR_NOT_FOUND to Error_Code enum example
- Replace placeholder Invalid_Error_Detail with actual fields
  (config_line, message) that have been in use since config module
- Add missing error macros: APP_INVALID_ERROR, APP_INVALID_ERROR_MSG,
  APP_NOT_FOUND_ERROR
- Update directory structure: node/ description (was "later"), add web/
  and tools/ entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:55:43 +00:00
15f4a0f560 planning: note controller_cli is temporary; controller binary is the target
The long-term replacement is a dedicated controller binary outside dev/cli
that maintains simultaneous connections to all discovered nodes and addresses
commands by peer index — mirroring the web UI model rather than the current
single-active-connection design.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:51:46 +00:00
ae2cc51626 Fix transport_conn_close fd double-close race
transport_conn_close previously called close(conn->fd), but the detached
read thread also calls close(conn->fd) when it exits.  If the kernel reused
the fd number before the read thread ran, the thread's close() would hit
the new connection — explaining connections that appeared to not terminate.

Fix: use shutdown(SHUT_RDWR) instead.  This signals EOF to the remote end
and unblocks the blocked read() without releasing the fd.  The read thread
remains the sole owner of the fd and is the only one to call close().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:54:55 +00:00
835cbbafba Fix connect accumulation; add display sinks to enum-devices
- controller_cli: drain semaphore and reset pending_cmd in do_connect
  so stale posts from old connection don't unblock the next command
- protocol: add Proto_Display_Device_Info; extend
  proto_write_enum_devices_response and proto_read_enum_devices_response
  with display section; backward-compatible (absent in older messages)
- node: handle_enum_devices snapshots active Display_Slots under mutex
  and includes them in the response
- controller_cli: on_display callback prints display window info in
  enum-devices output
- query_cli: updated to pass NULL on_display (no display interest)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:48:22 +00:00
961933e714 Docs: add peer addressing and connection multiplexing to deferred decisions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:36:13 +00:00
2481c3bae4 Refactor no-signal timing to integer milliseconds
Replace double wall-time with uint64_t monotonic milliseconds for
last_frame_ms and last_no_signal_ms. Integer ms is the right type
for a threshold comparison — no floating point needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:33:22 +00:00
8460841e8e Fix no-signal/video fight: only render no-signal after 1s of silence
The loop runs at ~200Hz; frames arrive at ~30fps. Most iterations have no
pending frame even during active streaming, so no-signal was rendering
between real frames. Fix: track last_frame_t and suppress no-signal while
a live stream is present (< 1s since last frame).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:32:20 +00:00
30ad5fbeae Fix no-signal noise: wrap time to [0,1000) to preserve float32 precision
CLOCK_MONOTONIC returns seconds since boot (~50000+s on a running system).
At that magnitude, float32 loses fractional precision in the hash function
and all cells evaluate to near-zero, producing a black screen instead of noise.
Wrapping to fmod(now, 1000.0) keeps the value small enough for the shader.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:31:09 +00:00
d6fe653a2e Fix discovery: key peers on (addr, tcp_port) not (addr, name)
Two nodes on the same host with the same name (e.g. unnamed:0) would
collide — the second announcement just updated the first entry's port.
Peer identity is addr+port; name is metadata, not identity.

Same fix applied to the self-skip check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:28:15 +00:00
ba2c3cb6cd controller_cli: readline, discovery integration, peers/connect commands
- readline replaces fgets — line editing and command history
- Discovery runs at startup (always); discovered peers print inline as they appear
- --host is now optional; without it, starts in discovery-only mode
- New REPL commands:
    peers              list discovered nodes with index
    connect            connect to first discovered peer
    connect <idx>      connect to peer by index
    connect <host:port> connect directly
- connect switching closes the old connection before opening the new one
- Commands that require a connection print "not connected" when conn is NULL
- Makefile: add $(DISCOVERY_OBJ) and -lreadline to controller_cli link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:26:38 +00:00
54d48c9c8e Add no-signal animation to display windows
When a viewer window has no incoming stream, renders animated analog-TV
noise (hash-based, scanlines, phosphor tint) at configurable fps (default
15) with a centred "NO SIGNAL" text overlay.

- xorg: FRAG_NOSIGNAL_SRC shader + xorg_viewer_render_no_signal(v, time, noise_res)
- main: Display_Slot gains no_signal_fps + last_no_signal_t; display_loop_tick
  drives no-signal render on idle slots via clock_gettime rate limiting
- protocol: START_DISPLAY extended by 2 bytes — no_signal_fps (0=default 15)
  + reserved; reader is backward-compatible (defaults 0 if length < 18)
- controller_cli: no_signal_fps optional arg on start-display
- docs: protocol.md updated with new field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:20:53 +00:00
7808d832be Docs: plan unified device model and controller_cli improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:16:09 +00:00
f8ecade810 Docs: update README status — ingest/reconciler done, display sink, controller_cli
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:12:13 +00:00
996397d615 Docs: fix module table ordering in planning.md
Move ingest and reconciler above node (node depends on both).
Renumber frame_alloc/relay/archive/codec/web node accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:10:30 +00:00
edf2208e08 Docs: fix stale directory structure in planning.md
Add missing modules (config, discovery, reconciler, ingest) and update
node description from "later" to reflect its current done state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:09:10 +00:00
b7e87ceb46 Docs: fix stale planning.md entries for node sink role and controller_cli
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:07:14 +00:00
f5764940e6 Docs: display sink commands, GLFW multi-window notes, planning updates
- protocol.md: add START_DISPLAY (0x000A) and STOP_DISPLAY (0x000B) wire
  schemas and field descriptions; add both to command table
- xorg.md: add 'Multiple windows' section covering glfwPollEvents global
  behaviour, per-context glfwMakeContextCurrent requirement, and
  glfwInit/glfwTerminate ref-counting; includes the gotcha that
  short-circuiting the event loop can starve non-polled windows
- planning.md: add cooperative capture release deferred decision;
  add xorg viewer remote controls (zoom, pan, scale, future shader
  post-processing) to deferred decisions; note xorg viewer controls
  not yet exposed remotely in module table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:03:30 +00:00
32d31cbd1e Add display sink: START_DISPLAY/STOP_DISPLAY, multi-window xorg, random port
Protocol:
- Add PROTO_CMD_START_DISPLAY (0x000A) and PROTO_CMD_STOP_DISPLAY (0x000B)
  with write/read functions; Proto_Start_Display carries stream_id, window
  position/size, scale and anchor; PROTO_DISPLAY_SCALE_*/ANCHOR_* constants

Node display sink:
- Display_Slot struct with wanted_state/current_state (DISP_CLOSED/DISP_OPEN);
  handlers set wanted state, display_loop_tick on main thread reconciles
- Up to MAX_DISPLAYS (4) simultaneous viewer windows
- on_frame routes incoming VIDEO_FRAME messages to matching display slot;
  transport thread deposits payload, main thread consumes without holding lock
  during JPEG decode/upload
- Main thread runs GL event loop when xorg is available; headless fallback
  joins reconciler timer thread as before

Xorg multi-window:
- Ref-count glfwInit/glfwTerminate via glfw_acquire/glfw_release so closing
  one viewer does not terminate GLFW for remaining windows
- Add glfwMakeContextCurrent before GL calls in push_yuv420, push_bgra,
  push_mjpeg and poll so each viewer uses its own GL context correctly

Transport random port:
- Bind port 0 lets the OS assign a free port; getsockname reads it back
  into server->bound_port after bind
- Add transport_server_get_port() accessor
- Default tcp_port changed from 8000 to 0 (random); node prints actual
  port after server start so it is always visible in output
- Add --port PORT CLI override (before config-file argument)

controller_cli:
- Add start-display and stop-display commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:03:21 +00:00
28216999e0 Fix make sub-make staleness and stats delivery accounting
- Add 'force' phony prerequisite to all sub-make delegation rules in
  dev/cli/Makefile and src/node/Makefile so the sub-make is always
  invoked and can check source timestamps itself; previously a stale
  .o would never be rebuilt by a dependent Makefile
- Move stream_stats_record_frame inside the successful send branch in
  on_ingest_frame so stats reflect actual delivered frames rather than
  capture throughput; avoids misleading Mbps readings when the
  transport is disconnected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:03:02 +00:00
a2f438bbbb Add controller_cli — interactive node controller REPL
Connects to a running video node by host:port. Supports:
  enum-devices, enum-controls, get-control, set-control,
  start-ingest, stop-ingest

Uses semaphore-based request/response synchronisation (same pattern as
query_cli). start-ingest maps directly to the new START_INGEST protocol
command with optional format/size/fps args; defaults to auto-select.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 02:22:57 +00:00
6747c9e00d Wire reconciler and ingest into video node
Each ingest stream gets two reconciler resources (device, transport) with
dependencies: transport waits for device OPEN (needs format for STREAM_OPEN),
device waits for transport CONNECTED before starting capture.

START_INGEST sets wanted state and triggers a tick; the reconciler drives
device CLOSED→OPEN→STREAMING and transport DISCONNECTED→CONNECTED over
subsequent ticks. STOP_INGEST reverses both.

External events (transport drop, ingest thread error) use
reconciler_force_current to push state backward; the periodic 500ms timer
thread re-drives toward wanted state automatically.

All 8 stream slots are pre-allocated at startup. on_ingest_frame sends
VIDEO_FRAME messages over the outbound transport connection, protected by
a per-stream conn_mutex.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 02:17:16 +00:00
6c9e0ce7dc Add START_INGEST and STOP_INGEST protocol commands
START_INGEST carries stream_id, format/width/height/fps, dest_host:port,
transport_mode (encapsulated or opaque), and device_path. All format fields
default to 0 (auto-select). STOP_INGEST carries stream_id only.

Both commands set wanted state on the node; reconciliation is asynchronous.
Protocol doc updated with wire schemas for both commands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 02:02:38 +00:00
639a84b1b9 Add reconciler and ingest modules with CLI driver
reconciler: generic resource state machine — BFS pathfinding from current
to wanted state, dependency constraints, event/periodic tick model.
reconciler_cli exercises it with simulated device/transport/stream resources.

ingest: V4L2 capture module — open device, negotiate MJPEG format, MMAP
buffer pool, capture thread with on_frame callback. start/stop lifecycle
designed for reconciler management. Transport-agnostic: caller wires
on_frame to proto_write_video_frame.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 01:52:17 +00:00
38 changed files with 4763 additions and 275 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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
```

View File

@@ -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
View 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;
}

View File

@@ -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
View 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
View 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
View 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 | 03 | 0=stretch 1=fit 2=fill 3=1:1 |
| `0x00D00002` | Anchor | 01 | 0=center 1=topleft |
| `0x00D00003` | No-signal FPS | 160 | 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
View 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
View 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
View 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).

View 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.

View 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.

View 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.

View 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
View 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
View 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
View 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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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);

View File

@@ -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
View 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);

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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);

View 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
View 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; }

View File

@@ -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);

View 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

View 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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 $@

File diff suppressed because it is too large Load Diff