Compare commits
58 Commits
34386b635e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 92ba1adf29 | |||
| f3a6be0701 | |||
| a5198e84d2 | |||
| 780f45b46f | |||
| b4facf04be | |||
| 8fa2f33bad | |||
| 7777292dfd | |||
| 87b9800e41 | |||
| 86f135792f | |||
| 44090c1d6d | |||
| 8c4cd69443 | |||
| 1066f793e2 | |||
| 15f4a0f560 | |||
| ae2cc51626 | |||
| 835cbbafba | |||
| 961933e714 | |||
| 2481c3bae4 | |||
| 8460841e8e | |||
| 30ad5fbeae | |||
| d6fe653a2e | |||
| ba2c3cb6cd | |||
| 54d48c9c8e | |||
| 7808d832be | |||
| f8ecade810 | |||
| 996397d615 | |||
| edf2208e08 | |||
| b7e87ceb46 | |||
| f5764940e6 | |||
| 32d31cbd1e | |||
| 28216999e0 | |||
| a2f438bbbb | |||
| 6747c9e00d | |||
| 6c9e0ce7dc | |||
| 639a84b1b9 | |||
| 4e40223478 | |||
| beaeea8dab | |||
| e0934afadb | |||
| 6fe45ee097 | |||
| de87425083 | |||
| 61c81398bb | |||
| 611376dbc1 | |||
| 7fd79e6120 | |||
| ef0319b45b | |||
| a1b52145d0 | |||
| 98c700390d | |||
| 14926f5421 | |||
| 24d031d42b | |||
| 08ee9a2eb0 | |||
| 2364170053 | |||
| 4d50c5fa80 | |||
| c9fa44e6e5 | |||
| fa9d8696fe | |||
| 7e5628f44c | |||
| 49e5076eea | |||
| ab47729d74 | |||
| d6b6b0042d | |||
| e1151410ad | |||
| 62c25247ef |
26
Makefile
26
Makefile
@@ -1,9 +1,29 @@
|
||||
.PHONY: all clean cli
|
||||
ROOT := $(abspath .)
|
||||
include $(ROOT)/common.mk
|
||||
|
||||
all: cli
|
||||
.PHONY: all clean modules cli node
|
||||
|
||||
cli:
|
||||
all: modules cli node
|
||||
|
||||
modules:
|
||||
$(MAKE) -C src/modules/common
|
||||
$(MAKE) -C src/modules/media_ctrl
|
||||
$(MAKE) -C src/modules/v4l2_ctrl
|
||||
$(MAKE) -C src/modules/serial
|
||||
$(MAKE) -C src/modules/transport
|
||||
$(MAKE) -C src/modules/discovery
|
||||
$(MAKE) -C src/modules/config
|
||||
$(MAKE) -C src/modules/protocol
|
||||
$(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
|
||||
|
||||
node: modules
|
||||
$(MAKE) -C src/node
|
||||
|
||||
clean:
|
||||
rm -rf build/
|
||||
|
||||
53
README.md
53
README.md
@@ -15,6 +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
|
||||
- [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
|
||||
|
||||
@@ -22,24 +34,33 @@ Designed to run on resource-constrained hardware (Raspberry Pi capturing raw MJP
|
||||
src/modules/ C modules, one directory each
|
||||
include/ public headers
|
||||
dev/cli/ CLI driver programs for each module
|
||||
dev/web/ development web UI (Node.js/Express, depends on protocol)
|
||||
dev/web/ development web UI (Node.js/Express) — connects to live nodes for V4L2 inspection and control
|
||||
tools/ build-time code generators (e.g. gen_font_atlas — bitmap font atlas for xorg text overlays)
|
||||
docs/ documentation
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
| Module | Status |
|
||||
|---|---|
|
||||
| `common` | done |
|
||||
| `media_ctrl` | done |
|
||||
| `v4l2_ctrl` | done |
|
||||
| `serial` | done |
|
||||
| `transport` | not started |
|
||||
| `protocol` | not started |
|
||||
| `frame_alloc` | not started |
|
||||
| `relay` | not started |
|
||||
| `ingest` | not started |
|
||||
| `archive` | not started |
|
||||
| `codec` | not started |
|
||||
| `xorg` | not started |
|
||||
| `web node` | 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 |
|
||||
|---|---|---|
|
||||
| `common` | done | Error types, base definitions |
|
||||
| `config` | done | INI file loader, schema-driven defaults, typed getters |
|
||||
| `media_ctrl` | done | Media Controller topology, pad format config |
|
||||
| `v4l2_ctrl` | done | V4L2 control enumeration, get/set |
|
||||
| `serial` | done | Little-endian binary serialization primitives |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `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 |
|
||||
|
||||
529
architecture.md
529
architecture.md
@@ -6,8 +6,29 @@ A graph-based multi-peer video routing system where nodes are media processes an
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Transport Protocol and Serialization](docs/transport.md) — wire modes, frame header, message types, protocol serialization layer, web peer
|
||||
- [Relay Design](docs/relay.md) — delivery modes, memory model, congestion handling, multi-input scheduling
|
||||
- [Codec Module](docs/codec.md) — stream metadata fields, format negotiation, codec backends
|
||||
- [X11 / Xorg Integration](docs/xorg.md) — screen geometry, screen grab source, frame viewer sink, renderers
|
||||
- [Node Discovery and Multi-Site](docs/discovery.md) — multicast announcement format, behaviour, site identity and gateways
|
||||
- [Node State Model](docs/node-state.md) — wanted vs current state, reconciler, resource state machines, stream stats, state queries, stream ID assignment, connection direction
|
||||
- [Device Resilience](docs/device-resilience.md) — transient device loss handling, stream events, recovery loop, audio (future)
|
||||
- [Protocol Reference](docs/protocol.md) — full message payload schemas
|
||||
|
||||
---
|
||||
|
||||
## Design Rationale
|
||||
|
||||
### Declarative, Not Imperative
|
||||
|
||||
The control model is **declarative**: the controller sets *wanted state* on each node ("you should be ingesting /dev/video0 and sending stream 3 to node B"), and each node is responsible for reconciling its current state toward that goal autonomously. The controller does not issue step-by-step commands like "open device", "connect to peer", "start sending".
|
||||
|
||||
This is a deliberate architectural decision. Imperative orchestration — where the controller drives each resource transition directly — is fragile: the controller must track the state of every remote resource, handle every failure sequence, and re-issue commands when things go wrong. Declarative orchestration pushes that responsibility to the node, which is the only place with direct access to its own resources and the ability to respond to local failures (device disconnect, transport drop, process crash) without round-tripping through the controller.
|
||||
|
||||
The practical effect: the controller writes wanted state and the node's reconciler does the rest. The controller can query both wanted and current state at any time to understand the topology and health of the network — see [Node State Model](docs/node-state.md).
|
||||
|
||||
### Get It on the Wire First
|
||||
|
||||
A key principle driving the architecture is that **capture devices should not be burdened with processing**.
|
||||
@@ -29,7 +50,7 @@ This is also why the V4L2 remote control protocol is useful — the Pi doesn't n
|
||||
|
||||
### Nodes
|
||||
|
||||
Each node is a named process instance, identified by a namespace and name (e.g. `v4l2:microscope`, `ffmpeg:ingest1`, `mpv:preview`, `archiver:main`).
|
||||
Each node is a named process instance, identified by a namespace and name (e.g. `v4l2:microscope`, `xorg:preview`, `archiver:main`).
|
||||
|
||||
Node types:
|
||||
|
||||
@@ -47,7 +68,7 @@ An edge is a transport connection between two nodes. Edges carry:
|
||||
|
||||
- The video stream itself (TCP, pipe, or other transport)
|
||||
- A **priority** value
|
||||
- A **transport mode** — opaque or encapsulated (see [Transport Protocol](#transport-protocol))
|
||||
- A **transport mode** — opaque or encapsulated (see [Transport Protocol](docs/transport.md))
|
||||
|
||||
### Priority
|
||||
|
||||
@@ -66,9 +87,11 @@ There is no central hub or broker. Nodes communicate directly with each other ov
|
||||
|
||||
The controller role is a capability, not a singleton. Multiple nodes could hold it simultaneously; which one a user interacts with is a matter of which they connect to. A node that is purely a source or relay with no UI holds no controller bits.
|
||||
|
||||
The practical flow is: a user starts a node with the controller role and a web interface, discovers the other nodes on the network via the multicast announcement layer, and uses the UI to configure how streams are routed between them. The controller node issues connection instructions directly to the relevant peers over the binary protocol — there is no intermediary.
|
||||
The practical flow is: a user starts a node with the controller role, discovers the other nodes on the network via the multicast announcement layer, and uses the interface to configure how streams are routed between them. The controller writes wanted state to the relevant peers over the binary protocol — each peer then reconciles its own resources autonomously. There is no intermediary and no imperative step-by-step orchestration.
|
||||
|
||||
V4L2 device control and enumeration are carried as control messages within the encapsulated transport on the same connection as video — see [Transport Protocol](#transport-protocol).
|
||||
The first controller interface is a CLI tool (`controller_cli`), which exercises the same protocol that the eventual web UI will use. The web UI is a later addition — the protocol and node behaviour are identical either way.
|
||||
|
||||
V4L2 device control and enumeration are carried as control messages within the encapsulated transport on the same connection as video — see [Transport Protocol](docs/transport.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -80,8 +103,8 @@ graph LR
|
||||
PI -->|encapsulated stream| RELAY[Relay]
|
||||
RELAY -->|high priority| DISPLAY[Display / Preview<br>low latency]
|
||||
RELAY -->|low priority| ARCHIVE[Archiver<br>high quality]
|
||||
CTRL[Controller node<br>web UI] -.->|V4L2 control<br>via transport| PI
|
||||
CTRL -.->|connection config| RELAY
|
||||
CTRL[Controller node<br>CLI or web UI] -.->|V4L2 control<br>via transport| PI
|
||||
CTRL -.->|wanted state| RELAY
|
||||
```
|
||||
|
||||
The Pi runs a node process that dequeues V4L2 buffers and forwards each buffer as an encapsulated frame over TCP. It also exposes the V4L2 control endpoint for remote parameter adjustment.
|
||||
@@ -102,349 +125,6 @@ This module is explicitly a workaround for non-compliant hardware. It is not par
|
||||
|
||||
---
|
||||
|
||||
## Transport Protocol
|
||||
|
||||
Transport between nodes operates in one of two modes. The choice is per-edge and has direct implications for what the relay on that edge can do.
|
||||
|
||||
### Opaque Binary Stream
|
||||
|
||||
The transport forwards bytes as they arrive with no understanding of frame boundaries. The relay acts as a pure byte pipe.
|
||||
|
||||
- Zero framing overhead
|
||||
- Cannot drop frames (frame boundaries are unknown)
|
||||
- Cannot multiplex multiple streams (no way to distinguish them)
|
||||
- Cannot do per-frame accounting (byte budgets become byte-rate estimates only)
|
||||
- Low-latency output is not available — the relay cannot discard a partial frame
|
||||
|
||||
This mode is appropriate for simple point-to-point forwarding where the consumer handles all framing, and where the relay has no need for frame-level intelligence.
|
||||
|
||||
### Frame-Encapsulated Stream
|
||||
|
||||
Each message is prefixed with a small fixed-size header. This applies to both video frames and control messages — the transport is unified.
|
||||
|
||||
Header fields:
|
||||
|
||||
| Field | Size | Purpose |
|
||||
|---|---|---|
|
||||
| `message_type` | 2 bytes | Determines how the payload is interpreted |
|
||||
| `payload_length` | 4 bytes | Byte length of the following payload |
|
||||
|
||||
The header is intentionally minimal. Any node — including a relay that does not recognise a message type — can skip or forward the frame by reading exactly `payload_length` bytes without needing to understand the payload. All message-specific identifiers (stream ID, correlation ID, etc.) live inside the payload and are handled by the relevant message type handler.
|
||||
|
||||
**Message types and their payload structure:**
|
||||
|
||||
| Value | Type | Payload starts with |
|
||||
|---|---|---|
|
||||
| `0x0001` | Video frame | `stream_id` (u16), then compressed frame data |
|
||||
| `0x0002` | Control request | `request_id` (u16), then command-specific fields |
|
||||
| `0x0003` | Control response | `request_id` (u16), then result-specific fields |
|
||||
| `0x0004` | Stream event | `stream_id` (u16), `event_code` (u8), then event-specific fields |
|
||||
|
||||
Node-level messages (not tied to any stream or request) have no prefix beyond the header — the payload begins with the message-specific fields directly.
|
||||
|
||||
Control payloads are binary-serialized structures — see [Protocol Serialization](#protocol-serialization). Stream events carry lifecycle signals — see [Device Resilience](#device-resilience).
|
||||
|
||||
### Unified Control and Video on One Connection
|
||||
|
||||
By carrying control messages on the same transport as video frames, the system avoids managing separate connections per peer. A node that receives a video stream can be queried or commanded over the same socket.
|
||||
|
||||
This directly enables **remote device enumeration**: a connecting node can issue a control request asking what V4L2 devices the remote host exposes, and receive the list in a control response — before any video streams are established. Discovery and streaming share the same channel.
|
||||
|
||||
The V4L2 control operations map naturally to control request/response pairs:
|
||||
|
||||
| Operation | Direction |
|
||||
|---|---|
|
||||
| Enumerate devices | request → response |
|
||||
| Get device controls (parameters, ranges, menus) | request → response |
|
||||
| Get control values | request → response |
|
||||
| Set control values | request → response (ack/fail) |
|
||||
|
||||
Control messages are low-volume and can be interleaved with the video frame stream without meaningful overhead.
|
||||
|
||||
### Capability Implications
|
||||
|
||||
| Feature | Opaque | Encapsulated |
|
||||
|---|---|---|
|
||||
| Simple forwarding | yes | yes |
|
||||
| Low-latency drop | **no** | yes |
|
||||
| Per-frame byte accounting | **no** | yes |
|
||||
| Multi-stream over one transport | **no** | yes |
|
||||
| Sequence numbers / timestamps | **no** | yes (via extension) |
|
||||
| Control / command channel | **no** | yes |
|
||||
| Remote device enumeration | **no** | yes |
|
||||
| Stream lifecycle signals | **no** | yes |
|
||||
|
||||
The most important forcing function is **low-latency relay**: to drop a pending frame when a newer one arrives, the relay must know where frames begin and end. An opaque stream cannot support this, so any edge that requires low-latency output must use encapsulation.
|
||||
|
||||
Opaque streams are a valid optimization for leaf edges where the downstream consumer (e.g. an archiver writing raw bytes to disk) does its own framing, requires no relay intelligence, and has no need for remote control.
|
||||
|
||||
---
|
||||
|
||||
## Relay Design
|
||||
|
||||
A relay receives frames from one or more upstream sources and distributes them to any number of outputs. Each output is independently configured with a **delivery mode** that determines how it handles the tension between latency and completeness.
|
||||
|
||||
### Output Delivery Modes
|
||||
|
||||
**Low-latency mode** — minimize delay, accept loss
|
||||
|
||||
The output holds at most one pending frame. When a new frame arrives:
|
||||
- If the slot is empty, the frame occupies it and is sent as soon as the transport allows
|
||||
- If the slot is already occupied (transport not ready), the incoming frame is dropped — the pending frame is already stale enough
|
||||
|
||||
The consumer always receives the most recent frame the transport could deliver. Frame loss is expected and acceptable.
|
||||
|
||||
**Completeness mode** — minimize loss, accept delay
|
||||
|
||||
The output maintains a queue. When a new frame arrives it is enqueued. The transport drains the queue in order. When the queue is full, a drop policy is applied — either drop the oldest frame (preserve recency) or drop the newest (preserve continuity). Which policy fits depends on the consumer: an archiver may prefer continuity; a scrubber may prefer recency.
|
||||
|
||||
### Memory Model
|
||||
|
||||
Compressed frames have variable sizes (I-frames vs P-frames, quality settings, scene complexity), so fixed-slot buffers waste memory unpredictably. The preferred model is **per-frame allocation** with explicit bookkeeping.
|
||||
|
||||
Each allocated frame is tracked with at minimum:
|
||||
- Byte size
|
||||
- Sequence number or timestamp
|
||||
- Which outputs still hold a reference
|
||||
|
||||
Limits are enforced per output independently — not as a shared pool — so a slow completeness output cannot starve a low-latency output or exhaust global memory. Per-output limits have two axes:
|
||||
- **Frame count** — cap on number of queued frames
|
||||
- **Byte budget** — cap on total bytes in flight for that output
|
||||
|
||||
Both limits should be configurable. Either limit being reached triggers the drop policy.
|
||||
|
||||
### Congestion: Two Sides
|
||||
|
||||
Congestion can arise at both ends of the relay and must be handled explicitly on each.
|
||||
|
||||
**Inbound congestion (upstream → relay)**
|
||||
|
||||
If the upstream source produces frames faster than any output can dispatch them:
|
||||
- Low-latency outputs are unaffected by design — they always hold at most one frame
|
||||
- Completeness outputs will see their queues grow; limits and drop policy absorb the excess
|
||||
|
||||
The relay never signals backpressure to the upstream. It is the upstream's concern to produce frames at a sustainable rate; the relay's concern is only to handle whatever arrives without blocking.
|
||||
|
||||
**Outbound congestion (relay → downstream transport)**
|
||||
|
||||
If the transport layer cannot accept a frame immediately:
|
||||
- Low-latency mode: the pending frame is dropped when the next frame arrives; the transport sends the newest frame it can when it becomes ready
|
||||
- Completeness mode: the frame stays in the queue; the queue grows until the transport catches up or limits are reached
|
||||
|
||||
The interaction between outbound congestion and the byte budget is important: a transport that is consistently slow will fill the completeness queue to its byte budget limit, at which point the drop policy engages. This is the intended safety valve — the budget defines the maximum acceptable latency inflation before the system reverts to dropping.
|
||||
|
||||
### Congestion Signals
|
||||
|
||||
Even though the relay does not apply backpressure, it should emit **observable congestion signals** — drop counts, queue depth, byte utilization — on the control plane so that the controller can make decisions: reduce upstream quality, reroute, alert, or adjust budgets dynamically.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
UP1[Upstream Source A] -->|encapsulated stream| RELAY[Relay]
|
||||
UP2[Upstream Source B] -->|encapsulated stream| RELAY
|
||||
|
||||
RELAY --> LS[Low-latency Output<br>single-slot<br>drop on collision]
|
||||
RELAY --> CS[Completeness Output<br>queued<br>drop on budget exceeded]
|
||||
RELAY --> OB[Opaque Output<br>byte pipe<br>no frame awareness]
|
||||
|
||||
LS -->|encapsulated| LC[Low-latency Consumer<br>eg. preview display]
|
||||
CS -->|encapsulated| CC[Completeness Consumer<br>eg. archiver]
|
||||
OB -->|opaque| RAW[Raw Consumer<br>eg. disk writer]
|
||||
|
||||
RELAY -.->|drop count<br>queue depth<br>byte utilization| CTRL[Controller node]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Codec Module
|
||||
|
||||
A `codec` module provides per-frame encode and decode operations for pixel data. It sits between raw pixel buffers and the transport — sources call encode before sending, sinks call decode after receiving. The relay and transport layers never need to understand pixel formats; they carry opaque payloads.
|
||||
|
||||
### Stream Metadata
|
||||
|
||||
Receivers must know what format a frame payload is in before they can decode it. This is communicated once at stream setup via a `stream_open` control message rather than tagging every frame header. The message carries three fields:
|
||||
|
||||
**`format` (u16)** — the wire format of the payload bytes; determines how the receiver decodes the frame:
|
||||
|
||||
| Value | Format |
|
||||
|---|---|
|
||||
| `0x0001` | MJPEG |
|
||||
| `0x0002` | H.264 |
|
||||
| `0x0003` | H.265 / HEVC |
|
||||
| `0x0004` | AV1 |
|
||||
| `0x0005` | FFV1 |
|
||||
| `0x0006` | ProRes |
|
||||
| `0x0007` | QOI |
|
||||
| `0x0008` | Raw pixels (see `pixel_format`) |
|
||||
| `0x0009` | Raw pixels + ZSTD (see `pixel_format`) |
|
||||
|
||||
**`pixel_format` (u16)** — pixel layout for raw formats; zero and ignored for compressed formats:
|
||||
|
||||
| Value | Layout |
|
||||
|---|---|
|
||||
| `0x0001` | BGRA 8:8:8:8 |
|
||||
| `0x0002` | RGBA 8:8:8:8 |
|
||||
| `0x0003` | BGR 8:8:8 |
|
||||
| `0x0004` | YUV 4:2:0 planar |
|
||||
| `0x0005` | YUV 4:2:2 packed |
|
||||
|
||||
**`origin` (u16)** — how the frame was produced; informational only, does not affect decoding; useful for diagnostics, quality inference, and routing decisions:
|
||||
|
||||
| Value | Origin |
|
||||
|---|---|
|
||||
| `0x0001` | Device native — camera or capture card encoded it directly |
|
||||
| `0x0002` | libjpeg-turbo |
|
||||
| `0x0003` | ffmpeg (libavcodec) |
|
||||
| `0x0004` | ffmpeg (subprocess) |
|
||||
| `0x0005` | VA-API direct |
|
||||
| `0x0006` | NVENC direct |
|
||||
| `0x0007` | Software (other) |
|
||||
|
||||
A V4L2 camera outputting MJPEG has `format=MJPEG, origin=device_native`. The same format re-encoded in process has `format=MJPEG, origin=libjpeg-turbo`. The receiver decodes both identically; the distinction is available for logging and diagnostics without polluting the format identifier.
|
||||
|
||||
### Format Negotiation
|
||||
|
||||
When a source node opens a stream channel it sends a `stream_open` control message that includes the codec identifier. The receiver can reject the codec if it has no decoder for it. This keeps codec knowledge at the edges — relay nodes are unaffected.
|
||||
|
||||
### libjpeg-turbo
|
||||
|
||||
JPEG is the natural first codec: libjpeg-turbo provides SIMD-accelerated encode on both x86 and ARM, the output format is identical to what V4L2 cameras already produce (so the ingest and archive paths treat them the same), and it is universally decodable including in browsers via `<img>` or `createImageBitmap`. Lossy, but quality is configurable.
|
||||
|
||||
### QOI
|
||||
|
||||
QOI (Quite OK Image Format) is a strong candidate for lossless screen grabs: it encodes and decodes in a single pass with no external dependencies, performs well on content with large uniform regions (UIs, text, diagrams), and the reference implementation is a single `.h` file. Output is larger than JPEG but decode is simpler and there is no quality loss. Worth benchmarking against JPEG at high quality settings for screen content.
|
||||
|
||||
### ZSTD over Raw Pixels
|
||||
|
||||
ZSTD at compression level 1 is extremely fast and can achieve meaningful ratios on screen content (which tends to be repetitive). No pixel format conversion is needed — capture raw, compress raw, decompress raw, display raw. This avoids any colour space or chroma subsampling decisions and is entirely lossless. The downside is that even compressed, the payload is larger than JPEG for photographic content; for UI-heavy screens it can be competitive.
|
||||
|
||||
### VA-API (Hardware H.264 Intra)
|
||||
|
||||
Intra-only H.264 via VA-API gives very high compression with GPU offload. This is the most complex option to set up and introduces a GPU dependency, but may be worthwhile for high-resolution grabs over constrained links. Deferred until simpler codecs are validated.
|
||||
|
||||
### ffmpeg Backend
|
||||
|
||||
ffmpeg (via libavcodec or subprocess) is a practical escape hatch that gives access to a large number of codecs, container formats, and hardware acceleration paths without implementing them from scratch. It is particularly useful for archival formats where the encode latency of a more complex codec is acceptable.
|
||||
|
||||
**Integration options:**
|
||||
|
||||
- **libavcodec** — link directly against the library; programmatic API, tight integration, same process; introduces a large build dependency but gives full control over codec parameters and hardware acceleration (NVENC, VA-API, VideoToolbox, etc.)
|
||||
- **subprocess pipe** — spawn `ffmpeg`, pipe raw frames to stdin, read encoded output from stdout; simpler, no build dependency, more isolated from the rest of the node process; latency is higher due to process overhead but acceptable for archival paths where real-time delivery is not required
|
||||
|
||||
The subprocess approach fits naturally into the completeness output path of the relay: frames arrive in order, there is no real-time drop pressure, and the ffmpeg process can be restarted independently if it crashes without taking down the node. libavcodec is the better fit for low-latency encoding (e.g. screen grab over a constrained link).
|
||||
|
||||
**Archival formats of interest:**
|
||||
|
||||
| Format | Notes |
|
||||
|---|---|
|
||||
| H.265 / HEVC | ~50% better compression than H.264 at same quality; NVENC and VA-API hardware support widely available |
|
||||
| AV1 | Best open-format compression; software encode is slow, hardware encode (AV1 NVENC on RTX 30+) is fast |
|
||||
| FFV1 | Lossless, designed for archival; good compression for video content; the format used by film archives |
|
||||
| ProRes | Near-lossless, widely accepted in post-production toolchains; large files but easy to edit downstream |
|
||||
|
||||
The encoder backend is recorded in the `origin` field of `stream_open` — the receiver cares only about `format`, not how the bytes were produced. Switching from a subprocess encode to libavcodec, or from software to hardware, requires no protocol change.
|
||||
|
||||
---
|
||||
|
||||
## X11 / Xorg Integration
|
||||
|
||||
An `xorg` module provides two capabilities that complement the V4L2 camera pipeline: screen geometry queries and an X11-based video feed viewer. Both operate as first-class node roles.
|
||||
|
||||
### Screen Geometry Queries (XRandR)
|
||||
|
||||
Using the XRandR extension, the module can enumerate connected outputs and retrieve their geometry — resolution, position within the desktop coordinate space, physical size, and refresh rate. This is useful for:
|
||||
|
||||
- **Routing decisions**: knowing the resolution of the target display before deciding how to scale or crop an incoming stream
|
||||
- **Screen grab source**: determining the exact rectangle to capture for a given monitor
|
||||
- **Multi-monitor layouts**: placing viewer windows correctly in a multi-head setup without guessing offsets
|
||||
|
||||
Queries are exposed as control request/response pairs on the standard transport, so a remote node can ask "what monitors does this machine have?" and receive structured geometry data without any X11 code on the asking side.
|
||||
|
||||
### Screen Grab Source
|
||||
|
||||
The module can act as a video source by capturing the contents of a screen region using `XShmGetImage` (MIT-SHM extension) for zero-copy capture within the same machine. The captured region is a configurable rectangle — typically one full monitor by its XRandR geometry, but can be any sub-region.
|
||||
|
||||
Raw captured pixels are uncompressed — 1920×1080 at 32 bpp is ~8 MB per frame. Before the frame enters the transport it must be encoded. The grab loop calls the `codec` module to compress each frame, then encapsulates the result. The codec is configured per stream; see [Codec Module](#codec-module).
|
||||
|
||||
The grab loop produces frames at a configured rate, encapsulates them, and feeds them into the transport like any other video source. Combined with geometry queries, a remote controller can enumerate monitors, select one, and start a screen grab stream without manual coordinate configuration.
|
||||
|
||||
### Frame Viewer Sink
|
||||
|
||||
The module can act as a video sink by creating an X11 window and rendering the latest received frame into it. The window:
|
||||
|
||||
- Can be placed on a specific monitor using XRandR geometry
|
||||
- Can be made fullscreen on a chosen output
|
||||
- Renders using `XShmPutImage` (MIT-SHM) when the source is local, or `XPutImage` otherwise
|
||||
- Displays the most recently received frame — it is driven by the low-latency output mode of the relay feeding it; it never buffers for completeness
|
||||
|
||||
This makes it the display-side counterpart of the V4L2 capture source: the same frame that was grabbed from a camera on a Pi can be viewed on any machine in the network that runs an xorg sink node, with the relay handling the path and delivery mode between them.
|
||||
|
||||
Scale and crop are applied at render time — the incoming frame is stretched or cropped to fill the window. This allows a high-resolution screen grab from one machine to be displayed scaled-down on a smaller physical monitor elsewhere in the network.
|
||||
|
||||
---
|
||||
|
||||
## Audio (Future)
|
||||
|
||||
Audio streams are not in scope for the initial implementation but the transport is designed to accommodate them without structural changes.
|
||||
|
||||
A future audio stream is just another message type on an existing transport connection — no new connection type or header field is needed. `stream_id` in the payload already handles multiplexing. The message type table has room for an `audio_frame` type alongside `video_frame`.
|
||||
|
||||
The main open question is codec and container: raw PCM is trivial to handle but large; compressed formats (Opus, AAC) need framing conventions. This is deferred until video is solid.
|
||||
|
||||
The frame allocator, relay, and archive modules should not assume that a frame implies video — they operate on opaque byte payloads with a message type and length, so audio frames will pass through the same infrastructure unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Device Resilience
|
||||
|
||||
Nodes that read from hardware devices (V4L2 cameras, media devices) must handle transient device loss — a USB camera that disconnects and reconnects, a device node that briefly disappears during a mode switch, or a stream that errors out and can be retried. This is not an early implementation concern but has structural implications that should be respected from the start.
|
||||
|
||||
### The Problem by Layer
|
||||
|
||||
**Source node / device reader**
|
||||
|
||||
A device is opened by fd. On a transient disconnect, the fd becomes invalid — reads return errors or short counts. The device may reappear under the same path after some time. Recovery requires closing the bad fd, waiting or polling for the device to reappear, reopening, and restarting the capture loop. Any state tied to the old fd (ioctl configuration, stream-on status) must be re-established.
|
||||
|
||||
**Opaque stream edge**
|
||||
|
||||
The downstream receiver sees bytes stop. There is no mechanism in an opaque stream to distinguish "slow source", "dead source", or "recovered source". A reconnection produces a new byte stream that appears continuous to the receiver — but contains a hard discontinuity. The receiver has no way to know it should reset state. This is a known limitation of opaque mode. If the downstream consumer is sensitive to stream discontinuities (e.g. a frame parser), it must use encapsulated mode on that edge.
|
||||
|
||||
**Encapsulated stream edge**
|
||||
|
||||
The source node sends a `stream_event` message (`0x0004`) on the affected `channel_id` before the bytes stop (if possible) or as the first message when stream resumes. The payload carries an event code:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0x01` | Stream interrupted — device lost, bytes will stop |
|
||||
| `0x02` | Stream resumed — device recovered, frames will follow |
|
||||
|
||||
On receiving `stream_interrupted`, downstream nodes know to discard any partial frame being assembled and reset parser state. On `stream_resumed`, they know a clean frame boundary follows and can restart cleanly.
|
||||
|
||||
**Ingest module (MJPEG parser)**
|
||||
|
||||
The two-pass EOI state machine is stateful per stream. It must expose an explicit reset operation that discards any partial frame in progress and returns the parser to a clean initial state. This reset is triggered by a `stream_interrupted` event, or by any read error from the device. Any frame allocation begun for the discarded partial frame must be released before the reset completes.
|
||||
|
||||
**Frame allocator**
|
||||
|
||||
A partial frame that was being assembled when the device dropped must be explicitly abandoned. The allocator must support an `abandon` operation distinct from a normal `release` — abandon means the allocation is invalid and any reference tracking for it should be unwound immediately. This prevents a partial allocation from sitting in the accounting tables and consuming budget.
|
||||
|
||||
### Source Node Recovery Loop
|
||||
|
||||
The general structure for a resilient device reader (not yet implemented, for design awareness):
|
||||
|
||||
1. Open device, configure, start capture
|
||||
2. On read error: emit `stream_interrupted` on the transport, close fd, enter retry loop
|
||||
3. Poll for device reappearance (inotify on `/dev`, or timed retry)
|
||||
4. On device back: reopen, reconfigure (ioctl state is lost), emit `stream_resumed`, resume capture
|
||||
5. Log reconnection events to the control plane as observable signals
|
||||
|
||||
The retry loop must be bounded — a device that never returns should eventually cause the node to report a permanent failure rather than loop indefinitely.
|
||||
|
||||
### Implications for Opaque Streams
|
||||
|
||||
If a source node is producing an opaque stream and the device drops, the TCP connection itself may remain open while bytes stop flowing. The downstream node only learns something is wrong via a timeout or its own read error. For this reason, **opaque streams should only be used on edges where the downstream consumer either does not care about discontinuities or has its own out-of-band mechanism to detect them**. Edges into an ingest node must use encapsulated mode.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
The system is built module by module in C11. Each translation unit is developed and validated independently before being integrated. See [planning.md](planning.md) for current status and module order, and [conventions.md](conventions.md) for code and project conventions.
|
||||
@@ -453,148 +133,23 @@ The final deliverable is a single configurable node binary. During development,
|
||||
|
||||
---
|
||||
|
||||
## Protocol Serialization
|
||||
## Decided
|
||||
|
||||
Control message payloads use a compact binary format. The wire encoding is **little-endian** throughout — all target platforms (Raspberry Pi ARM, x86 laptop) are little-endian, and little-endian is the convention of most modern protocols (USB, Bluetooth LE, etc.).
|
||||
These were previously open questions and are now resolved:
|
||||
|
||||
### Serialization Layer
|
||||
|
||||
A `serial` module provides the primitive read/write operations on byte buffers:
|
||||
|
||||
- `put_u8`, `put_u16`, `put_u32`, `put_i32`, `put_u64` — write a value at a position in a buffer
|
||||
- `get_u8`, `get_u16`, `get_u32`, `get_i32`, `get_u64` — read a value from a position in a buffer
|
||||
|
||||
These are pure buffer operations with no I/O. Fields are never written by casting a struct to bytes — each field is placed explicitly, which eliminates struct padding and alignment assumptions.
|
||||
|
||||
### Protocol Layer
|
||||
|
||||
A `protocol` module builds on `serial` and the transport to provide typed message functions:
|
||||
|
||||
```c
|
||||
write_v4l2_set_control(stream, id, value);
|
||||
write_v4l2_get_control(stream, id);
|
||||
write_v4l2_enumerate_controls(stream);
|
||||
```
|
||||
|
||||
Each `write_*` function knows the exact wire layout of its message, packs the full frame (header + payload) into a stack buffer using `put_*`, then issues a single write to the stream. The corresponding `read_*` functions unpack responses using `get_*`.
|
||||
|
||||
This gives a clean two-layer separation: `serial` handles byte layout, `protocol` handles message semantics and I/O.
|
||||
|
||||
### Web Interface as a Protocol Peer
|
||||
|
||||
The web interface (Node.js/Express) participates in the graph as a first-class protocol peer — it speaks the same binary protocol as any C node. There is no JSON bridge or special C code to serve the web layer. The boundary is:
|
||||
|
||||
- **Socket side**: binary protocol, framed messages, little-endian fields read with `DataView` (`dataView.getUint32(offset, true)` maps directly to `get_u32`)
|
||||
- **Browser side**: HTTP/WebSocket, JSON, standard web APIs
|
||||
|
||||
A `protocol.mjs` module in the web layer mirrors the C `protocol` module — same message types, same wire layout, different language. This lets the web interface connect to any video node, send control requests (V4L2 enumeration, parameter get/set, device discovery), and receive structured responses.
|
||||
|
||||
Treating the web node as a peer also means it exercises the real protocol, which surfaces bugs that a JSON bridge would hide.
|
||||
|
||||
### Future: Single Source of Truth via Preprocessor
|
||||
|
||||
The C `protocol` module and the JavaScript `protocol.mjs` currently encode the same wire format in two languages. This duplication is a drift risk — a change to a message layout must be applied in both places.
|
||||
|
||||
A future preprocessor will eliminate this. Protocol messages will be defined once in a language-agnostic schema, and the preprocessor will emit both:
|
||||
- C source — `put_*`/`get_*` calls, struct definitions, `write_*`/`read_*` functions
|
||||
- ESM JavaScript — `DataView`-based encode/decode, typed constants
|
||||
|
||||
The preprocessor is the same tool planned for generating error location codes (see `common/error`). The protocol schema becomes a single source of truth, and both the C and JavaScript implementations are derived artifacts.
|
||||
|
||||
---
|
||||
|
||||
## Node Discovery
|
||||
|
||||
Standard mDNS (RFC 6762) uses UDP multicast over `224.0.0.251:5353` with DNS-SD service records. The wire protocol is well-defined and the multicast group is already in active use on most LANs. The standard service discovery stack (Avahi, Bonjour, `nss-mdns`) provides that transport but brings significant overhead: persistent daemons, D-Bus dependencies, complex configuration surface, and substantial resident memory. None of that is needed here.
|
||||
|
||||
The approach: **reuse the multicast transport, define our own wire format**.
|
||||
|
||||
Rather than DNS wire format, node announcements are encoded as binary frames using the same serialization layer (`serial`) and frame header used for video transport. A node joins the multicast group, broadcasts periodic announcements, and listens for announcements from peers.
|
||||
|
||||
### Announcement Frame
|
||||
|
||||
| Field | Size | Purpose |
|
||||
|---|---|---|
|
||||
| `message_type` | 2 bytes | Discovery message type (e.g. `0x0010` for node announcement) |
|
||||
| `channel_id` | 2 bytes | Reserved / zero |
|
||||
| `payload_length` | 4 bytes | Byte length of payload |
|
||||
| Payload | variable | Encoded node identity and capabilities |
|
||||
|
||||
Payload fields:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `protocol_version` | u8 | Wire format version |
|
||||
| `site_id` | u16 | Site this node belongs to (`0` = local / unassigned) |
|
||||
| `tcp_port` | u16 | Port where this node accepts transport connections |
|
||||
| `function_flags` | u16 | Bitfield declaring node capabilities (see below) |
|
||||
| `name_len` | u8 | Length of name string |
|
||||
| `name` | bytes | Node name (`namespace:instance`, e.g. `v4l2:microscope`) |
|
||||
|
||||
`function_flags` bits:
|
||||
|
||||
| Bit | Mask | Meaning |
|
||||
|---|---|---|
|
||||
| 0 | `0x0001` | Source — produces video |
|
||||
| 1 | `0x0002` | Relay — receives and distributes streams |
|
||||
| 2 | `0x0004` | Sink — consumes video (display, archiver, etc.) |
|
||||
| 3 | `0x0008` | Controller — participates in control plane coordination |
|
||||
|
||||
A node may set multiple bits — a relay that also archives sets both `RELAY` and `SINK`.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- Nodes send announcements periodically (e.g. every 5 s) and immediately on startup
|
||||
- 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
|
||||
|
||||
### No Avahi/Bonjour Dependency
|
||||
|
||||
The system does not link against, depend on, or interact with Avahi or Bonjour. It opens a raw UDP multicast socket directly, which requires only standard POSIX socket APIs. This keeps the runtime dependency footprint minimal and the behaviour predictable.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Site (Forward Compatibility)
|
||||
|
||||
The immediate use case is a single LAN. A planned future use case is **site-to-site linking** — two independent networks (e.g. a lab and a remote location) connected by a tunnel (SSH port-forward, WireGuard, etc.), where nodes on both sites are reachable from either side.
|
||||
|
||||
### Site Identity
|
||||
|
||||
Every node carries a `site_id` (`u16`) in its announcement. In a single-site deployment this is always `0`. When sites are joined, each site is assigned a distinct non-zero ID; nodes retain their IDs across the join and are fully addressable by `(site_id, name)` from anywhere in the combined network.
|
||||
|
||||
This field is reserved from day one so that multi-site never requires a wire format change or a rename of existing identifiers.
|
||||
|
||||
### Site Gateway Node
|
||||
|
||||
A site gateway is a node that participates in both networks simultaneously — it has a connection on the local transport and a connection over the inter-site tunnel. It:
|
||||
|
||||
- Bridges discovery announcements between sites (rewriting `site_id` appropriately)
|
||||
- Forwards encapsulated transport frames across the tunnel on behalf of cross-site edges
|
||||
- Is itself a named node, so the control plane can see and reason about it
|
||||
|
||||
The tunnel transport is out of scope for now. The gateway is a node type, not a special infrastructure component — it uses the same wire protocol as everything else.
|
||||
|
||||
### Site ID Translation
|
||||
|
||||
Both sides of a site-to-site link will independently default to `site_id = 0`. A gateway cannot simply forward announcements across the boundary — every node on both sides would appear as site 0 and be indistinguishable.
|
||||
|
||||
The gateway is responsible for **site ID translation**: it assigns a distinct non-zero `site_id` to each side of the link and rewrites the `site_id` field in all announcements and any protocol messages that carry a `site_id` as they cross the boundary. From each side's perspective, remote nodes appear with the translated ID assigned by the gateway; local nodes retain their own IDs.
|
||||
|
||||
This means `site_id = 0` should be treated as "local / unassigned" and never forwarded across a site boundary without translation. A node that receives an announcement with `site_id = 0` on a cross-site link should treat it as a protocol error from the gateway.
|
||||
|
||||
### Addressing
|
||||
|
||||
A fully-qualified node address is `site_id:namespace:instance`. Within a single site, `site_id` is implicit and can be omitted. The control plane and discovery layer must store `site_id` alongside every peer record from the start, even if it is always `0`, so that the upgrade to multi-site addressing requires only configuration and a gateway node — not code changes.
|
||||
- **Connection direction**: the source node connects outbound to the sink's transport server. The controller writes wanted state to the source node including the destination host:port; the source's reconciler establishes the connection.
|
||||
- **Stream ID assignment**: stream IDs are assigned by the controller, not generated locally by nodes. This ensures both ends of a stream report the same ID and the graph can be reconstructed by correlating node state reports.
|
||||
- **Single port per node**: one TCP listening port handles all traffic — video frames, control messages, state queries — in both directions. Dedicated per-stream ports on separate sockets are a future option but not the default.
|
||||
- **First delivery mode**: low-latency (no-buffer) mode is implemented first. No frame queue anywhere in the pipeline — V4L2 dequeue goes directly to transport send; received frames render immediately and are dropped if the display is behind.
|
||||
- **Drop policy**: per-output configurable. Both drop-oldest (recency) and drop-newest (continuity) are supported; the policy is set at stream open time.
|
||||
- **Stream ID remapping at relay**: no remapping — stream IDs pass through unchanged. The relay forwards frames with the same stream ID they arrived with. Site-to-site gateways may need to translate IDs at the boundary but that is a future concern handled at the gateway, not in the relay itself.
|
||||
- **Transport for relay edges**: TCP only for now. UDP and shared memory (for local hops) are future considerations; the transport abstraction should accommodate them without the relay needing to care which is in use.
|
||||
- **Byte budgets**: soft limits with hysteresis — two thresholds (start dropping, stop dropping) to avoid thrashing at the boundary.
|
||||
- **Relay scheduler**: strict priority first. Additional policies (round-robin, weighted round-robin, deficit round-robin, source suppression) are documented in [Relay Design](docs/relay.md) and will be added later. The scheduler interface is pluggable so policies are interchangeable without touching routing logic.
|
||||
- **Graph representation**: the graph lives in the web interface (ESM). No special format needed — plain objects, classes, and arrays. The web node queries all discovered peers for their wanted and runtime state, reconstructs the graph in-memory, and drives the UI from that. Future TUI/CLI controller tools reuse the same ESM libraries via Node.js. Complex graph logic (reconstruction, topology diffing, layout) is easier to maintain in ESM than in C and belongs there.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- What is the graph's representation format — in-memory object graph, serialized config, or both?
|
||||
- How are connections established — does the controller push connection instructions to nodes, or do nodes pull from a known address?
|
||||
- Drop policy for completeness queues: drop oldest (recency) or drop newest (continuity)? Should be per-output configurable.
|
||||
- When a relay has multiple inputs on an encapsulated transport, how are streams tagged on the outbound side — same stream_id passthrough, or remapped?
|
||||
- What transport is used for relay edges — TCP, UDP, shared memory for local hops?
|
||||
- Should per-output byte budgets be hard limits or soft limits with hysteresis?
|
||||
None currently open.
|
||||
|
||||
53
common.mk
53
common.mk
@@ -5,3 +5,56 @@
|
||||
CC = gcc
|
||||
CFLAGS = -std=c11 -Wall -Wextra -D_GNU_SOURCE -flto -I$(ROOT)/include
|
||||
BUILD = $(ROOT)/build
|
||||
|
||||
# Automatic dependency generation.
|
||||
# -MMD emit a .d file listing header prerequisites alongside each .o
|
||||
# -MP add phony targets for headers so removed headers don't break make
|
||||
DEPFLAGS = -MMD -MP
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Feature flags
|
||||
# Set FEATURES to a space-separated list of optional deps to enable.
|
||||
# Example: make FEATURES="glfw turbojpeg"
|
||||
# Headless (no optional deps): make FEATURES=
|
||||
#
|
||||
# Available features:
|
||||
# glfw — GLFW window management + OpenGL renderer (libglfw3, libglew)
|
||||
# vulkan — GLFW window management + Vulkan renderer (vulkan-loader, libglfw3)
|
||||
# turbojpeg — MJPEG encode/decode (libturbojpeg)
|
||||
# xorg — XRandR geometry queries + XShmGetImage screen grab (libx11, libxrandr)
|
||||
# vaapi — VA-API hardware encode/decode (libva)
|
||||
# -----------------------------------------------------------------------
|
||||
FEATURES ?= glfw vulkan turbojpeg xorg vaapi
|
||||
export FEATURES
|
||||
|
||||
# Inject HAVE_ defines into CFLAGS
|
||||
CFLAGS += $(if $(filter glfw, $(FEATURES)),-DHAVE_GLFW)
|
||||
CFLAGS += $(if $(filter vulkan, $(FEATURES)),-DHAVE_VULKAN)
|
||||
CFLAGS += $(if $(filter turbojpeg, $(FEATURES)),-DHAVE_TURBOJPEG)
|
||||
CFLAGS += $(if $(filter xorg, $(FEATURES)),-DHAVE_XORG)
|
||||
CFLAGS += $(if $(filter vaapi, $(FEATURES)),-DHAVE_VAAPI)
|
||||
|
||||
# Per-feature pkg-config flags — accumulated into PKG_CFLAGS / PKG_LDFLAGS.
|
||||
# Modules that need them add $(PKG_CFLAGS) to their compile rules.
|
||||
# The node and cli link rules append $(PKG_LDFLAGS).
|
||||
PKG_CFLAGS :=
|
||||
PKG_LDFLAGS :=
|
||||
|
||||
ifneq (,$(filter glfw,$(FEATURES)))
|
||||
PKG_CFLAGS += $(shell pkg-config --cflags glfw3 glew 2>/dev/null)
|
||||
PKG_LDFLAGS += $(shell pkg-config --libs glfw3 glew 2>/dev/null) -lGL
|
||||
endif
|
||||
ifneq (,$(filter turbojpeg,$(FEATURES)))
|
||||
PKG_CFLAGS += $(shell pkg-config --cflags libturbojpeg 2>/dev/null)
|
||||
PKG_LDFLAGS += $(shell pkg-config --libs libturbojpeg 2>/dev/null)
|
||||
endif
|
||||
ifneq (,$(filter xorg,$(FEATURES)))
|
||||
PKG_CFLAGS += $(shell pkg-config --cflags x11 xrandr 2>/dev/null)
|
||||
PKG_LDFLAGS += $(shell pkg-config --libs x11 xrandr 2>/dev/null)
|
||||
endif
|
||||
ifneq (,$(filter vaapi,$(FEATURES)))
|
||||
PKG_CFLAGS += $(shell pkg-config --cflags libva 2>/dev/null)
|
||||
PKG_LDFLAGS += $(shell pkg-config --libs libva 2>/dev/null)
|
||||
endif
|
||||
|
||||
CFLAGS += $(PKG_CFLAGS)
|
||||
|
||||
@@ -80,6 +80,7 @@ typedef enum Error_Code {
|
||||
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
|
||||
```
|
||||
|
||||
116
dev/cli/Makefile
116
dev/cli/Makefile
@@ -9,47 +9,129 @@ 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
|
||||
TEST_IMAGE_OBJ = $(BUILD)/test_image/test_image.o
|
||||
XORG_OBJ = $(BUILD)/xorg/xorg.o
|
||||
RECONCILER_OBJ = $(BUILD)/reconciler/reconciler.o
|
||||
|
||||
.PHONY: all clean modules
|
||||
CLI_SRCS = \
|
||||
media_ctrl_cli.c \
|
||||
v4l2_ctrl_cli.c \
|
||||
transport_cli.c \
|
||||
discovery_cli.c \
|
||||
config_cli.c \
|
||||
protocol_cli.c \
|
||||
query_cli.c \
|
||||
test_image_cli.c \
|
||||
xorg_cli.c \
|
||||
v4l2_view_cli.c \
|
||||
stream_send_cli.c \
|
||||
stream_recv_cli.c \
|
||||
reconciler_cli.c \
|
||||
controller_cli.c
|
||||
|
||||
all: modules \
|
||||
CLI_OBJS = $(CLI_SRCS:%.c=$(CLI_BUILD)/%.o)
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: \
|
||||
$(CLI_BUILD)/media_ctrl_cli \
|
||||
$(CLI_BUILD)/v4l2_ctrl_cli \
|
||||
$(CLI_BUILD)/transport_cli \
|
||||
$(CLI_BUILD)/discovery_cli \
|
||||
$(CLI_BUILD)/config_cli
|
||||
$(CLI_BUILD)/config_cli \
|
||||
$(CLI_BUILD)/protocol_cli \
|
||||
$(CLI_BUILD)/query_cli \
|
||||
$(CLI_BUILD)/test_image_cli \
|
||||
$(CLI_BUILD)/xorg_cli \
|
||||
$(CLI_BUILD)/v4l2_view_cli \
|
||||
$(CLI_BUILD)/stream_send_cli \
|
||||
$(CLI_BUILD)/stream_recv_cli \
|
||||
$(CLI_BUILD)/reconciler_cli \
|
||||
$(CLI_BUILD)/controller_cli
|
||||
|
||||
modules:
|
||||
$(MAKE) -C $(ROOT)/src/modules/common
|
||||
$(MAKE) -C $(ROOT)/src/modules/media_ctrl
|
||||
$(MAKE) -C $(ROOT)/src/modules/v4l2_ctrl
|
||||
$(MAKE) -C $(ROOT)/src/modules/serial
|
||||
$(MAKE) -C $(ROOT)/src/modules/transport
|
||||
$(MAKE) -C $(ROOT)/src/modules/discovery
|
||||
$(MAKE) -C $(ROOT)/src/modules/config
|
||||
# Module objects delegate to their sub-makes.
|
||||
# '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
|
||||
|
||||
$(CLI_BUILD)/media_ctrl_cli: media_ctrl_cli.c $(COMMON_OBJ) $(MEDIA_CTRL_OBJ) | $(CLI_BUILD)
|
||||
# Compile each CLI source to its own .o (generates .d alongside).
|
||||
$(CLI_BUILD)/%.o: %.c | $(CLI_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
# Link rules.
|
||||
$(CLI_BUILD)/media_ctrl_cli: $(CLI_BUILD)/media_ctrl_cli.o $(COMMON_OBJ) $(MEDIA_CTRL_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^
|
||||
|
||||
$(CLI_BUILD)/v4l2_ctrl_cli: v4l2_ctrl_cli.c $(COMMON_OBJ) $(V4L2_CTRL_OBJ) | $(CLI_BUILD)
|
||||
$(CLI_BUILD)/v4l2_ctrl_cli: $(CLI_BUILD)/v4l2_ctrl_cli.o $(COMMON_OBJ) $(V4L2_CTRL_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^
|
||||
|
||||
$(CLI_BUILD)/transport_cli: transport_cli.c $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) | $(CLI_BUILD)
|
||||
$(CLI_BUILD)/transport_cli: $(CLI_BUILD)/transport_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ -lpthread
|
||||
|
||||
$(CLI_BUILD)/discovery_cli: discovery_cli.c $(COMMON_OBJ) $(SERIAL_OBJ) $(DISCOVERY_OBJ) | $(CLI_BUILD)
|
||||
$(CLI_BUILD)/discovery_cli: $(CLI_BUILD)/discovery_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(DISCOVERY_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ -lpthread
|
||||
|
||||
$(CLI_BUILD)/config_cli: config_cli.c $(COMMON_OBJ) $(CONFIG_OBJ) | $(CLI_BUILD)
|
||||
$(CLI_BUILD)/config_cli: $(CLI_BUILD)/config_cli.o $(COMMON_OBJ) $(CONFIG_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^
|
||||
|
||||
$(CLI_BUILD)/protocol_cli: $(CLI_BUILD)/protocol_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) $(PROTOCOL_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ -lpthread
|
||||
|
||||
$(CLI_BUILD)/query_cli: $(CLI_BUILD)/query_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) $(DISCOVERY_OBJ) $(PROTOCOL_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ -lpthread
|
||||
|
||||
$(CLI_BUILD)/test_image_cli: $(CLI_BUILD)/test_image_cli.o $(TEST_IMAGE_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^
|
||||
|
||||
$(CLI_BUILD)/xorg_cli: $(CLI_BUILD)/xorg_cli.o $(TEST_IMAGE_OBJ) $(XORG_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(PKG_LDFLAGS)
|
||||
|
||||
$(CLI_BUILD)/v4l2_view_cli: $(CLI_BUILD)/v4l2_view_cli.o $(XORG_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ $(PKG_LDFLAGS)
|
||||
|
||||
$(CLI_BUILD)/stream_send_cli: $(CLI_BUILD)/stream_send_cli.o $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSPORT_OBJ) $(PROTOCOL_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ -lpthread
|
||||
|
||||
$(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 $@
|
||||
|
||||
clean:
|
||||
rm -f \
|
||||
$(CLI_OBJS) \
|
||||
$(CLI_OBJS:%.o=%.d) \
|
||||
$(CLI_BUILD)/media_ctrl_cli \
|
||||
$(CLI_BUILD)/v4l2_ctrl_cli \
|
||||
$(CLI_BUILD)/transport_cli \
|
||||
$(CLI_BUILD)/discovery_cli \
|
||||
$(CLI_BUILD)/config_cli
|
||||
$(CLI_BUILD)/config_cli \
|
||||
$(CLI_BUILD)/protocol_cli \
|
||||
$(CLI_BUILD)/query_cli \
|
||||
$(CLI_BUILD)/test_image_cli \
|
||||
$(CLI_BUILD)/xorg_cli \
|
||||
$(CLI_BUILD)/v4l2_view_cli \
|
||||
$(CLI_BUILD)/stream_send_cli \
|
||||
$(CLI_BUILD)/stream_recv_cli \
|
||||
$(CLI_BUILD)/reconciler_cli \
|
||||
$(CLI_BUILD)/controller_cli
|
||||
|
||||
-include $(CLI_OBJS:%.o=%.d)
|
||||
|
||||
@@ -62,7 +62,7 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
|
||||
if (!APP_IS_OK(err)) {
|
||||
fprintf(stderr, "config_load: errno %d\n", err.detail.syscall.err_no);
|
||||
app_error_print(&err);
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
671
dev/cli/controller_cli.c
Normal file
671
dev/cli/controller_cli.c
Normal file
@@ -0,0 +1,671 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
#include <semaphore.h>
|
||||
#include <pthread.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <readline/readline.h>
|
||||
#include <readline/history.h>
|
||||
|
||||
#include "transport.h"
|
||||
#include "protocol.h"
|
||||
#include "discovery.h"
|
||||
#include "error.h"
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Discovery peer table
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define MAX_PEERS 16
|
||||
|
||||
struct Peer_Entry {
|
||||
char host[64];
|
||||
uint16_t port;
|
||||
char name[DISCOVERY_MAX_NAME_LEN + 1];
|
||||
};
|
||||
|
||||
static struct Peer_Entry peer_table[MAX_PEERS];
|
||||
static int peer_count = 0;
|
||||
static pthread_mutex_t peer_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static void on_peer_found(const struct Discovery_Peer *peer, void *ud)
|
||||
{
|
||||
(void)ud;
|
||||
pthread_mutex_lock(&peer_mutex);
|
||||
if (peer_count < MAX_PEERS) {
|
||||
struct in_addr a;
|
||||
a.s_addr = peer->addr;
|
||||
inet_ntop(AF_INET, &a, peer_table[peer_count].host,
|
||||
sizeof(peer_table[0].host));
|
||||
peer_table[peer_count].port = peer->tcp_port;
|
||||
strncpy(peer_table[peer_count].name, peer->name, DISCOVERY_MAX_NAME_LEN);
|
||||
peer_table[peer_count].name[DISCOVERY_MAX_NAME_LEN] = '\0';
|
||||
peer_count++;
|
||||
/* Print inline — readline will redraw the prompt */
|
||||
printf("\n[discovered %d] %s %s:%u\n",
|
||||
peer_count - 1,
|
||||
peer_table[peer_count - 1].name,
|
||||
peer_table[peer_count - 1].host,
|
||||
peer->tcp_port);
|
||||
rl_on_new_line();
|
||||
rl_redisplay();
|
||||
}
|
||||
pthread_mutex_unlock(&peer_mutex);
|
||||
}
|
||||
|
||||
static void on_peer_lost(const struct Discovery_Peer *peer, void *ud)
|
||||
{
|
||||
(void)ud;
|
||||
struct in_addr a;
|
||||
a.s_addr = peer->addr;
|
||||
char host[64];
|
||||
inet_ntop(AF_INET, &a, host, sizeof(host));
|
||||
|
||||
pthread_mutex_lock(&peer_mutex);
|
||||
for (int i = 0; i < peer_count; i++) {
|
||||
if (strcmp(peer_table[i].host, host) == 0 &&
|
||||
peer_table[i].port == peer->tcp_port) {
|
||||
printf("\n[lost] %s %s:%u\n",
|
||||
peer_table[i].name, host, peer->tcp_port);
|
||||
rl_on_new_line();
|
||||
rl_redisplay();
|
||||
memmove(&peer_table[i], &peer_table[i + 1],
|
||||
(size_t)(peer_count - i - 1) * sizeof(peer_table[0]));
|
||||
peer_count--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&peer_mutex);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Shared state between REPL and transport read thread
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
struct Ctrl_State {
|
||||
sem_t sem;
|
||||
uint16_t pending_cmd;
|
||||
uint16_t last_status;
|
||||
int32_t last_value; /* GET_CONTROL response */
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Response display helpers — reused across commands
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void caps_str(uint32_t caps, char *buf, size_t len)
|
||||
{
|
||||
static const struct { uint32_t bit; const char *name; } flags[] = {
|
||||
{ 0x00000001u, "video-capture" },
|
||||
{ 0x00000002u, "video-output" },
|
||||
{ 0x00800000u, "meta-capture" },
|
||||
{ 0x04000000u, "streaming" },
|
||||
};
|
||||
buf[0] = '\0';
|
||||
size_t pos = 0;
|
||||
for (size_t i = 0; i < sizeof(flags)/sizeof(flags[0]); i++) {
|
||||
if (!(caps & flags[i].bit)) { continue; }
|
||||
int n = snprintf(buf + pos, len - pos, "%s%s",
|
||||
pos ? "," : "", flags[i].name);
|
||||
if (n < 0 || (size_t)n >= len - pos) { break; }
|
||||
pos += (size_t)n;
|
||||
}
|
||||
}
|
||||
|
||||
static void on_media_device(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *driver, uint8_t driver_len,
|
||||
const char *model, uint8_t model_len,
|
||||
const char *bus_info, uint8_t bus_info_len,
|
||||
uint8_t vcount, void *ud)
|
||||
{
|
||||
(void)ud;
|
||||
printf(" media %.*s driver=%.*s model=%.*s bus=%.*s (%u video node(s))\n",
|
||||
(int)path_len, path,
|
||||
(int)driver_len, driver,
|
||||
(int)model_len, model,
|
||||
(int)bus_info_len, bus_info,
|
||||
(unsigned)vcount);
|
||||
}
|
||||
|
||||
static void on_video_node(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *ename, uint8_t ename_len,
|
||||
uint32_t etype, uint32_t eflags,
|
||||
uint32_t dcaps,
|
||||
uint8_t pflags, uint8_t is_capture,
|
||||
void *ud)
|
||||
{
|
||||
(void)eflags; (void)pflags;
|
||||
int *idx = ud;
|
||||
char caps[128];
|
||||
caps_str(dcaps, caps, sizeof(caps));
|
||||
printf(" [%d] video %.*s entity=%.*s type=0x%08x caps=[%s]%s\n",
|
||||
*idx,
|
||||
(int)path_len, path,
|
||||
(int)ename_len, ename,
|
||||
etype, caps,
|
||||
is_capture ? " [capture]" : "");
|
||||
(*idx)++;
|
||||
}
|
||||
|
||||
static void on_standalone(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *name, uint8_t name_len,
|
||||
void *ud)
|
||||
{
|
||||
int *idx = ud;
|
||||
printf(" [%d] standalone %.*s card=%.*s\n",
|
||||
*idx,
|
||||
(int)path_len, path,
|
||||
(int)name_len, name);
|
||||
(*idx)++;
|
||||
}
|
||||
|
||||
static const char *scale_mode_name(uint8_t s)
|
||||
{
|
||||
switch (s) {
|
||||
case 0: return "stretch";
|
||||
case 1: return "fit";
|
||||
case 2: return "fill";
|
||||
case 3: return "1:1";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
|
||||
static void on_display(
|
||||
uint16_t device_id,
|
||||
uint16_t stream_id,
|
||||
int16_t win_x, int16_t win_y,
|
||||
uint16_t win_w, uint16_t win_h,
|
||||
uint8_t scale_mode, uint8_t anchor,
|
||||
void *ud)
|
||||
{
|
||||
(void)ud;
|
||||
printf(" [%u] display stream=%u pos=%d,%d size=%ux%u scale_mode=%s anchor=%s\n",
|
||||
device_id, stream_id, win_x, win_y, win_w, win_h,
|
||||
scale_mode_name(scale_mode),
|
||||
anchor == 0 ? "center" : "topleft");
|
||||
}
|
||||
|
||||
static void on_control(
|
||||
uint32_t id, uint8_t type, uint32_t flags,
|
||||
const char *name, uint8_t name_len,
|
||||
int32_t min, int32_t max, int32_t step,
|
||||
int32_t default_val, int32_t current_val,
|
||||
uint8_t menu_count, void *ud)
|
||||
{
|
||||
(void)flags; (void)ud;
|
||||
printf(" ctrl id=0x%08x type=%u %.*s"
|
||||
" min=%d max=%d step=%d default=%d current=%d",
|
||||
id, type,
|
||||
(int)name_len, name,
|
||||
min, max, step, default_val, current_val);
|
||||
if (menu_count) { printf(" (%u menu items)", (unsigned)menu_count); }
|
||||
printf("\n");
|
||||
}
|
||||
|
||||
static void on_menu_item(
|
||||
uint32_t index,
|
||||
const char *name, uint8_t name_len,
|
||||
int64_t int_value,
|
||||
void *ud)
|
||||
{
|
||||
(void)ud;
|
||||
printf(" menu %u %.*s val=%lld\n",
|
||||
index,
|
||||
(int)name_len, name,
|
||||
(long long)int_value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Transport callbacks
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void on_frame(struct Transport_Conn *conn,
|
||||
struct Transport_Frame *frame, void *userdata)
|
||||
{
|
||||
(void)conn;
|
||||
struct Ctrl_State *cs = userdata;
|
||||
|
||||
if (frame->message_type != PROTO_MSG_CONTROL_RESPONSE) {
|
||||
free(frame->payload);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (cs->pending_cmd) {
|
||||
case PROTO_CMD_ENUM_DEVICES: {
|
||||
struct Proto_Response_Header hdr;
|
||||
int dev_idx = 0;
|
||||
struct App_Error e = proto_read_enum_devices_response(
|
||||
frame->payload, frame->payload_length, &hdr,
|
||||
on_media_device, on_video_node, on_standalone, on_display, &dev_idx);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); }
|
||||
else if (hdr.status != PROTO_STATUS_OK) {
|
||||
fprintf(stderr, "ENUM_DEVICES: status=%u\n", hdr.status);
|
||||
}
|
||||
cs->last_status = hdr.status;
|
||||
break;
|
||||
}
|
||||
case PROTO_CMD_ENUM_CONTROLS: {
|
||||
struct Proto_Response_Header hdr;
|
||||
struct App_Error e = proto_read_enum_controls_response(
|
||||
frame->payload, frame->payload_length, &hdr,
|
||||
on_control, on_menu_item, NULL);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); }
|
||||
else if (hdr.status != PROTO_STATUS_OK) {
|
||||
fprintf(stderr, "ENUM_CONTROLS: status=%u\n", hdr.status);
|
||||
}
|
||||
cs->last_status = hdr.status;
|
||||
break;
|
||||
}
|
||||
case PROTO_CMD_GET_CONTROL: {
|
||||
struct Proto_Get_Control_Resp resp;
|
||||
struct App_Error e = proto_read_get_control_response(
|
||||
frame->payload, frame->payload_length, &resp);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); }
|
||||
else if (resp.status == PROTO_STATUS_OK) {
|
||||
printf(" value = %d\n", resp.value);
|
||||
} else {
|
||||
fprintf(stderr, "GET_CONTROL: status=%u\n", resp.status);
|
||||
}
|
||||
cs->last_status = resp.status;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
/* Generic response: just read request_id + status */
|
||||
struct Proto_Response_Header hdr;
|
||||
struct App_Error e = proto_read_response_header(
|
||||
frame->payload, frame->payload_length, &hdr);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); }
|
||||
else if (hdr.status != PROTO_STATUS_OK) {
|
||||
fprintf(stderr, "command 0x%04x: status=%u\n",
|
||||
cs->pending_cmd, hdr.status);
|
||||
} else {
|
||||
printf(" ok\n");
|
||||
}
|
||||
cs->last_status = APP_IS_OK(e) ? hdr.status : PROTO_STATUS_ERROR;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
free(frame->payload);
|
||||
sem_post(&cs->sem);
|
||||
}
|
||||
|
||||
static void on_disconnect(struct Transport_Conn *conn, void *userdata)
|
||||
{
|
||||
(void)conn; (void)userdata;
|
||||
printf("\ndisconnected from node\n");
|
||||
rl_on_new_line();
|
||||
rl_redisplay();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Request helpers
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static uint16_t next_req_id(uint16_t *counter)
|
||||
{
|
||||
return ++(*counter);
|
||||
}
|
||||
|
||||
/* Send a request, set pending_cmd, wait for response */
|
||||
#define SEND_AND_WAIT(cs, cmd, send_expr) do { \
|
||||
(cs)->pending_cmd = (cmd); \
|
||||
struct App_Error _e = (send_expr); \
|
||||
if (!APP_IS_OK(_e)) { app_error_print(&_e); break; } \
|
||||
sem_wait(&(cs)->sem); \
|
||||
} while (0)
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* REPL command implementations
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void cmd_enum_devices(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req)
|
||||
{
|
||||
printf("devices:\n");
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_ENUM_DEVICES,
|
||||
proto_write_enum_devices(conn, next_req_id(req)));
|
||||
}
|
||||
|
||||
static void cmd_enum_controls(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
const char *idx_str)
|
||||
{
|
||||
int idx = atoi(idx_str);
|
||||
printf("controls for device %d:\n", idx);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_ENUM_CONTROLS,
|
||||
proto_write_enum_controls(conn, next_req_id(req), (uint16_t)idx));
|
||||
}
|
||||
|
||||
static void cmd_get_control(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
const char *idx_str, const char *id_str)
|
||||
{
|
||||
int idx = atoi(idx_str);
|
||||
uint32_t id = (uint32_t)strtoul(id_str, NULL, 0);
|
||||
printf("get control 0x%08x on device %d:\n", id, idx);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_GET_CONTROL,
|
||||
proto_write_get_control(conn, next_req_id(req), (uint16_t)idx, id));
|
||||
}
|
||||
|
||||
static void cmd_set_control(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
const char *idx_str, const char *id_str, const char *val_str)
|
||||
{
|
||||
int idx = atoi(idx_str);
|
||||
uint32_t id = (uint32_t)strtoul(id_str, NULL, 0);
|
||||
int32_t val = (int32_t)atoi(val_str);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_SET_CONTROL,
|
||||
proto_write_set_control(conn, next_req_id(req), (uint16_t)idx, id, val));
|
||||
}
|
||||
|
||||
static void cmd_start_ingest(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
int ntok, char *tokens[])
|
||||
{
|
||||
/* Required: stream_id device dest_host dest_port
|
||||
* Optional: format width height fps_n fps_d */
|
||||
if (ntok < 5) {
|
||||
printf("usage: start-ingest <stream_id> <device> <dest_host> <dest_port>"
|
||||
" [format] [width] [height] [fps_n] [fps_d]\n"
|
||||
" format: 0=auto 1=mjpeg (default 0)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t stream_id = (uint16_t)atoi(tokens[1]);
|
||||
const char *device = tokens[2];
|
||||
const char *host = tokens[3];
|
||||
uint16_t port = (uint16_t)atoi(tokens[4]);
|
||||
|
||||
uint16_t format = ntok > 5 ? (uint16_t)atoi(tokens[5]) : 0;
|
||||
uint16_t width = ntok > 6 ? (uint16_t)atoi(tokens[6]) : 0;
|
||||
uint16_t height = ntok > 7 ? (uint16_t)atoi(tokens[7]) : 0;
|
||||
uint16_t fps_n = ntok > 8 ? (uint16_t)atoi(tokens[8]) : 0;
|
||||
uint16_t fps_d = ntok > 9 ? (uint16_t)atoi(tokens[9]) : 1;
|
||||
|
||||
printf("start-ingest: stream=%u device=%s dest=%s:%u"
|
||||
" format=%u %ux%u fps=%u/%u\n",
|
||||
stream_id, device, host, port, format, width, height, fps_n, fps_d);
|
||||
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_START_INGEST,
|
||||
proto_write_start_ingest(conn, next_req_id(req),
|
||||
stream_id, format, width, height, fps_n, fps_d,
|
||||
PROTO_TRANSPORT_ENCAPSULATED, device, host, port));
|
||||
}
|
||||
|
||||
static void cmd_stop_ingest(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
const char *sid_str)
|
||||
{
|
||||
uint16_t stream_id = (uint16_t)atoi(sid_str);
|
||||
printf("stop-ingest: stream=%u\n", stream_id);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_STOP_INGEST,
|
||||
proto_write_stop_ingest(conn, next_req_id(req), stream_id));
|
||||
}
|
||||
|
||||
static void cmd_start_display(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
int ntok, char *tokens[])
|
||||
{
|
||||
/* Required: stream_id
|
||||
* Optional: win_x win_y win_w win_h no_signal_fps */
|
||||
if (ntok < 2) {
|
||||
printf("usage: start-display <stream_id> [win_x] [win_y] [win_w] [win_h] [no_signal_fps]\n");
|
||||
return;
|
||||
}
|
||||
uint16_t stream_id = (uint16_t)atoi(tokens[1]);
|
||||
int16_t win_x = ntok > 2 ? (int16_t)atoi(tokens[2]) : 0;
|
||||
int16_t win_y = ntok > 3 ? (int16_t)atoi(tokens[3]) : 0;
|
||||
uint16_t win_w = ntok > 4 ? (uint16_t)atoi(tokens[4]) : 0;
|
||||
uint16_t win_h = ntok > 5 ? (uint16_t)atoi(tokens[5]) : 0;
|
||||
uint8_t no_signal_fps = ntok > 6 ? (uint8_t)atoi(tokens[6]) : 0;
|
||||
printf("start-display: stream=%u pos=%d,%d size=%ux%u no_signal_fps=%u\n",
|
||||
stream_id, win_x, win_y, win_w, win_h,
|
||||
no_signal_fps > 0 ? no_signal_fps : 15);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_START_DISPLAY,
|
||||
proto_write_start_display(conn, next_req_id(req),
|
||||
stream_id, win_x, win_y, win_w, win_h,
|
||||
PROTO_DISPLAY_SCALE_FIT, PROTO_DISPLAY_ANCHOR_CENTER,
|
||||
no_signal_fps));
|
||||
}
|
||||
|
||||
static void cmd_stop_display(struct Transport_Conn *conn,
|
||||
struct Ctrl_State *cs, uint16_t *req,
|
||||
const char *sid_str)
|
||||
{
|
||||
uint16_t stream_id = (uint16_t)atoi(sid_str);
|
||||
printf("stop-display: stream=%u\n", stream_id);
|
||||
SEND_AND_WAIT(cs, PROTO_CMD_STOP_DISPLAY,
|
||||
proto_write_stop_display(conn, next_req_id(req), stream_id));
|
||||
}
|
||||
|
||||
static void cmd_help(void)
|
||||
{
|
||||
printf("commands:\n"
|
||||
" peers list discovered nodes\n"
|
||||
" connect [idx|host:port] connect to peer (no arg = first discovered)\n"
|
||||
" enum-devices\n"
|
||||
" enum-controls <device_index>\n"
|
||||
" get-control <device_index> <control_id_hex>\n"
|
||||
" set-control <device_index> <control_id_hex> <value>\n"
|
||||
" start-ingest <stream_id> <device> <dest_host> <dest_port>"
|
||||
" [format] [width] [height] [fps_n] [fps_d]\n"
|
||||
" stop-ingest <stream_id>\n"
|
||||
" start-display <stream_id> [win_x] [win_y] [win_w] [win_h] [no_signal_fps]\n"
|
||||
" stop-display <stream_id>\n"
|
||||
" help\n"
|
||||
" quit / exit\n");
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Entry point
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void usage(void)
|
||||
{
|
||||
fprintf(stderr,
|
||||
"usage: controller_cli [--host HOST] [--port PORT]\n"
|
||||
"\n"
|
||||
" Interactive controller for a video node.\n"
|
||||
" --host HOST connect directly on startup\n"
|
||||
" --port PORT TCP port (default 8000; used with --host)\n"
|
||||
"\n"
|
||||
" Without --host: starts discovery and waits for nodes.\n"
|
||||
" Use 'connect' in the REPL to connect to a discovered node.\n");
|
||||
}
|
||||
|
||||
/* Attempt to connect/reconnect; prints result. Returns new conn or NULL. */
|
||||
static struct Transport_Conn *do_connect(struct Ctrl_State *cs,
|
||||
const char *host, uint16_t port,
|
||||
struct Transport_Conn *old_conn)
|
||||
{
|
||||
if (old_conn) { transport_conn_close(old_conn); }
|
||||
/* Reset state — drain stale semaphore posts from the old connection */
|
||||
cs->pending_cmd = 0;
|
||||
while (sem_trywait(&cs->sem) == 0) { /* drain */ }
|
||||
struct Transport_Conn *conn;
|
||||
struct App_Error e = transport_connect(&conn, host, port,
|
||||
TRANSPORT_DEFAULT_MAX_PAYLOAD, on_frame, on_disconnect, cs);
|
||||
if (!APP_IS_OK(e)) {
|
||||
app_error_print(&e);
|
||||
return NULL;
|
||||
}
|
||||
printf("connected to %s:%u\n", host, port);
|
||||
return conn;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
const char *init_host = NULL;
|
||||
uint16_t init_port = 8000;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--host") == 0 && i + 1 < argc) {
|
||||
init_host = argv[++i];
|
||||
} else if (strcmp(argv[i], "--port") == 0 && i + 1 < argc) {
|
||||
init_port = (uint16_t)atoi(argv[++i]);
|
||||
} else {
|
||||
usage(); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Start discovery (always — useful even when --host given, for 'peers') */
|
||||
struct Discovery *disc = NULL;
|
||||
struct Discovery_Config dcfg = {0};
|
||||
dcfg.site_id = 0;
|
||||
dcfg.tcp_port = 0;
|
||||
dcfg.function_flags = DISCOVERY_FLAG_CONTROLLER;
|
||||
dcfg.name = "controller_cli";
|
||||
dcfg.on_peer_found = on_peer_found;
|
||||
dcfg.on_peer_lost = on_peer_lost;
|
||||
if (!APP_IS_OK(discovery_create(&disc, &dcfg)) ||
|
||||
!APP_IS_OK(discovery_start(disc))) {
|
||||
fprintf(stderr, "warning: discovery failed to start\n");
|
||||
disc = NULL;
|
||||
}
|
||||
|
||||
struct Ctrl_State cs;
|
||||
memset(&cs, 0, sizeof(cs));
|
||||
sem_init(&cs.sem, 0, 0);
|
||||
|
||||
struct Transport_Conn *conn = NULL;
|
||||
|
||||
if (init_host) {
|
||||
conn = do_connect(&cs, init_host, init_port, NULL);
|
||||
if (!conn) { return 1; }
|
||||
} else {
|
||||
printf("listening for nodes — type 'peers' to list, 'connect' to connect\n");
|
||||
}
|
||||
|
||||
cmd_help();
|
||||
printf("\n");
|
||||
|
||||
/* REPL */
|
||||
uint16_t req_id = 0;
|
||||
char line[512];
|
||||
|
||||
while (1) {
|
||||
char *rl_line = readline(conn ? "> " : "(no node) > ");
|
||||
if (!rl_line) { break; }
|
||||
if (*rl_line) { add_history(rl_line); }
|
||||
strncpy(line, rl_line, sizeof(line) - 1);
|
||||
line[sizeof(line) - 1] = '\0';
|
||||
free(rl_line);
|
||||
|
||||
/* Tokenise (up to 12 tokens) */
|
||||
char *tokens[12];
|
||||
int ntok = 0;
|
||||
char *p = line;
|
||||
while (*p && ntok < 12) {
|
||||
while (*p == ' ' || *p == '\t') { p++; }
|
||||
if (!*p) { break; }
|
||||
tokens[ntok++] = p;
|
||||
while (*p && *p != ' ' && *p != '\t') { p++; }
|
||||
if (*p) { *p++ = '\0'; }
|
||||
}
|
||||
if (ntok == 0) { continue; }
|
||||
|
||||
const char *cmd = tokens[0];
|
||||
|
||||
if (strcmp(cmd, "quit") == 0 || strcmp(cmd, "exit") == 0) {
|
||||
break;
|
||||
|
||||
} else if (strcmp(cmd, "help") == 0) {
|
||||
cmd_help();
|
||||
|
||||
} else if (strcmp(cmd, "peers") == 0) {
|
||||
pthread_mutex_lock(&peer_mutex);
|
||||
if (peer_count == 0) {
|
||||
printf("no peers discovered yet\n");
|
||||
} else {
|
||||
for (int i = 0; i < peer_count; i++) {
|
||||
printf(" [%d] %s %s:%u\n", i,
|
||||
peer_table[i].name,
|
||||
peer_table[i].host,
|
||||
peer_table[i].port);
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&peer_mutex);
|
||||
|
||||
} else if (strcmp(cmd, "connect") == 0) {
|
||||
char host[64];
|
||||
uint16_t port = 8000;
|
||||
|
||||
if (ntok < 2) {
|
||||
/* No argument — connect to first discovered peer */
|
||||
pthread_mutex_lock(&peer_mutex);
|
||||
int ok = peer_count > 0;
|
||||
if (ok) {
|
||||
strncpy(host, peer_table[0].host, sizeof(host) - 1);
|
||||
host[sizeof(host) - 1] = '\0';
|
||||
port = peer_table[0].port;
|
||||
}
|
||||
pthread_mutex_unlock(&peer_mutex);
|
||||
if (!ok) {
|
||||
printf("no peers discovered yet — try 'peers'\n");
|
||||
continue;
|
||||
}
|
||||
} else if (strchr(tokens[1], ':')) {
|
||||
/* host:port */
|
||||
char *colon = strchr(tokens[1], ':');
|
||||
size_t hlen = (size_t)(colon - tokens[1]);
|
||||
if (hlen >= sizeof(host)) { hlen = sizeof(host) - 1; }
|
||||
memcpy(host, tokens[1], hlen);
|
||||
host[hlen] = '\0';
|
||||
port = (uint16_t)atoi(colon + 1);
|
||||
} else {
|
||||
/* numeric index into peer table */
|
||||
int idx = atoi(tokens[1]);
|
||||
pthread_mutex_lock(&peer_mutex);
|
||||
int ok = idx >= 0 && idx < peer_count;
|
||||
if (ok) {
|
||||
strncpy(host, peer_table[idx].host, sizeof(host) - 1);
|
||||
host[sizeof(host) - 1] = '\0';
|
||||
port = peer_table[idx].port;
|
||||
}
|
||||
pthread_mutex_unlock(&peer_mutex);
|
||||
if (!ok) {
|
||||
printf("index %d out of range — try 'peers'\n", idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
conn = do_connect(&cs, host, port, conn);
|
||||
|
||||
} else if (!conn) {
|
||||
printf("not connected — use 'connect' to connect to a node\n");
|
||||
|
||||
} else if (strcmp(cmd, "enum-devices") == 0) {
|
||||
cmd_enum_devices(conn, &cs, &req_id);
|
||||
} else if (strcmp(cmd, "enum-controls") == 0) {
|
||||
if (ntok < 2) { printf("usage: enum-controls <device_index>\n"); }
|
||||
else { cmd_enum_controls(conn, &cs, &req_id, tokens[1]); }
|
||||
} else if (strcmp(cmd, "get-control") == 0) {
|
||||
if (ntok < 3) { printf("usage: get-control <device_index> <control_id>\n"); }
|
||||
else { cmd_get_control(conn, &cs, &req_id, tokens[1], tokens[2]); }
|
||||
} else if (strcmp(cmd, "set-control") == 0) {
|
||||
if (ntok < 4) { printf("usage: set-control <device_index> <control_id> <value>\n"); }
|
||||
else { cmd_set_control(conn, &cs, &req_id, tokens[1], tokens[2], tokens[3]); }
|
||||
} else if (strcmp(cmd, "start-ingest") == 0) {
|
||||
cmd_start_ingest(conn, &cs, &req_id, ntok, tokens);
|
||||
} else if (strcmp(cmd, "stop-ingest") == 0) {
|
||||
if (ntok < 2) { printf("usage: stop-ingest <stream_id>\n"); }
|
||||
else { cmd_stop_ingest(conn, &cs, &req_id, tokens[1]); }
|
||||
} else if (strcmp(cmd, "start-display") == 0) {
|
||||
cmd_start_display(conn, &cs, &req_id, ntok, tokens);
|
||||
} else if (strcmp(cmd, "stop-display") == 0) {
|
||||
if (ntok < 2) { printf("usage: stop-display <stream_id>\n"); }
|
||||
else { cmd_stop_display(conn, &cs, &req_id, tokens[1]); }
|
||||
} else {
|
||||
printf("unknown command: %s (type 'help' for commands)\n", cmd);
|
||||
}
|
||||
}
|
||||
|
||||
if (conn) { transport_conn_close(conn); }
|
||||
if (disc) { discovery_destroy(disc); }
|
||||
sem_destroy(&cs.sem);
|
||||
return 0;
|
||||
}
|
||||
250
dev/cli/protocol_cli.c
Normal file
250
dev/cli/protocol_cli.c
Normal file
@@ -0,0 +1,250 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "protocol.h"
|
||||
#include "transport.h"
|
||||
#include "error.h"
|
||||
|
||||
/* -- frame decoder --------------------------------------------------------- */
|
||||
|
||||
static const char *cmd_name(uint16_t cmd) {
|
||||
switch (cmd) {
|
||||
case PROTO_CMD_STREAM_OPEN: return "STREAM_OPEN";
|
||||
case PROTO_CMD_STREAM_CLOSE: return "STREAM_CLOSE";
|
||||
case PROTO_CMD_ENUM_DEVICES: return "ENUM_DEVICES";
|
||||
case PROTO_CMD_ENUM_CONTROLS: return "ENUM_CONTROLS";
|
||||
case PROTO_CMD_GET_CONTROL: return "GET_CONTROL";
|
||||
case PROTO_CMD_SET_CONTROL: return "SET_CONTROL";
|
||||
case PROTO_CMD_ENUM_MONITORS: return "ENUM_MONITORS";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static const char *event_name(uint8_t code) {
|
||||
switch (code) {
|
||||
case PROTO_EVENT_INTERRUPTED: return "INTERRUPTED";
|
||||
case PROTO_EVENT_RESUMED: return "RESUMED";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static const char *status_name(uint16_t s) {
|
||||
switch (s) {
|
||||
case PROTO_STATUS_OK: return "OK";
|
||||
case PROTO_STATUS_ERROR: return "ERROR";
|
||||
case PROTO_STATUS_UNKNOWN_CMD: return "UNKNOWN_CMD";
|
||||
case PROTO_STATUS_INVALID_PARAMS: return "INVALID_PARAMS";
|
||||
case PROTO_STATUS_NOT_FOUND: return "NOT_FOUND";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static void decode_and_print(struct Transport_Frame *frame) {
|
||||
switch (frame->message_type) {
|
||||
|
||||
case PROTO_MSG_VIDEO_FRAME: {
|
||||
struct Proto_Video_Frame vf;
|
||||
struct App_Error e = proto_read_video_frame(
|
||||
frame->payload, frame->payload_length, &vf);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return; }
|
||||
printf("VIDEO_FRAME stream_id=%u data_len=%u\n",
|
||||
vf.stream_id, vf.data_len);
|
||||
break;
|
||||
}
|
||||
|
||||
case PROTO_MSG_STREAM_EVENT: {
|
||||
struct Proto_Stream_Event ev;
|
||||
struct App_Error e = proto_read_stream_event(
|
||||
frame->payload, frame->payload_length, &ev);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return; }
|
||||
printf("STREAM_EVENT stream_id=%u event=%s\n",
|
||||
ev.stream_id, event_name(ev.event_code));
|
||||
break;
|
||||
}
|
||||
|
||||
case PROTO_MSG_CONTROL_REQUEST: {
|
||||
struct Proto_Request_Header hdr;
|
||||
struct App_Error e = proto_read_request_header(
|
||||
frame->payload, frame->payload_length, &hdr);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return; }
|
||||
|
||||
printf("CONTROL_REQUEST request_id=%u command=%s\n",
|
||||
hdr.request_id, cmd_name(hdr.command));
|
||||
|
||||
switch (hdr.command) {
|
||||
case PROTO_CMD_STREAM_OPEN: {
|
||||
struct Proto_Stream_Open so;
|
||||
e = proto_read_stream_open(frame->payload, frame->payload_length, &so);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return; }
|
||||
printf(" stream_id=%u format=0x%04x pixel_format=0x%04x origin=0x%04x\n",
|
||||
so.stream_id, so.format, so.pixel_format, so.origin);
|
||||
break;
|
||||
}
|
||||
case PROTO_CMD_STREAM_CLOSE: {
|
||||
struct Proto_Stream_Close sc;
|
||||
e = proto_read_stream_close(frame->payload, frame->payload_length, &sc);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return; }
|
||||
printf(" stream_id=%u\n", sc.stream_id);
|
||||
break;
|
||||
}
|
||||
case PROTO_CMD_ENUM_CONTROLS: {
|
||||
struct Proto_Enum_Controls_Req req;
|
||||
e = proto_read_enum_controls_req(frame->payload, frame->payload_length, &req);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return; }
|
||||
printf(" device_index=%u\n", req.device_index);
|
||||
break;
|
||||
}
|
||||
case PROTO_CMD_GET_CONTROL: {
|
||||
struct Proto_Get_Control_Req req;
|
||||
e = proto_read_get_control_req(frame->payload, frame->payload_length, &req);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return; }
|
||||
printf(" device_index=%u control_id=%u\n",
|
||||
req.device_index, req.control_id);
|
||||
break;
|
||||
}
|
||||
case PROTO_CMD_SET_CONTROL: {
|
||||
struct Proto_Set_Control_Req req;
|
||||
e = proto_read_set_control_req(frame->payload, frame->payload_length, &req);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return; }
|
||||
printf(" device_index=%u control_id=%u value=%d\n",
|
||||
req.device_index, req.control_id, req.value);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PROTO_MSG_CONTROL_RESPONSE: {
|
||||
struct Proto_Response_Header hdr;
|
||||
struct App_Error e = proto_read_response_header(
|
||||
frame->payload, frame->payload_length, &hdr);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return; }
|
||||
|
||||
printf("CONTROL_RESPONSE request_id=%u status=%s\n",
|
||||
hdr.request_id, status_name(hdr.status));
|
||||
|
||||
/* extra bytes are command-specific; caller must know the command */
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
printf("unknown message_type=0x%04x payload_length=%u\n",
|
||||
frame->message_type, frame->payload_length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- server mode ----------------------------------------------------------- */
|
||||
|
||||
static void server_on_frame(struct Transport_Conn *conn,
|
||||
struct Transport_Frame *frame, void *ud)
|
||||
{
|
||||
(void)conn; (void)ud;
|
||||
decode_and_print(frame);
|
||||
free(frame->payload);
|
||||
}
|
||||
|
||||
static void server_on_connect(struct Transport_Conn *conn, void *ud) {
|
||||
(void)conn; (void)ud;
|
||||
printf("client connected\n");
|
||||
}
|
||||
|
||||
static void server_on_disconnect(struct Transport_Conn *conn, void *ud) {
|
||||
(void)conn; (void)ud;
|
||||
printf("client disconnected\n");
|
||||
}
|
||||
|
||||
static int run_server(uint16_t port) {
|
||||
struct Transport_Server_Config cfg = {
|
||||
.port = port,
|
||||
.max_connections = 8,
|
||||
.max_payload = 16 * 1024 * 1024,
|
||||
.on_frame = server_on_frame,
|
||||
.on_connect = server_on_connect,
|
||||
.on_disconnect = server_on_disconnect,
|
||||
};
|
||||
|
||||
struct Transport_Server *server;
|
||||
struct App_Error e = transport_server_create(&server, &cfg);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return 1; }
|
||||
|
||||
e = transport_server_start(server);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return 1; }
|
||||
|
||||
printf("listening on port %u\n", port);
|
||||
pause();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* -- client mode ----------------------------------------------------------- */
|
||||
|
||||
static void client_on_frame(struct Transport_Conn *conn,
|
||||
struct Transport_Frame *frame, void *ud)
|
||||
{
|
||||
(void)conn; (void)ud;
|
||||
decode_and_print(frame);
|
||||
free(frame->payload);
|
||||
}
|
||||
|
||||
static void client_on_disconnect(struct Transport_Conn *conn, void *ud) {
|
||||
(void)conn; (void)ud;
|
||||
printf("disconnected\n");
|
||||
}
|
||||
|
||||
static int run_client(const char *host, uint16_t port) {
|
||||
struct Transport_Conn *conn;
|
||||
struct App_Error e = transport_connect(&conn, host, port,
|
||||
16 * 1024 * 1024,
|
||||
client_on_frame, client_on_disconnect, NULL);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return 1; }
|
||||
|
||||
printf("connected — sending STREAM_OPEN request\n");
|
||||
|
||||
e = proto_write_stream_open(conn,
|
||||
/*request_id=*/1,
|
||||
/*stream_id=*/0,
|
||||
PROTO_FORMAT_MJPEG,
|
||||
0,
|
||||
PROTO_ORIGIN_DEVICE_NATIVE);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return 1; }
|
||||
|
||||
printf("sent STREAM_OPEN; waiting for response (ctrl-c to exit)\n");
|
||||
pause();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* -- usage ----------------------------------------------------------------- */
|
||||
|
||||
static void usage(void) {
|
||||
fprintf(stderr,
|
||||
"usage: protocol_cli --server [port]\n"
|
||||
" protocol_cli --client host port\n"
|
||||
"\n"
|
||||
" --server [port] listen and decode incoming protocol frames\n"
|
||||
" (default port 8000)\n"
|
||||
" --client host port connect, send STREAM_OPEN, decode responses\n");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 2) { usage(); return 1; }
|
||||
|
||||
if (strcmp(argv[1], "--server") == 0) {
|
||||
uint16_t port = 8000;
|
||||
if (argc >= 3) { port = (uint16_t)atoi(argv[2]); }
|
||||
return run_server(port);
|
||||
}
|
||||
|
||||
if (strcmp(argv[1], "--client") == 0) {
|
||||
if (argc < 4) { usage(); return 1; }
|
||||
uint16_t port = (uint16_t)atoi(argv[3]);
|
||||
return run_client(argv[2], port);
|
||||
}
|
||||
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
297
dev/cli/query_cli.c
Normal file
297
dev/cli/query_cli.c
Normal file
@@ -0,0 +1,297 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <semaphore.h>
|
||||
#include <stdatomic.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#include "discovery.h"
|
||||
#include "transport.h"
|
||||
#include "protocol.h"
|
||||
#include "error.h"
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Discovery — wait for first matching node
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
struct Discovery_Wait {
|
||||
struct Discovery_Peer peer;
|
||||
atomic_int found;
|
||||
};
|
||||
|
||||
static void on_peer_found(const struct Discovery_Peer *peer, void *userdata) {
|
||||
struct Discovery_Wait *w = userdata;
|
||||
if (atomic_load(&w->found)) { return; }
|
||||
w->peer = *peer;
|
||||
atomic_store(&w->found, 1);
|
||||
}
|
||||
|
||||
static int wait_for_node(struct Discovery_Peer *peer_out,
|
||||
const char *self_name, int timeout_ms)
|
||||
{
|
||||
struct Discovery_Wait w;
|
||||
memset(&w, 0, sizeof(w));
|
||||
atomic_init(&w.found, 0);
|
||||
|
||||
struct Discovery_Config cfg = {
|
||||
.site_id = 0,
|
||||
.tcp_port = 0,
|
||||
.function_flags = 0,
|
||||
.name = self_name,
|
||||
.interval_ms = 2000,
|
||||
.timeout_intervals= 3,
|
||||
.on_peer_found = on_peer_found,
|
||||
.userdata = &w,
|
||||
};
|
||||
|
||||
struct Discovery *disc;
|
||||
struct App_Error e = discovery_create(&disc, &cfg);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return -1; }
|
||||
|
||||
e = discovery_start(disc);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); discovery_destroy(disc); return -1; }
|
||||
|
||||
printf("waiting for a node (timeout %d ms)...\n", timeout_ms);
|
||||
|
||||
int elapsed = 0;
|
||||
while (!atomic_load(&w.found) && elapsed < timeout_ms) {
|
||||
usleep(100000);
|
||||
elapsed += 100;
|
||||
}
|
||||
|
||||
discovery_destroy(disc);
|
||||
|
||||
if (!atomic_load(&w.found)) {
|
||||
fprintf(stderr, "no node found within %d ms\n", timeout_ms);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*peer_out = w.peer;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Response handling — semaphore-based synchronisation
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
struct Query_State {
|
||||
sem_t sem;
|
||||
uint16_t last_request_id;
|
||||
int done;
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* ENUM_DEVICES callbacks
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void on_media_device(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *driver, uint8_t driver_len,
|
||||
const char *model, uint8_t model_len,
|
||||
const char *bus_info, uint8_t bus_info_len,
|
||||
uint8_t vcount,
|
||||
void *ud)
|
||||
{
|
||||
(void)ud;
|
||||
printf(" media %.*s driver=%.*s model=%.*s bus=%.*s (%u video node(s))\n",
|
||||
(int)path_len, path,
|
||||
(int)driver_len, driver,
|
||||
(int)model_len, model,
|
||||
(int)bus_info_len, bus_info,
|
||||
(unsigned)vcount);
|
||||
}
|
||||
|
||||
static void caps_str(uint32_t caps, char *buf, size_t len) {
|
||||
/* Build a compact comma-separated list of the relevant V4L2_CAP_* bits. */
|
||||
static const struct { uint32_t bit; const char *name; } flags[] = {
|
||||
{ 0x00000001u, "video-capture" },
|
||||
{ 0x00000002u, "video-output" },
|
||||
{ 0x00000010u, "vbi-capture" },
|
||||
{ 0x00800000u, "meta-capture" },
|
||||
{ 0x04000000u, "streaming" },
|
||||
};
|
||||
buf[0] = '\0';
|
||||
size_t pos = 0;
|
||||
for (size_t i = 0; i < sizeof(flags)/sizeof(flags[0]); i++) {
|
||||
if (!(caps & flags[i].bit)) { continue; }
|
||||
int n = snprintf(buf + pos, len - pos, "%s%s",
|
||||
pos ? "," : "", flags[i].name);
|
||||
if (n < 0 || (size_t)n >= len - pos) { break; }
|
||||
pos += (size_t)n;
|
||||
}
|
||||
}
|
||||
|
||||
static void on_video_node(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *ename, uint8_t ename_len,
|
||||
uint32_t etype, uint32_t eflags,
|
||||
uint32_t dcaps,
|
||||
uint8_t pflags, uint8_t is_capture,
|
||||
void *ud)
|
||||
{
|
||||
(void)eflags; (void)pflags; (void)ud;
|
||||
char caps[128];
|
||||
caps_str(dcaps, caps, sizeof(caps));
|
||||
printf(" video %.*s entity=%.*s type=0x%08x caps=[%s]%s\n",
|
||||
(int)path_len, path,
|
||||
(int)ename_len, ename,
|
||||
etype, caps,
|
||||
is_capture ? " [capture]" : "");
|
||||
}
|
||||
|
||||
static void on_standalone(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *name, uint8_t name_len,
|
||||
void *ud)
|
||||
{
|
||||
(void)ud;
|
||||
printf(" standalone %.*s card=%.*s\n",
|
||||
(int)path_len, path,
|
||||
(int)name_len, name);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* ENUM_CONTROLS callbacks
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void on_control(
|
||||
uint32_t id, uint8_t type, uint32_t flags,
|
||||
const char *name, uint8_t name_len,
|
||||
int32_t min, int32_t max, int32_t step,
|
||||
int32_t default_val, int32_t current_val,
|
||||
uint8_t menu_count, void *ud)
|
||||
{
|
||||
(void)flags; (void)ud;
|
||||
printf(" ctrl id=0x%08x type=%u %.*s"
|
||||
" min=%d max=%d step=%d default=%d current=%d",
|
||||
id, type,
|
||||
(int)name_len, name,
|
||||
min, max, step, default_val, current_val);
|
||||
if (menu_count) { printf(" (%u menu items)", (unsigned)menu_count); }
|
||||
printf("\n");
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Frame handler
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
struct Frame_State {
|
||||
struct Query_State *q;
|
||||
uint16_t pending_cmd; /* command we expect a response for */
|
||||
uint16_t device_count; /* flat video node count from ENUM_DEVICES */
|
||||
};
|
||||
|
||||
static void on_frame(struct Transport_Conn *conn,
|
||||
struct Transport_Frame *frame, void *userdata)
|
||||
{
|
||||
(void)conn;
|
||||
struct Frame_State *fs = userdata;
|
||||
|
||||
if (frame->message_type == PROTO_MSG_CONTROL_RESPONSE) {
|
||||
switch (fs->pending_cmd) {
|
||||
case PROTO_CMD_ENUM_DEVICES: {
|
||||
struct Proto_Response_Header hdr;
|
||||
struct App_Error e = proto_read_enum_devices_response(
|
||||
frame->payload, frame->payload_length, &hdr,
|
||||
on_media_device, on_video_node, on_standalone, NULL, NULL);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); }
|
||||
else if (hdr.status != PROTO_STATUS_OK) {
|
||||
fprintf(stderr, "ENUM_DEVICES failed: status=%u\n", hdr.status);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PROTO_CMD_ENUM_CONTROLS: {
|
||||
struct Proto_Response_Header hdr;
|
||||
struct App_Error e = proto_read_enum_controls_response(
|
||||
frame->payload, frame->payload_length, &hdr,
|
||||
on_control, NULL, NULL);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); }
|
||||
else if (hdr.status != PROTO_STATUS_OK) {
|
||||
fprintf(stderr, "ENUM_CONTROLS failed: status=%u\n", hdr.status);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
sem_post(&fs->q->sem);
|
||||
}
|
||||
|
||||
free(frame->payload);
|
||||
}
|
||||
|
||||
static void on_disconnect(struct Transport_Conn *conn, void *userdata) {
|
||||
(void)conn; (void)userdata;
|
||||
printf("disconnected\n");
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Entry point
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void usage(void) {
|
||||
fprintf(stderr,
|
||||
"usage: query_cli [--timeout ms] [--controls device_index]\n"
|
||||
"\n"
|
||||
" Discovers a video node on the LAN and queries its devices.\n"
|
||||
" --timeout ms discovery timeout in ms (default 5000)\n"
|
||||
" --controls idx also enumerate controls for device at index idx\n");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
int timeout_ms = 5000;
|
||||
int query_controls = -1; /* -1 = don't query controls */
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--timeout") == 0 && i + 1 < argc) {
|
||||
timeout_ms = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--controls") == 0 && i + 1 < argc) {
|
||||
query_controls = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--help") == 0) {
|
||||
usage(); return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Discover a node */
|
||||
struct Discovery_Peer peer;
|
||||
if (wait_for_node(&peer, "query_cli:0", timeout_ms) != 0) { return 1; }
|
||||
|
||||
char addr_str[INET_ADDRSTRLEN];
|
||||
struct in_addr in = { .s_addr = peer.addr };
|
||||
inet_ntop(AF_INET, &in, addr_str, sizeof(addr_str));
|
||||
printf("found node: %s addr=%s port=%u\n",
|
||||
peer.name, addr_str, peer.tcp_port);
|
||||
|
||||
/* Set up connection state */
|
||||
struct Query_State q;
|
||||
sem_init(&q.sem, 0, 0);
|
||||
|
||||
struct Frame_State fs = { .q = &q, .pending_cmd = 0, .device_count = 0 };
|
||||
|
||||
struct Transport_Conn *conn;
|
||||
struct App_Error e = transport_connect(&conn, addr_str, peer.tcp_port,
|
||||
16 * 1024 * 1024, on_frame, on_disconnect, &fs);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return 1; }
|
||||
|
||||
/* ENUM_DEVICES */
|
||||
printf("\ndevices:\n");
|
||||
fs.pending_cmd = PROTO_CMD_ENUM_DEVICES;
|
||||
e = proto_write_enum_devices(conn, 1);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return 1; }
|
||||
sem_wait(&q.sem);
|
||||
|
||||
/* ENUM_CONTROLS for a specific device if requested */
|
||||
if (query_controls >= 0) {
|
||||
printf("\ncontrols for device %d:\n", query_controls);
|
||||
fs.pending_cmd = PROTO_CMD_ENUM_CONTROLS;
|
||||
e = proto_write_enum_controls(conn, 2, (uint16_t)query_controls);
|
||||
if (!APP_IS_OK(e)) { app_error_print(&e); return 1; }
|
||||
sem_wait(&q.sem);
|
||||
}
|
||||
|
||||
transport_conn_close(conn);
|
||||
sem_destroy(&q.sem);
|
||||
return 0;
|
||||
}
|
||||
456
dev/cli/reconciler_cli.c
Normal file
456
dev/cli/reconciler_cli.c
Normal file
@@ -0,0 +1,456 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <ctype.h>
|
||||
#include "reconciler.h"
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Simulated resource userdata
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
struct Sim_State {
|
||||
const char *name;
|
||||
int fail_next;
|
||||
};
|
||||
|
||||
static int sim_action(struct Sim_State *s, const char *action_name) {
|
||||
printf(" [%s] %s\n", s->name, action_name);
|
||||
if (s->fail_next) {
|
||||
s->fail_next = 0;
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* device transitions */
|
||||
static int device_open(void *ud) { return sim_action((struct Sim_State *)ud, "opening device"); }
|
||||
static int device_close(void *ud) { return sim_action((struct Sim_State *)ud, "closing device"); }
|
||||
static int device_start(void *ud) { return sim_action((struct Sim_State *)ud, "starting capture"); }
|
||||
static int device_stop(void *ud) { return sim_action((struct Sim_State *)ud, "stopping capture"); }
|
||||
|
||||
/* transport transitions */
|
||||
static int transport_connect(void *ud) { return sim_action((struct Sim_State *)ud, "connecting transport"); }
|
||||
static int transport_disconnect(void *ud) { return sim_action((struct Sim_State *)ud, "disconnecting transport"); }
|
||||
|
||||
/* stream transitions */
|
||||
static int stream_activate(void *ud) { return sim_action((struct Sim_State *)ud, "activating stream"); }
|
||||
static int stream_deactivate(void *ud) { return sim_action((struct Sim_State *)ud, "deactivating stream"); }
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Log callback
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
static void on_log(
|
||||
const struct Rec_Resource *res,
|
||||
int from, int to, int success,
|
||||
void *userdata)
|
||||
{
|
||||
(void)userdata;
|
||||
|
||||
const char *from_name = reconciler_state_name(res, from);
|
||||
const char *to_name = reconciler_state_name(res, to);
|
||||
|
||||
if (from_name != NULL && to_name != NULL) {
|
||||
printf(" [%s] %s -> %s ... %s\n",
|
||||
reconciler_get_name(res),
|
||||
from_name, to_name,
|
||||
success ? "ok" : "FAILED");
|
||||
} else {
|
||||
printf(" [%s] %d -> %d ... %s\n",
|
||||
reconciler_get_name(res),
|
||||
from, to,
|
||||
success ? "ok" : "FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Helpers
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
static const char *status_label(Rec_Status s) {
|
||||
switch (s) {
|
||||
case REC_STATUS_STABLE: return "stable";
|
||||
case REC_STATUS_WORKING: return "working";
|
||||
case REC_STATUS_BLOCKED: return "blocked";
|
||||
case REC_STATUS_NO_PATH: return "no_path";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
/*
|
||||
* Find the first unsatisfied dep for a resource.
|
||||
* Returns NULL if none (or resource is not blocked).
|
||||
*/
|
||||
static void print_blocked_reason(const struct Rec_Resource *res) {
|
||||
/* We need access to internals — expose via a helper approach.
|
||||
* Since we can't access internal deps from outside the module,
|
||||
* we rely on reconciler_get_status returning BLOCKED and print
|
||||
* a generic message. The CLI has access to the sim resources
|
||||
* directly so we can check ourselves using the public API. */
|
||||
(void)res;
|
||||
printf(" (dependency unsatisfied)");
|
||||
}
|
||||
|
||||
static void print_state(const struct Rec_Resource *res, int state) {
|
||||
const char *name = reconciler_state_name(res, state);
|
||||
if (name != NULL) {
|
||||
printf("%s(%d)", name, state);
|
||||
} else {
|
||||
printf("%d", state);
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Blocked dependency introspection
|
||||
*
|
||||
* We track resource/dep relationships here in the CLI so we can print
|
||||
* informative blocked messages without exposing internals from the module.
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
#define CLI_MAX_DEPS 8
|
||||
|
||||
struct Cli_Dep {
|
||||
const struct Rec_Resource *resource;
|
||||
int blocked_below;
|
||||
const struct Rec_Resource *dep;
|
||||
int dep_min_state;
|
||||
};
|
||||
|
||||
static struct Cli_Dep cli_deps[CLI_MAX_DEPS];
|
||||
static int cli_dep_count = 0;
|
||||
|
||||
static void cli_add_dep(
|
||||
const struct Rec_Resource *resource,
|
||||
int blocked_below,
|
||||
const struct Rec_Resource *dep,
|
||||
int dep_min_state)
|
||||
{
|
||||
if (cli_dep_count >= CLI_MAX_DEPS) {
|
||||
return;
|
||||
}
|
||||
struct Cli_Dep *d = &cli_deps[cli_dep_count++];
|
||||
d->resource = resource;
|
||||
d->blocked_below = blocked_below;
|
||||
d->dep = dep;
|
||||
d->dep_min_state = dep_min_state;
|
||||
}
|
||||
|
||||
static void print_blocked_info(const struct Rec_Resource *res) {
|
||||
int wanted = reconciler_get_wanted(res);
|
||||
int current = reconciler_get_current(res);
|
||||
(void)current;
|
||||
|
||||
for (int i = 0; i < cli_dep_count; i++) {
|
||||
struct Cli_Dep *d = &cli_deps[i];
|
||||
if (d->resource != res) {
|
||||
continue;
|
||||
}
|
||||
if (wanted < d->blocked_below) {
|
||||
continue;
|
||||
}
|
||||
if (reconciler_get_current(d->dep) < d->dep_min_state) {
|
||||
printf(" [blocked: %s < ", reconciler_get_name(d->dep));
|
||||
print_state(d->dep, d->dep_min_state);
|
||||
printf("]");
|
||||
return;
|
||||
}
|
||||
}
|
||||
print_blocked_reason(res);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Command implementations
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
#define MAX_RESOURCES 8
|
||||
|
||||
static struct Rec_Resource *all_resources[MAX_RESOURCES];
|
||||
static int resource_count = 0;
|
||||
|
||||
static struct Rec_Resource *find_resource(const char *name) {
|
||||
for (int i = 0; i < resource_count; i++) {
|
||||
if (strcmp(reconciler_get_name(all_resources[i]), name) == 0) {
|
||||
return all_resources[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int parse_state(const struct Rec_Resource *res, const char *token) {
|
||||
/* Try numeric first. */
|
||||
char *end;
|
||||
long n = strtol(token, &end, 10);
|
||||
if (*end == '\0') {
|
||||
return (int)n;
|
||||
}
|
||||
|
||||
/* Try case-insensitive name match. */
|
||||
int state_count = 0;
|
||||
/* Iterate states 0..N-1 using reconciler_state_name. We don't know
|
||||
* state_count without internal access, so scan until NULL. */
|
||||
for (int i = 0; i < 64; i++) {
|
||||
const char *sname = reconciler_state_name(res, i);
|
||||
if (sname == NULL) {
|
||||
break;
|
||||
}
|
||||
state_count++;
|
||||
/* Case-insensitive compare. */
|
||||
int match = 1;
|
||||
size_t tlen = strlen(token);
|
||||
size_t slen = strlen(sname);
|
||||
if (tlen != slen) {
|
||||
match = 0;
|
||||
} else {
|
||||
for (size_t j = 0; j < tlen; j++) {
|
||||
if (tolower((unsigned char)token[j]) != tolower((unsigned char)sname[j])) {
|
||||
match = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
(void)state_count;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void cmd_status(void) {
|
||||
for (int i = 0; i < resource_count; i++) {
|
||||
const struct Rec_Resource *res = all_resources[i];
|
||||
int current = reconciler_get_current(res);
|
||||
int wanted = reconciler_get_wanted(res);
|
||||
Rec_Status status = reconciler_get_status(res);
|
||||
|
||||
printf(" %-12s ", reconciler_get_name(res));
|
||||
print_state(res, current);
|
||||
printf(" wanted ");
|
||||
print_state(res, wanted);
|
||||
printf(" [%s]", status_label(status));
|
||||
|
||||
if (status == REC_STATUS_BLOCKED) {
|
||||
print_blocked_info(res);
|
||||
}
|
||||
|
||||
printf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
static void cmd_want(const char *name, const char *state_token) {
|
||||
struct Rec_Resource *res = find_resource(name);
|
||||
if (res == NULL) {
|
||||
printf("unknown resource: %s\n", name);
|
||||
return;
|
||||
}
|
||||
|
||||
int state = parse_state(res, state_token);
|
||||
if (state < 0) {
|
||||
printf("unknown state: %s\n", state_token);
|
||||
return;
|
||||
}
|
||||
|
||||
reconciler_set_wanted(res, state);
|
||||
printf(" %s wanted -> ", name);
|
||||
print_state(res, state);
|
||||
printf("\n");
|
||||
}
|
||||
|
||||
static void cmd_tick(struct Reconciler *r) {
|
||||
int n = reconciler_tick(r);
|
||||
if (n == 0) {
|
||||
printf(" (no transitions)\n");
|
||||
}
|
||||
}
|
||||
|
||||
static void cmd_run(struct Reconciler *r) {
|
||||
int max_ticks = 20;
|
||||
int tick = 0;
|
||||
|
||||
while (!reconciler_is_stable(r) && tick < max_ticks) {
|
||||
printf("tick %d:\n", tick + 1);
|
||||
int n = reconciler_tick(r);
|
||||
if (n == 0) {
|
||||
printf(" (no progress — stopping)\n");
|
||||
break;
|
||||
}
|
||||
tick++;
|
||||
}
|
||||
|
||||
if (reconciler_is_stable(r)) {
|
||||
printf("stable after %d tick(s)\n", tick);
|
||||
} else if (tick >= max_ticks) {
|
||||
printf("reached max ticks (%d) without stabilising\n", max_ticks);
|
||||
}
|
||||
}
|
||||
|
||||
static void cmd_fail(const char *name, struct Sim_State sim_states[], int sim_count) {
|
||||
for (int i = 0; i < sim_count; i++) {
|
||||
if (strcmp(sim_states[i].name, name) == 0) {
|
||||
sim_states[i].fail_next = 1;
|
||||
printf(" next action for %s will fail\n", name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
printf("unknown resource: %s\n", name);
|
||||
}
|
||||
|
||||
static void cmd_help(void) {
|
||||
printf("commands:\n");
|
||||
printf(" status print all resources with current/wanted state and status\n");
|
||||
printf(" want <name> <state> set wanted state (by number or name, case-insensitive)\n");
|
||||
printf(" tick run one reconciler tick\n");
|
||||
printf(" run tick until stable (max 20 ticks)\n");
|
||||
printf(" fail <name> make the next action for this resource fail\n");
|
||||
printf(" help show this help\n");
|
||||
printf(" quit / exit exit\n");
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
* Main
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
int main(void) {
|
||||
struct Reconciler *r = reconciler_create();
|
||||
reconciler_set_log(r, on_log, NULL);
|
||||
|
||||
/* Device resource. */
|
||||
static const struct Rec_Transition device_trans[] = {
|
||||
{0, 1, device_open},
|
||||
{1, 0, device_close},
|
||||
{1, 2, device_start},
|
||||
{2, 1, device_stop},
|
||||
{-1, -1, NULL}
|
||||
};
|
||||
static const char *device_states[] = {"CLOSED", "OPEN", "STREAMING"};
|
||||
|
||||
/* Transport resource. */
|
||||
static const struct Rec_Transition transport_trans[] = {
|
||||
{0, 1, transport_connect},
|
||||
{1, 0, transport_disconnect},
|
||||
{-1, -1, NULL}
|
||||
};
|
||||
static const char *transport_states[] = {"DISCONNECTED", "CONNECTED"};
|
||||
|
||||
/* Stream resource. */
|
||||
static const struct Rec_Transition stream_trans[] = {
|
||||
{0, 1, stream_activate},
|
||||
{1, 0, stream_deactivate},
|
||||
{-1, -1, NULL}
|
||||
};
|
||||
static const char *stream_states[] = {"INACTIVE", "ACTIVE"};
|
||||
|
||||
/* Sim userdata — indexed to match resource order. */
|
||||
static struct Sim_State sim_states[] = {
|
||||
{"device", 0},
|
||||
{"transport", 0},
|
||||
{"stream", 0},
|
||||
};
|
||||
|
||||
struct Rec_Resource *device = reconciler_add_resource(r,
|
||||
"device", device_trans, 3, device_states, 0, &sim_states[0]);
|
||||
|
||||
struct Rec_Resource *transport = reconciler_add_resource(r,
|
||||
"transport", transport_trans, 2, transport_states, 0, &sim_states[1]);
|
||||
|
||||
struct Rec_Resource *stream = reconciler_add_resource(r,
|
||||
"stream", stream_trans, 2, stream_states, 0, &sim_states[2]);
|
||||
|
||||
/* Dependencies. */
|
||||
/* transport cannot reach CONNECTED(1) unless device >= OPEN(1). */
|
||||
reconciler_add_dep(transport, 1, device, 1);
|
||||
cli_add_dep(transport, 1, device, 1);
|
||||
|
||||
/* stream cannot reach ACTIVE(1) unless transport >= CONNECTED(1). */
|
||||
reconciler_add_dep(stream, 1, transport, 1);
|
||||
cli_add_dep(stream, 1, transport, 1);
|
||||
|
||||
/* stream cannot reach ACTIVE(1) unless device >= STREAMING(2). */
|
||||
reconciler_add_dep(stream, 1, device, 2);
|
||||
cli_add_dep(stream, 1, device, 2);
|
||||
|
||||
/* Register resources for lookup. */
|
||||
all_resources[resource_count++] = device;
|
||||
all_resources[resource_count++] = transport;
|
||||
all_resources[resource_count++] = stream;
|
||||
|
||||
/* Welcome. */
|
||||
printf("reconciler_cli — interactive declarative state machine demo\n\n");
|
||||
cmd_help();
|
||||
printf("\n");
|
||||
cmd_status();
|
||||
printf("\n");
|
||||
|
||||
/* REPL. */
|
||||
char line[256];
|
||||
while (1) {
|
||||
printf("> ");
|
||||
fflush(stdout);
|
||||
|
||||
if (fgets(line, sizeof(line), stdin) == NULL) {
|
||||
break;
|
||||
}
|
||||
|
||||
/* Strip trailing newline. */
|
||||
size_t len = strlen(line);
|
||||
while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) {
|
||||
line[--len] = '\0';
|
||||
}
|
||||
|
||||
/* Tokenise. */
|
||||
char *tokens[4];
|
||||
int ntok = 0;
|
||||
char *p = line;
|
||||
|
||||
while (*p != '\0' && ntok < 4) {
|
||||
while (*p == ' ' || *p == '\t') {
|
||||
p++;
|
||||
}
|
||||
if (*p == '\0') {
|
||||
break;
|
||||
}
|
||||
tokens[ntok++] = p;
|
||||
while (*p != '\0' && *p != ' ' && *p != '\t') {
|
||||
p++;
|
||||
}
|
||||
if (*p != '\0') {
|
||||
*p++ = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
if (ntok == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const char *cmd = tokens[0];
|
||||
|
||||
if (strcmp(cmd, "quit") == 0 || strcmp(cmd, "exit") == 0) {
|
||||
break;
|
||||
} else if (strcmp(cmd, "help") == 0) {
|
||||
cmd_help();
|
||||
} else if (strcmp(cmd, "status") == 0) {
|
||||
cmd_status();
|
||||
} else if (strcmp(cmd, "tick") == 0) {
|
||||
cmd_tick(r);
|
||||
} else if (strcmp(cmd, "run") == 0) {
|
||||
cmd_run(r);
|
||||
} else if (strcmp(cmd, "want") == 0) {
|
||||
if (ntok < 3) {
|
||||
printf("usage: want <name> <state>\n");
|
||||
} else {
|
||||
cmd_want(tokens[1], tokens[2]);
|
||||
}
|
||||
} else if (strcmp(cmd, "fail") == 0) {
|
||||
if (ntok < 2) {
|
||||
printf("usage: fail <name>\n");
|
||||
} else {
|
||||
cmd_fail(tokens[1], sim_states, 3);
|
||||
}
|
||||
} else {
|
||||
printf("unknown command: %s (type 'help' for commands)\n", cmd);
|
||||
}
|
||||
}
|
||||
|
||||
reconciler_destroy(r);
|
||||
return 0;
|
||||
}
|
||||
336
dev/cli/stream_recv_cli.c
Normal file
336
dev/cli/stream_recv_cli.c
Normal file
@@ -0,0 +1,336 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <pthread.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "xorg.h"
|
||||
#include "transport.h"
|
||||
#include "protocol.h"
|
||||
#include "stream_stats.h"
|
||||
#include "error.h"
|
||||
|
||||
#define DEFAULT_PORT 7700
|
||||
#define DEFAULT_WIN_W 1280
|
||||
#define DEFAULT_WIN_H 720
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Frame slot — single-frame handoff from transport thread to GL thread */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
typedef struct {
|
||||
pthread_mutex_t mutex;
|
||||
pthread_cond_t cond;
|
||||
uint8_t *payload; /* transport-malloc'd; we own */
|
||||
const uint8_t *data; /* points into payload */
|
||||
uint32_t data_len;
|
||||
uint16_t stream_id;
|
||||
int ready; /* 1 = new frame available */
|
||||
int done; /* 1 = sender disconnected */
|
||||
} Frame_Slot;
|
||||
|
||||
static void frame_slot_init(Frame_Slot *s)
|
||||
{
|
||||
pthread_mutex_init(&s->mutex, NULL);
|
||||
pthread_cond_init(&s->cond, NULL);
|
||||
s->payload = NULL;
|
||||
s->data = NULL;
|
||||
s->data_len = 0;
|
||||
s->ready = 0;
|
||||
s->done = 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Receiver state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
typedef struct {
|
||||
Frame_Slot *slot;
|
||||
int stream_id_filter; /* 0 = accept any */
|
||||
pthread_mutex_t conn_mutex;
|
||||
struct Transport_Conn *conn; /* current sender connection */
|
||||
} Recv_State;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transport callbacks */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void on_connect(struct Transport_Conn *conn, void *userdata)
|
||||
{
|
||||
Recv_State *rs = userdata;
|
||||
fprintf(stderr, "stream_recv_cli: sender connected\n");
|
||||
pthread_mutex_lock(&rs->conn_mutex);
|
||||
rs->conn = conn;
|
||||
pthread_mutex_unlock(&rs->conn_mutex);
|
||||
}
|
||||
|
||||
static void on_disconnect(struct Transport_Conn *conn, void *userdata)
|
||||
{
|
||||
(void)conn;
|
||||
Recv_State *rs = userdata;
|
||||
fprintf(stderr, "stream_recv_cli: sender disconnected\n");
|
||||
|
||||
pthread_mutex_lock(&rs->conn_mutex);
|
||||
rs->conn = NULL;
|
||||
pthread_mutex_unlock(&rs->conn_mutex);
|
||||
|
||||
Frame_Slot *slot = rs->slot;
|
||||
pthread_mutex_lock(&slot->mutex);
|
||||
slot->done = 1;
|
||||
pthread_cond_signal(&slot->cond);
|
||||
pthread_mutex_unlock(&slot->mutex);
|
||||
}
|
||||
|
||||
static void on_frame(struct Transport_Conn *conn,
|
||||
struct Transport_Frame *frame,
|
||||
void *userdata)
|
||||
{
|
||||
Recv_State *rs = userdata;
|
||||
|
||||
if (frame->message_type == PROTO_MSG_VIDEO_FRAME) {
|
||||
struct Proto_Video_Frame vf;
|
||||
struct App_Error err = proto_read_video_frame(
|
||||
frame->payload, frame->payload_length, &vf);
|
||||
if (!APP_IS_OK(err)) {
|
||||
free(frame->payload);
|
||||
return;
|
||||
}
|
||||
if (rs->stream_id_filter && vf.stream_id != (uint16_t)rs->stream_id_filter) {
|
||||
free(frame->payload);
|
||||
return;
|
||||
}
|
||||
|
||||
Frame_Slot *slot = rs->slot;
|
||||
pthread_mutex_lock(&slot->mutex);
|
||||
if (slot->ready) {
|
||||
free(slot->payload); /* drop stale frame — main thread is behind */
|
||||
}
|
||||
slot->payload = frame->payload; /* take ownership */
|
||||
slot->data = vf.data;
|
||||
slot->data_len = vf.data_len;
|
||||
slot->stream_id = vf.stream_id;
|
||||
slot->ready = 1;
|
||||
pthread_cond_signal(&slot->cond);
|
||||
pthread_mutex_unlock(&slot->mutex);
|
||||
/* frame->payload is now owned by slot; do not free here */
|
||||
|
||||
} else if (frame->message_type == PROTO_MSG_CONTROL_REQUEST) {
|
||||
struct Proto_Request_Header hdr;
|
||||
struct App_Error err = proto_read_request_header(
|
||||
frame->payload, frame->payload_length, &hdr);
|
||||
if (APP_IS_OK(err) && hdr.command == PROTO_CMD_STREAM_OPEN) {
|
||||
struct Proto_Stream_Open so;
|
||||
err = proto_read_stream_open(
|
||||
frame->payload, frame->payload_length, &so);
|
||||
if (APP_IS_OK(err)) {
|
||||
fprintf(stderr,
|
||||
"stream_recv_cli: STREAM_OPEN stream_id=%u format=%u origin=%u\n",
|
||||
so.stream_id, so.format, so.origin);
|
||||
proto_write_control_response(conn, hdr.request_id,
|
||||
PROTO_STATUS_OK, NULL, 0);
|
||||
}
|
||||
}
|
||||
free(frame->payload);
|
||||
|
||||
} else {
|
||||
free(frame->payload);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Usage */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void usage(void)
|
||||
{
|
||||
fprintf(stderr,
|
||||
"usage: stream_recv_cli [--port PORT] [--stream-id N]\n"
|
||||
" [--scale stretch|fit|fill|1:1]\n"
|
||||
" [--anchor center|topleft]\n"
|
||||
" [--x N] [--y N]\n"
|
||||
"\n"
|
||||
"Listens for an incoming TCP stream and displays VIDEO_FRAME messages.\n"
|
||||
"Accepts MJPEG streams. Shows per-stream fps and Mbps as an overlay.\n"
|
||||
"\n"
|
||||
"defaults: port=7700 stream-id=0 (any) fit center x=0 y=0\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
uint16_t port = DEFAULT_PORT;
|
||||
int stream_id_filter = 0;
|
||||
int win_x = 0;
|
||||
int win_y = 0;
|
||||
Xorg_Scale scale = XORG_SCALE_FIT;
|
||||
Xorg_Anchor anchor = XORG_ANCHOR_CENTER;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--port") == 0 && i + 1 < argc) {
|
||||
port = (uint16_t)atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--stream-id") == 0 && i + 1 < argc) {
|
||||
stream_id_filter = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--x") == 0 && i + 1 < argc) {
|
||||
win_x = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--y") == 0 && i + 1 < argc) {
|
||||
win_y = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--scale") == 0 && i + 1 < argc) {
|
||||
i++;
|
||||
if (strcmp(argv[i], "stretch") == 0) { scale = XORG_SCALE_STRETCH; }
|
||||
else if (strcmp(argv[i], "fit") == 0) { scale = XORG_SCALE_FIT; }
|
||||
else if (strcmp(argv[i], "fill") == 0) { scale = XORG_SCALE_FILL; }
|
||||
else if (strcmp(argv[i], "1:1") == 0) { scale = XORG_SCALE_1_1; }
|
||||
else { fprintf(stderr, "unknown scale: %s\n", argv[i]); usage(); return 1; }
|
||||
} else if (strcmp(argv[i], "--anchor") == 0 && i + 1 < argc) {
|
||||
i++;
|
||||
if (strcmp(argv[i], "center") == 0) { anchor = XORG_ANCHOR_CENTER; }
|
||||
else if (strcmp(argv[i], "topleft") == 0) { anchor = XORG_ANCHOR_TOP_LEFT; }
|
||||
else { fprintf(stderr, "unknown anchor: %s\n", argv[i]); usage(); return 1; }
|
||||
} else {
|
||||
usage(); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!xorg_available()) {
|
||||
fprintf(stderr, "stream_recv_cli: built without HAVE_GLFW — viewer not available\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Frame slot and receiver state */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
Frame_Slot slot;
|
||||
frame_slot_init(&slot);
|
||||
|
||||
Recv_State rs = {0};
|
||||
rs.slot = &slot;
|
||||
rs.stream_id_filter = stream_id_filter;
|
||||
pthread_mutex_init(&rs.conn_mutex, NULL);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Transport server */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
struct Transport_Server_Config cfg = {
|
||||
.port = port,
|
||||
.max_connections = 1,
|
||||
.max_payload = TRANSPORT_DEFAULT_MAX_PAYLOAD,
|
||||
.on_frame = on_frame,
|
||||
.on_connect = on_connect,
|
||||
.on_disconnect = on_disconnect,
|
||||
.userdata = &rs,
|
||||
};
|
||||
|
||||
struct Transport_Server *server = NULL;
|
||||
struct App_Error err = transport_server_create(&server, &cfg);
|
||||
if (!APP_IS_OK(err)) { app_error_print(&err); return 1; }
|
||||
|
||||
err = transport_server_start(server);
|
||||
if (!APP_IS_OK(err)) {
|
||||
app_error_print(&err);
|
||||
transport_server_destroy(server);
|
||||
return 1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "stream_recv_cli: listening on port %u\n", port);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Open viewer */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
Xorg_Viewer *v = xorg_viewer_open(win_x, win_y,
|
||||
DEFAULT_WIN_W, DEFAULT_WIN_H,
|
||||
"stream_recv_cli");
|
||||
if (!v) {
|
||||
fprintf(stderr, "stream_recv_cli: failed to open viewer window\n");
|
||||
transport_server_destroy(server);
|
||||
return 1;
|
||||
}
|
||||
xorg_viewer_set_scale(v, scale);
|
||||
xorg_viewer_set_anchor(v, anchor);
|
||||
xorg_viewer_set_overlay_text(v, 0, 10, 10,
|
||||
"waiting for stream...", 1.0f, 1.0f, 0.8f);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Render loop */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
Stream_Stats stats;
|
||||
stream_stats_init(&stats, (uint16_t)stream_id_filter);
|
||||
|
||||
while (xorg_viewer_handle_events(v)) {
|
||||
/*
|
||||
* Wait for the next frame with a 100ms deadline so we can poll
|
||||
* window events even when no frames arrive.
|
||||
*/
|
||||
struct timespec deadline;
|
||||
clock_gettime(CLOCK_REALTIME, &deadline);
|
||||
deadline.tv_nsec += 100000000LL;
|
||||
if (deadline.tv_nsec >= 1000000000LL) {
|
||||
deadline.tv_sec++;
|
||||
deadline.tv_nsec -= 1000000000LL;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&slot.mutex);
|
||||
while (!slot.ready && !slot.done) {
|
||||
int rc = pthread_cond_timedwait(&slot.cond, &slot.mutex, &deadline);
|
||||
if (rc != 0) { break; } /* timeout — poll events */
|
||||
}
|
||||
|
||||
if (!slot.ready) {
|
||||
int done = slot.done;
|
||||
pthread_mutex_unlock(&slot.mutex);
|
||||
if (done) { break; }
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Take ownership of the frame. */
|
||||
uint8_t *payload = slot.payload;
|
||||
const uint8_t *data = slot.data;
|
||||
uint32_t data_len = slot.data_len;
|
||||
uint16_t stream_id = slot.stream_id;
|
||||
slot.payload = NULL;
|
||||
slot.data = NULL;
|
||||
slot.ready = 0;
|
||||
pthread_mutex_unlock(&slot.mutex);
|
||||
|
||||
xorg_viewer_push_mjpeg(v, data, data_len);
|
||||
stream_stats_record_frame(&stats, data_len);
|
||||
free(payload);
|
||||
|
||||
if (stream_stats_tick(&stats)) {
|
||||
char info[64];
|
||||
snprintf(info, sizeof(info), "stream %u %.1f fps %.2f Mbps",
|
||||
stream_id, stats.fps, stats.mbps);
|
||||
xorg_viewer_set_overlay_text(v, 0, 10, 10, info, 1.0f, 1.0f, 0.8f);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Cleanup */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/* Close the active connection if still open. */
|
||||
pthread_mutex_lock(&rs.conn_mutex);
|
||||
if (rs.conn) {
|
||||
transport_conn_close(rs.conn);
|
||||
rs.conn = NULL;
|
||||
}
|
||||
pthread_mutex_unlock(&rs.conn_mutex);
|
||||
|
||||
xorg_viewer_close(v);
|
||||
transport_server_destroy(server);
|
||||
|
||||
pthread_mutex_lock(&slot.mutex);
|
||||
free(slot.payload);
|
||||
pthread_mutex_unlock(&slot.mutex);
|
||||
pthread_mutex_destroy(&slot.mutex);
|
||||
pthread_cond_destroy(&slot.cond);
|
||||
pthread_mutex_destroy(&rs.conn_mutex);
|
||||
|
||||
return 0;
|
||||
}
|
||||
284
dev/cli/stream_send_cli.c
Normal file
284
dev/cli/stream_send_cli.c
Normal file
@@ -0,0 +1,284 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/select.h>
|
||||
#include <linux/videodev2.h>
|
||||
|
||||
#include "v4l2_fmt.h"
|
||||
#include "transport.h"
|
||||
#include "protocol.h"
|
||||
#include "stream_stats.h"
|
||||
#include "error.h"
|
||||
|
||||
#define N_BUFS 4
|
||||
#define DEFAULT_PORT 7700
|
||||
#define DEFAULT_STREAM_ID 1
|
||||
|
||||
typedef struct { void *start; size_t length; } Mmap_Buf;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transport callbacks */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void on_frame(struct Transport_Conn *conn,
|
||||
struct Transport_Frame *frame,
|
||||
void *userdata)
|
||||
{
|
||||
(void)conn; (void)userdata;
|
||||
/* Receiver may send responses; just discard them. */
|
||||
free(frame->payload);
|
||||
}
|
||||
|
||||
static void on_disconnect(struct Transport_Conn *conn, void *userdata)
|
||||
{
|
||||
(void)conn; (void)userdata;
|
||||
fprintf(stderr, "stream_send_cli: disconnected from receiver\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Usage */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void usage(void)
|
||||
{
|
||||
fprintf(stderr,
|
||||
"usage: stream_send_cli [--device PATH] [--host HOST] [--port PORT]\n"
|
||||
" [--stream-id N]\n"
|
||||
"\n"
|
||||
"Captures MJPEG from a V4L2 device and streams VIDEO_FRAME messages over TCP.\n"
|
||||
"Prints frame rate and throughput to stderr every 0.5 s.\n"
|
||||
"\n"
|
||||
"defaults: /dev/video0 127.0.0.1 7700 stream-id=1\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
const char *device = "/dev/video0";
|
||||
const char *host = "127.0.0.1";
|
||||
uint16_t port = DEFAULT_PORT;
|
||||
uint16_t stream_id = DEFAULT_STREAM_ID;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--device") == 0 && i + 1 < argc) {
|
||||
device = argv[++i];
|
||||
} else if (strcmp(argv[i], "--host") == 0 && i + 1 < argc) {
|
||||
host = argv[++i];
|
||||
} else if (strcmp(argv[i], "--port") == 0 && i + 1 < argc) {
|
||||
port = (uint16_t)atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--stream-id") == 0 && i + 1 < argc) {
|
||||
stream_id = (uint16_t)atoi(argv[++i]);
|
||||
} else {
|
||||
usage(); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Open V4L2 device */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
int fd = open(device, O_RDWR | O_NONBLOCK);
|
||||
if (fd < 0) { perror(device); return 1; }
|
||||
|
||||
struct v4l2_capability cap = {0};
|
||||
if (v4l2_xioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) {
|
||||
perror("VIDIOC_QUERYCAP"); close(fd); return 1;
|
||||
}
|
||||
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) {
|
||||
fprintf(stderr, "%s: not a capture device\n", device); close(fd); return 1;
|
||||
}
|
||||
if (!(cap.capabilities & V4L2_CAP_STREAMING)) {
|
||||
fprintf(stderr, "%s: does not support streaming\n", device); close(fd); return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Format selection — MJPEG, best FPS then largest size */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
V4l2_Fmt_Option opts[V4L2_FMT_MAX_OPTS];
|
||||
int n = v4l2_enumerate_formats(fd, opts, V4L2_FMT_MAX_OPTS, V4L2_PIX_FMT_MJPEG);
|
||||
if (n == 0) {
|
||||
fprintf(stderr, "%s: no MJPEG formats found\n", device);
|
||||
close(fd); return 1;
|
||||
}
|
||||
const V4l2_Fmt_Option *best = v4l2_select_best(opts, n);
|
||||
|
||||
struct v4l2_format fmt = {0};
|
||||
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
fmt.fmt.pix.pixelformat = best->pixfmt;
|
||||
fmt.fmt.pix.width = (uint32_t)best->w;
|
||||
fmt.fmt.pix.height = (uint32_t)best->h;
|
||||
fmt.fmt.pix.field = V4L2_FIELD_ANY;
|
||||
if (v4l2_xioctl(fd, VIDIOC_S_FMT, &fmt) < 0) {
|
||||
perror("VIDIOC_S_FMT"); close(fd); return 1;
|
||||
}
|
||||
|
||||
int width = (int)fmt.fmt.pix.width;
|
||||
int height = (int)fmt.fmt.pix.height;
|
||||
int fps_n = best->fps_n;
|
||||
int fps_d = best->fps_d;
|
||||
|
||||
{
|
||||
struct v4l2_streamparm parm = {0};
|
||||
parm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
parm.parm.capture.timeperframe.numerator = (uint32_t)fps_d;
|
||||
parm.parm.capture.timeperframe.denominator = (uint32_t)fps_n;
|
||||
v4l2_xioctl(fd, VIDIOC_S_PARM, &parm);
|
||||
if (v4l2_xioctl(fd, VIDIOC_G_PARM, &parm) == 0 &&
|
||||
parm.parm.capture.timeperframe.denominator > 0) {
|
||||
fps_n = (int)parm.parm.capture.timeperframe.denominator;
|
||||
fps_d = (int)parm.parm.capture.timeperframe.numerator;
|
||||
}
|
||||
}
|
||||
|
||||
fprintf(stderr, "device: %s (%s)\n", device, (char *)cap.card);
|
||||
fprintf(stderr, "format: MJPEG %dx%d target=%.1f fps\n",
|
||||
width, height,
|
||||
fps_d > 0 ? (double)fps_n / fps_d : 0.0);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Mmap buffers + stream on */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
struct v4l2_requestbuffers req = {0};
|
||||
req.count = N_BUFS;
|
||||
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
req.memory = V4L2_MEMORY_MMAP;
|
||||
if (v4l2_xioctl(fd, VIDIOC_REQBUFS, &req) < 0) {
|
||||
perror("VIDIOC_REQBUFS"); close(fd); return 1;
|
||||
}
|
||||
|
||||
Mmap_Buf bufs[N_BUFS] = {0};
|
||||
for (unsigned i = 0; i < req.count; i++) {
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
buf.index = i;
|
||||
if (v4l2_xioctl(fd, VIDIOC_QUERYBUF, &buf) < 0) {
|
||||
perror("VIDIOC_QUERYBUF"); close(fd); return 1;
|
||||
}
|
||||
bufs[i].length = buf.length;
|
||||
bufs[i].start = mmap(NULL, buf.length, PROT_READ | PROT_WRITE,
|
||||
MAP_SHARED, fd, buf.m.offset);
|
||||
if (bufs[i].start == MAP_FAILED) {
|
||||
perror("mmap"); close(fd); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (unsigned i = 0; i < req.count; i++) {
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
buf.index = i;
|
||||
if (v4l2_xioctl(fd, VIDIOC_QBUF, &buf) < 0) {
|
||||
perror("VIDIOC_QBUF"); close(fd); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
enum v4l2_buf_type stream_type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
if (v4l2_xioctl(fd, VIDIOC_STREAMON, &stream_type) < 0) {
|
||||
perror("VIDIOC_STREAMON"); close(fd); return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Connect to receiver */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
struct Transport_Conn *conn = NULL;
|
||||
struct App_Error err = transport_connect(&conn, host, port,
|
||||
TRANSPORT_DEFAULT_MAX_PAYLOAD,
|
||||
on_frame, on_disconnect, NULL);
|
||||
if (!APP_IS_OK(err)) {
|
||||
app_error_print(&err);
|
||||
v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type);
|
||||
close(fd); return 1;
|
||||
}
|
||||
|
||||
fprintf(stderr, "connected to %s:%u stream_id=%u\n", host, port, stream_id);
|
||||
|
||||
err = proto_write_stream_open(conn, 1 /* request_id */, stream_id,
|
||||
PROTO_FORMAT_MJPEG,
|
||||
0 /* pixel_format: compressed */,
|
||||
PROTO_ORIGIN_DEVICE_NATIVE);
|
||||
if (!APP_IS_OK(err)) {
|
||||
app_error_print(&err);
|
||||
transport_conn_close(conn);
|
||||
v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type);
|
||||
close(fd); return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Capture + send loop */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
Stream_Stats stats;
|
||||
stream_stats_init(&stats, stream_id);
|
||||
|
||||
while (1) {
|
||||
fd_set fds;
|
||||
FD_ZERO(&fds);
|
||||
FD_SET(fd, &fds);
|
||||
struct timeval tv = {1, 0};
|
||||
int r = select(fd + 1, &fds, NULL, NULL, &tv);
|
||||
|
||||
if (r < 0) {
|
||||
if (errno == EINTR) { continue; }
|
||||
perror("select"); break;
|
||||
}
|
||||
if (r == 0) {
|
||||
fprintf(stderr, "stream_send_cli: select timeout — no frames\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
if (v4l2_xioctl(fd, VIDIOC_DQBUF, &buf) < 0) {
|
||||
if (errno == EAGAIN) { continue; }
|
||||
perror("VIDIOC_DQBUF"); break;
|
||||
}
|
||||
|
||||
const uint8_t *data = (const uint8_t *)bufs[buf.index].start;
|
||||
uint32_t nbytes = buf.bytesused;
|
||||
|
||||
err = proto_write_video_frame(conn, stream_id, data, nbytes);
|
||||
|
||||
if (v4l2_xioctl(fd, VIDIOC_QBUF, &buf) < 0) {
|
||||
perror("VIDIOC_QBUF");
|
||||
if (!APP_IS_OK(err)) { app_error_print(&err); }
|
||||
break;
|
||||
}
|
||||
|
||||
if (!APP_IS_OK(err)) {
|
||||
app_error_print(&err);
|
||||
break;
|
||||
}
|
||||
|
||||
stream_stats_record_frame(&stats, nbytes);
|
||||
if (stream_stats_tick(&stats)) {
|
||||
fprintf(stderr, "stream %u %.1f fps %.2f Mbps\n",
|
||||
stream_id, stats.fps, stats.mbps);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Cleanup */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
transport_conn_close(conn);
|
||||
v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type);
|
||||
for (unsigned i = 0; i < req.count; i++) {
|
||||
if (bufs[i].start && bufs[i].start != MAP_FAILED) {
|
||||
munmap(bufs[i].start, bufs[i].length);
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
147
dev/cli/test_image_cli.c
Normal file
147
dev/cli/test_image_cli.c
Normal file
@@ -0,0 +1,147 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "test_image.h"
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* BT.601 limited-range YCbCr → RGB (for PPM output only) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void ycbcr_to_rgb(uint8_t y, uint8_t cb, uint8_t cr,
|
||||
uint8_t *r, uint8_t *g, uint8_t *b)
|
||||
{
|
||||
int yy = (int)y - 16;
|
||||
int cb_ = (int)cb - 128;
|
||||
int cr_ = (int)cr - 128;
|
||||
int rv = (298 * yy + 409 * cr_ + 128) >> 8;
|
||||
int gv = (298 * yy - 100 * cb_ - 208 * cr_ + 128) >> 8;
|
||||
int bv = (298 * yy + 516 * cb_ + 128) >> 8;
|
||||
*r = (uint8_t)(rv < 0 ? 0 : rv > 255 ? 255 : rv);
|
||||
*g = (uint8_t)(gv < 0 ? 0 : gv > 255 ? 255 : gv);
|
||||
*b = (uint8_t)(bv < 0 ? 0 : bv > 255 ? 255 : bv);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PPM output */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static int write_ppm(const char *path, Test_Frame *f)
|
||||
{
|
||||
FILE *fp = fopen(path, "wb");
|
||||
if (!fp) { perror(path); return -1; }
|
||||
|
||||
fprintf(fp, "P6\n%d %d\n255\n", f->width, f->height);
|
||||
|
||||
for (int row = 0; row < f->height; row++) {
|
||||
for (int col = 0; col < f->width; col++) {
|
||||
uint8_t r, g, b;
|
||||
|
||||
if (f->fmt == TEST_FMT_BGRA) {
|
||||
const uint8_t *p = f->plane[0] + row * f->stride[0] + col * 4;
|
||||
b = p[0]; g = p[1]; r = p[2];
|
||||
} else {
|
||||
uint8_t luma = f->plane[0][row * f->stride[0] + col];
|
||||
uint8_t cb, cr;
|
||||
if (f->fmt == TEST_FMT_YUV420) {
|
||||
cb = f->plane[1][(row / 2) * f->stride[1] + col / 2];
|
||||
cr = f->plane[2][(row / 2) * f->stride[2] + col / 2];
|
||||
} else { /* YUV422 */
|
||||
cb = f->plane[1][row * f->stride[1] + col / 2];
|
||||
cr = f->plane[2][row * f->stride[2] + col / 2];
|
||||
}
|
||||
ycbcr_to_rgb(luma, cb, cr, &r, &g, &b);
|
||||
}
|
||||
|
||||
uint8_t pix[3] = {r, g, b};
|
||||
fwrite(pix, 1, 3, fp);
|
||||
}
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void usage(void)
|
||||
{
|
||||
fprintf(stderr,
|
||||
"usage: test_image_cli [--pattern bars|ramp|grid]\n"
|
||||
" [--width N] [--height N]\n"
|
||||
" [--format yuv420|yuv422|bgra]\n"
|
||||
" --out FILE.ppm\n"
|
||||
"\n"
|
||||
"defaults: bars 1280x720 yuv420\n");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
Test_Pattern pattern = TEST_PATTERN_BARS;
|
||||
Test_Fmt fmt = TEST_FMT_YUV420;
|
||||
int width = 1280;
|
||||
int height = 720;
|
||||
const char *out = NULL;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--pattern") == 0 && i + 1 < argc) {
|
||||
i++;
|
||||
if (strcmp(argv[i], "bars") == 0) { pattern = TEST_PATTERN_BARS; }
|
||||
else if (strcmp(argv[i], "ramp") == 0) { pattern = TEST_PATTERN_RAMP; }
|
||||
else if (strcmp(argv[i], "grid") == 0) { pattern = TEST_PATTERN_GRID; }
|
||||
else {
|
||||
fprintf(stderr, "unknown pattern: %s\n", argv[i]);
|
||||
usage(); return 1;
|
||||
}
|
||||
} else if (strcmp(argv[i], "--width") == 0 && i + 1 < argc) {
|
||||
width = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--height") == 0 && i + 1 < argc) {
|
||||
height = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--format") == 0 && i + 1 < argc) {
|
||||
i++;
|
||||
if (strcmp(argv[i], "yuv420") == 0) { fmt = TEST_FMT_YUV420; }
|
||||
else if (strcmp(argv[i], "yuv422") == 0) { fmt = TEST_FMT_YUV422; }
|
||||
else if (strcmp(argv[i], "bgra") == 0) { fmt = TEST_FMT_BGRA; }
|
||||
else {
|
||||
fprintf(stderr, "unknown format: %s\n", argv[i]);
|
||||
usage(); return 1;
|
||||
}
|
||||
} else if (strcmp(argv[i], "--out") == 0 && i + 1 < argc) {
|
||||
out = argv[++i];
|
||||
} else {
|
||||
usage(); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!out) {
|
||||
fprintf(stderr, "test_image_cli: --out FILE required\n");
|
||||
usage(); return 1;
|
||||
}
|
||||
if (width < 2 || height < 2) {
|
||||
fprintf(stderr, "test_image_cli: width and height must be >= 2\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Test_Frame *f = test_image_alloc(width, height, fmt);
|
||||
if (!f) {
|
||||
fprintf(stderr, "test_image_cli: allocation failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
test_image_generate(f, pattern);
|
||||
|
||||
int rc = write_ppm(out, f);
|
||||
test_image_free(f);
|
||||
if (rc != 0) { return 1; }
|
||||
|
||||
const char *fmt_name = fmt == TEST_FMT_YUV420 ? "yuv420"
|
||||
: fmt == TEST_FMT_YUV422 ? "yuv422"
|
||||
: "bgra";
|
||||
const char *pat_name = pattern == TEST_PATTERN_BARS ? "bars"
|
||||
: pattern == TEST_PATTERN_RAMP ? "ramp"
|
||||
: "grid";
|
||||
|
||||
printf("%s: %dx%d format=%s pattern=%s\n", out, width, height, fmt_name, pat_name);
|
||||
return 0;
|
||||
}
|
||||
394
dev/cli/v4l2_view_cli.c
Normal file
394
dev/cli/v4l2_view_cli.c
Normal file
@@ -0,0 +1,394 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <time.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/select.h>
|
||||
|
||||
#include "v4l2_fmt.h"
|
||||
#include "xorg.h"
|
||||
|
||||
#define N_BUFS 4
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* YUYV → planar YUV420 conversion */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void yuyv_to_yuv420(const uint8_t *yuyv, int stride,
|
||||
int w, int h,
|
||||
uint8_t *y_out, uint8_t *cb_out, uint8_t *cr_out)
|
||||
{
|
||||
for (int row = 0; row < h; row++) {
|
||||
const uint8_t *src = yuyv + row * stride;
|
||||
uint8_t *dst = y_out + row * w;
|
||||
for (int col = 0; col < w; col++) {
|
||||
dst[col] = src[col * 2];
|
||||
}
|
||||
}
|
||||
for (int row = 0; row < h; row += 2) {
|
||||
const uint8_t *src = yuyv + row * stride;
|
||||
int c_row = row / 2;
|
||||
for (int col = 0; col < w; col += 2) {
|
||||
cb_out[c_row * (w / 2) + col / 2] = src[col * 2 + 1];
|
||||
cr_out[c_row * (w / 2) + col / 2] = src[col * 2 + 3];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mmap buffers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
typedef struct { void *start; size_t length; } Mmap_Buf;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Usage */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void usage(void)
|
||||
{
|
||||
fprintf(stderr,
|
||||
"usage: v4l2_view_cli [--device PATH]\n"
|
||||
" [--width N --height N]\n"
|
||||
" [--format mjpeg|yuyv]\n"
|
||||
" [--scale stretch|fit|fill|1:1]\n"
|
||||
" [--anchor center|topleft]\n"
|
||||
" [--x N] [--y N]\n"
|
||||
"\n"
|
||||
"Opens a V4L2 capture device and displays the live feed.\n"
|
||||
"Without --width/--height, selects the highest-FPS mode\n"
|
||||
"and within that the largest resolution.\n"
|
||||
"Q or Escape closes the window.\n"
|
||||
"\n"
|
||||
"defaults: /dev/video0 auto fit center at 0,0\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
const char *device = "/dev/video0";
|
||||
int req_width = 0;
|
||||
int req_height = 0;
|
||||
int win_x = 0;
|
||||
int win_y = 0;
|
||||
Xorg_Scale scale = XORG_SCALE_FIT;
|
||||
Xorg_Anchor anchor = XORG_ANCHOR_CENTER;
|
||||
uint32_t fmt_filter = 0; /* 0 = auto */
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--device") == 0 && i + 1 < argc) {
|
||||
device = argv[++i];
|
||||
} else if (strcmp(argv[i], "--width") == 0 && i + 1 < argc) {
|
||||
req_width = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--height") == 0 && i + 1 < argc) {
|
||||
req_height = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--x") == 0 && i + 1 < argc) {
|
||||
win_x = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--y") == 0 && i + 1 < argc) {
|
||||
win_y = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--format") == 0 && i + 1 < argc) {
|
||||
i++;
|
||||
if (strcmp(argv[i], "mjpeg") == 0) { fmt_filter = V4L2_PIX_FMT_MJPEG; }
|
||||
else if (strcmp(argv[i], "yuyv") == 0) { fmt_filter = V4L2_PIX_FMT_YUYV; }
|
||||
else { fprintf(stderr, "unknown format: %s\n", argv[i]); usage(); return 1; }
|
||||
} else if (strcmp(argv[i], "--scale") == 0 && i + 1 < argc) {
|
||||
i++;
|
||||
if (strcmp(argv[i], "stretch") == 0) { scale = XORG_SCALE_STRETCH; }
|
||||
else if (strcmp(argv[i], "fit") == 0) { scale = XORG_SCALE_FIT; }
|
||||
else if (strcmp(argv[i], "fill") == 0) { scale = XORG_SCALE_FILL; }
|
||||
else if (strcmp(argv[i], "1:1") == 0) { scale = XORG_SCALE_1_1; }
|
||||
else { fprintf(stderr, "unknown scale: %s\n", argv[i]); usage(); return 1; }
|
||||
} else if (strcmp(argv[i], "--anchor") == 0 && i + 1 < argc) {
|
||||
i++;
|
||||
if (strcmp(argv[i], "center") == 0) { anchor = XORG_ANCHOR_CENTER; }
|
||||
else if (strcmp(argv[i], "topleft") == 0) { anchor = XORG_ANCHOR_TOP_LEFT; }
|
||||
else { fprintf(stderr, "unknown anchor: %s\n", argv[i]); usage(); return 1; }
|
||||
} else {
|
||||
usage(); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!xorg_available()) {
|
||||
fprintf(stderr, "v4l2_view_cli: built without HAVE_GLFW — viewer not available\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Open V4L2 device */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
int fd = open(device, O_RDWR | O_NONBLOCK);
|
||||
if (fd < 0) { perror(device); return 1; }
|
||||
|
||||
struct v4l2_capability cap = {0};
|
||||
if (v4l2_xioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) {
|
||||
perror("VIDIOC_QUERYCAP"); close(fd); return 1;
|
||||
}
|
||||
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) {
|
||||
fprintf(stderr, "%s: not a capture device\n", device); close(fd); return 1;
|
||||
}
|
||||
if (!(cap.capabilities & V4L2_CAP_STREAMING)) {
|
||||
fprintf(stderr, "%s: does not support streaming\n", device); close(fd); return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Format selection */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
int width, height, stride;
|
||||
int use_mjpeg;
|
||||
int sel_fps_n, sel_fps_d;
|
||||
|
||||
if (req_width > 0 && req_height > 0) {
|
||||
/*
|
||||
* Explicit size requested — skip enumeration, negotiate directly.
|
||||
* Try MJPEG first (or whatever fmt_filter says), fall back to YUYV.
|
||||
*/
|
||||
struct v4l2_format fmt = {0};
|
||||
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
fmt.fmt.pix.width = (uint32_t)req_width;
|
||||
fmt.fmt.pix.height = (uint32_t)req_height;
|
||||
fmt.fmt.pix.field = V4L2_FIELD_ANY;
|
||||
|
||||
use_mjpeg = 0;
|
||||
if (fmt_filter != V4L2_PIX_FMT_YUYV) {
|
||||
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_MJPEG;
|
||||
if (v4l2_xioctl(fd, VIDIOC_S_FMT, &fmt) == 0 &&
|
||||
fmt.fmt.pix.pixelformat == V4L2_PIX_FMT_MJPEG) {
|
||||
use_mjpeg = 1;
|
||||
}
|
||||
}
|
||||
if (!use_mjpeg) {
|
||||
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV;
|
||||
if (v4l2_xioctl(fd, VIDIOC_S_FMT, &fmt) < 0 ||
|
||||
fmt.fmt.pix.pixelformat != V4L2_PIX_FMT_YUYV) {
|
||||
fprintf(stderr, "%s: could not set %dx%d in MJPEG or YUYV\n",
|
||||
device, req_width, req_height);
|
||||
close(fd); return 1;
|
||||
}
|
||||
}
|
||||
width = (int)fmt.fmt.pix.width;
|
||||
height = (int)fmt.fmt.pix.height;
|
||||
stride = (int)fmt.fmt.pix.bytesperline;
|
||||
sel_fps_n = 0; sel_fps_d = 1; /* unknown until G_PARM below */
|
||||
} else {
|
||||
/* Enumerate all supported modes and pick the best. */
|
||||
V4l2_Fmt_Option *opts = malloc(V4L2_FMT_MAX_OPTS * sizeof(*opts));
|
||||
if (!opts) { fprintf(stderr, "out of memory\n"); close(fd); return 1; }
|
||||
|
||||
int n = v4l2_enumerate_formats(fd, opts, V4L2_FMT_MAX_OPTS, fmt_filter);
|
||||
if (n == 0) {
|
||||
fprintf(stderr, "%s: no usable formats found (MJPEG/YUYV)\n", device);
|
||||
free(opts); close(fd); return 1;
|
||||
}
|
||||
|
||||
const V4l2_Fmt_Option *best = v4l2_select_best(opts, n);
|
||||
|
||||
/* Apply the selected format. */
|
||||
struct v4l2_format fmt = {0};
|
||||
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
fmt.fmt.pix.pixelformat = best->pixfmt;
|
||||
fmt.fmt.pix.width = (uint32_t)best->w;
|
||||
fmt.fmt.pix.height = (uint32_t)best->h;
|
||||
fmt.fmt.pix.field = V4L2_FIELD_ANY;
|
||||
if (v4l2_xioctl(fd, VIDIOC_S_FMT, &fmt) < 0) {
|
||||
perror("VIDIOC_S_FMT"); free(opts); close(fd); return 1;
|
||||
}
|
||||
|
||||
use_mjpeg = (fmt.fmt.pix.pixelformat == V4L2_PIX_FMT_MJPEG);
|
||||
width = (int)fmt.fmt.pix.width;
|
||||
height = (int)fmt.fmt.pix.height;
|
||||
stride = (int)fmt.fmt.pix.bytesperline;
|
||||
sel_fps_n = best->fps_n;
|
||||
sel_fps_d = best->fps_d;
|
||||
free(opts);
|
||||
}
|
||||
|
||||
/* Request the selected frame rate (driver may ignore, but try). */
|
||||
{
|
||||
struct v4l2_streamparm parm = {0};
|
||||
parm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
parm.parm.capture.timeperframe.numerator = (uint32_t)sel_fps_d;
|
||||
parm.parm.capture.timeperframe.denominator = (uint32_t)sel_fps_n;
|
||||
v4l2_xioctl(fd, VIDIOC_S_PARM, &parm);
|
||||
/* Read back what the driver actually set. */
|
||||
if (v4l2_xioctl(fd, VIDIOC_G_PARM, &parm) == 0 &&
|
||||
parm.parm.capture.timeperframe.denominator > 0) {
|
||||
sel_fps_n = (int)parm.parm.capture.timeperframe.denominator;
|
||||
sel_fps_d = (int)parm.parm.capture.timeperframe.numerator;
|
||||
}
|
||||
}
|
||||
|
||||
printf("device: %s (%s)\n", device, (char *)cap.card);
|
||||
printf("format: %s %dx%d stride=%d target=%.1f fps\n",
|
||||
use_mjpeg ? "MJPEG" : "YUYV", width, height, stride,
|
||||
sel_fps_d > 0 ? (double)sel_fps_n / sel_fps_d : 0.0);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Mmap buffers + stream on */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
struct v4l2_requestbuffers req = {0};
|
||||
req.count = N_BUFS;
|
||||
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
req.memory = V4L2_MEMORY_MMAP;
|
||||
if (v4l2_xioctl(fd, VIDIOC_REQBUFS, &req) < 0) {
|
||||
perror("VIDIOC_REQBUFS"); close(fd); return 1;
|
||||
}
|
||||
|
||||
Mmap_Buf bufs[N_BUFS] = {0};
|
||||
for (unsigned i = 0; i < req.count; i++) {
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
buf.index = i;
|
||||
if (v4l2_xioctl(fd, VIDIOC_QUERYBUF, &buf) < 0) {
|
||||
perror("VIDIOC_QUERYBUF"); close(fd); return 1;
|
||||
}
|
||||
bufs[i].length = buf.length;
|
||||
bufs[i].start = mmap(NULL, buf.length, PROT_READ | PROT_WRITE,
|
||||
MAP_SHARED, fd, buf.m.offset);
|
||||
if (bufs[i].start == MAP_FAILED) {
|
||||
perror("mmap"); close(fd); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (unsigned i = 0; i < req.count; i++) {
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
buf.index = i;
|
||||
if (v4l2_xioctl(fd, VIDIOC_QBUF, &buf) < 0) {
|
||||
perror("VIDIOC_QBUF"); close(fd); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
enum v4l2_buf_type stream_type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
if (v4l2_xioctl(fd, VIDIOC_STREAMON, &stream_type) < 0) {
|
||||
perror("VIDIOC_STREAMON"); close(fd); return 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Open viewer */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
Xorg_Viewer *v = xorg_viewer_open(win_x, win_y, width, height, "v4l2_view_cli");
|
||||
if (!v) {
|
||||
fprintf(stderr, "v4l2_view_cli: failed to open viewer window\n");
|
||||
v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type);
|
||||
close(fd); return 1;
|
||||
}
|
||||
xorg_viewer_set_scale(v, scale);
|
||||
xorg_viewer_set_anchor(v, anchor);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* YUYV conversion buffer */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
uint8_t *yuv420_buf = NULL;
|
||||
if (!use_mjpeg) {
|
||||
yuv420_buf = malloc((size_t)(width * height * 3 / 2));
|
||||
if (!yuv420_buf) {
|
||||
fprintf(stderr, "v4l2_view_cli: out of memory\n");
|
||||
xorg_viewer_close(v);
|
||||
v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type);
|
||||
close(fd); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Capture loop */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
struct timespec t_fps;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t_fps);
|
||||
int fps_frame_count = 0;
|
||||
float displayed_fps = 0.0f;
|
||||
|
||||
/* Set initial info overlay; fps will be filled in once measured. */
|
||||
const char *fmt_name = use_mjpeg ? "MJPEG" : "YUYV";
|
||||
{
|
||||
char info[64];
|
||||
snprintf(info, sizeof(info), "%s %dx%d @ --.- fps", fmt_name, width, height);
|
||||
xorg_viewer_set_overlay_text(v, 0, 10, 10, info, 1.0f, 1.0f, 0.8f);
|
||||
}
|
||||
|
||||
while (1) {
|
||||
fd_set fds;
|
||||
FD_ZERO(&fds);
|
||||
FD_SET(fd, &fds);
|
||||
struct timeval tv = {1, 0};
|
||||
int r = select(fd + 1, &fds, NULL, NULL, &tv);
|
||||
|
||||
if (r < 0) {
|
||||
if (errno == EINTR) { continue; }
|
||||
perror("select"); break;
|
||||
}
|
||||
if (r == 0) {
|
||||
fprintf(stderr, "v4l2_view_cli: select timeout — no frames\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
if (v4l2_xioctl(fd, VIDIOC_DQBUF, &buf) < 0) {
|
||||
if (errno == EAGAIN) { continue; }
|
||||
perror("VIDIOC_DQBUF"); break;
|
||||
}
|
||||
|
||||
const uint8_t *data = bufs[buf.index].start;
|
||||
|
||||
if (use_mjpeg) {
|
||||
xorg_viewer_push_mjpeg(v, data, buf.bytesused);
|
||||
} else {
|
||||
uint8_t *y_p = yuv420_buf;
|
||||
uint8_t *cb_p = y_p + width * height;
|
||||
uint8_t *cr_p = cb_p + width * height / 4;
|
||||
yuyv_to_yuv420(data, stride, width, height, y_p, cb_p, cr_p);
|
||||
xorg_viewer_push_yuv420(v, y_p, cb_p, cr_p, width, height);
|
||||
}
|
||||
|
||||
if (v4l2_xioctl(fd, VIDIOC_QBUF, &buf) < 0) {
|
||||
perror("VIDIOC_QBUF"); break;
|
||||
}
|
||||
|
||||
/* Update FPS overlay every 0.5s. */
|
||||
fps_frame_count++;
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
double elapsed = (now.tv_sec - t_fps.tv_sec) +
|
||||
(now.tv_nsec - t_fps.tv_nsec) * 1e-9;
|
||||
if (elapsed >= 0.5) {
|
||||
displayed_fps = (float)(fps_frame_count / elapsed);
|
||||
fps_frame_count = 0;
|
||||
t_fps = now;
|
||||
char info[64];
|
||||
snprintf(info, sizeof(info), "%s %dx%d @ %.1f fps",
|
||||
fmt_name, width, height, displayed_fps);
|
||||
xorg_viewer_set_overlay_text(v, 0, 10, 10, info, 1.0f, 1.0f, 0.8f);
|
||||
}
|
||||
|
||||
if (!xorg_viewer_handle_events(v)) { break; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Cleanup */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
xorg_viewer_close(v);
|
||||
v4l2_xioctl(fd, VIDIOC_STREAMOFF, &stream_type);
|
||||
for (unsigned i = 0; i < req.count; i++) {
|
||||
if (bufs[i].start && bufs[i].start != MAP_FAILED) {
|
||||
munmap(bufs[i].start, bufs[i].length);
|
||||
}
|
||||
}
|
||||
free(yuv420_buf);
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
136
dev/cli/xorg_cli.c
Normal file
136
dev/cli/xorg_cli.c
Normal file
@@ -0,0 +1,136 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "test_image.h"
|
||||
#include "xorg.h"
|
||||
|
||||
static void usage(void)
|
||||
{
|
||||
fprintf(stderr,
|
||||
"usage: xorg_cli [--pattern bars|ramp|grid]\n"
|
||||
" [--width N] [--height N]\n"
|
||||
" [--format yuv420|bgra]\n"
|
||||
" [--scale stretch|fit|fill|1:1]\n"
|
||||
" [--anchor center|topleft]\n"
|
||||
" [--x N] [--y N]\n"
|
||||
"\n"
|
||||
"Opens a window and renders a test image using the xorg viewer sink.\n"
|
||||
"Q or Escape closes the window.\n"
|
||||
"\n"
|
||||
"defaults: bars 1280x720 yuv420 stretch center at 0,0\n");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
Test_Pattern pattern = TEST_PATTERN_BARS;
|
||||
Test_Fmt fmt = TEST_FMT_YUV420;
|
||||
Xorg_Scale scale = XORG_SCALE_STRETCH;
|
||||
Xorg_Anchor anchor = XORG_ANCHOR_CENTER;
|
||||
int width = 1280;
|
||||
int height = 720;
|
||||
int win_x = 0;
|
||||
int win_y = 0;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--pattern") == 0 && i + 1 < argc) {
|
||||
i++;
|
||||
if (strcmp(argv[i], "bars") == 0) { pattern = TEST_PATTERN_BARS; }
|
||||
else if (strcmp(argv[i], "ramp") == 0) { pattern = TEST_PATTERN_RAMP; }
|
||||
else if (strcmp(argv[i], "grid") == 0) { pattern = TEST_PATTERN_GRID; }
|
||||
else { fprintf(stderr, "unknown pattern: %s\n", argv[i]); usage(); return 1; }
|
||||
} else if (strcmp(argv[i], "--width") == 0 && i + 1 < argc) {
|
||||
width = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--height") == 0 && i + 1 < argc) {
|
||||
height = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--x") == 0 && i + 1 < argc) {
|
||||
win_x = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--y") == 0 && i + 1 < argc) {
|
||||
win_y = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--scale") == 0 && i + 1 < argc) {
|
||||
i++;
|
||||
if (strcmp(argv[i], "stretch") == 0) { scale = XORG_SCALE_STRETCH; }
|
||||
else if (strcmp(argv[i], "fit") == 0) { scale = XORG_SCALE_FIT; }
|
||||
else if (strcmp(argv[i], "fill") == 0) { scale = XORG_SCALE_FILL; }
|
||||
else if (strcmp(argv[i], "1:1") == 0) { scale = XORG_SCALE_1_1; }
|
||||
else { fprintf(stderr, "unknown scale: %s\n", argv[i]); usage(); return 1; }
|
||||
} else if (strcmp(argv[i], "--anchor") == 0 && i + 1 < argc) {
|
||||
i++;
|
||||
if (strcmp(argv[i], "center") == 0) { anchor = XORG_ANCHOR_CENTER; }
|
||||
else if (strcmp(argv[i], "topleft") == 0) { anchor = XORG_ANCHOR_TOP_LEFT; }
|
||||
else { fprintf(stderr, "unknown anchor: %s\n", argv[i]); usage(); return 1; }
|
||||
} else if (strcmp(argv[i], "--format") == 0 && i + 1 < argc) {
|
||||
i++;
|
||||
if (strcmp(argv[i], "yuv420") == 0) { fmt = TEST_FMT_YUV420; }
|
||||
else if (strcmp(argv[i], "bgra") == 0) { fmt = TEST_FMT_BGRA; }
|
||||
else { fprintf(stderr, "unknown format: %s\n", argv[i]); usage(); return 1; }
|
||||
} else {
|
||||
usage(); return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!xorg_available()) {
|
||||
fprintf(stderr, "xorg_cli: built without HAVE_GLFW — viewer not available\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (width < 2 || height < 2) {
|
||||
fprintf(stderr, "xorg_cli: width and height must be >= 2\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Test_Frame *f = test_image_alloc(width, height, fmt);
|
||||
if (!f) {
|
||||
fprintf(stderr, "xorg_cli: allocation failed\n");
|
||||
return 1;
|
||||
}
|
||||
test_image_generate(f, pattern);
|
||||
|
||||
const char *pat_name = pattern == TEST_PATTERN_BARS ? "bars"
|
||||
: pattern == TEST_PATTERN_RAMP ? "ramp"
|
||||
: "grid";
|
||||
const char *fmt_name = fmt == TEST_FMT_YUV420 ? "yuv420" : "bgra";
|
||||
const char *scale_name = scale == XORG_SCALE_STRETCH ? "stretch"
|
||||
: scale == XORG_SCALE_FIT ? "fit"
|
||||
: scale == XORG_SCALE_FILL ? "fill"
|
||||
: "1:1";
|
||||
const char *anchor_name = anchor == XORG_ANCHOR_CENTER ? "center" : "topleft";
|
||||
|
||||
printf("opening %dx%d %s %s scale=%s anchor=%s at (%d,%d)\n",
|
||||
width, height, fmt_name, pat_name, scale_name, anchor_name, win_x, win_y);
|
||||
|
||||
Xorg_Viewer *v = xorg_viewer_open(win_x, win_y, width, height, "xorg_cli");
|
||||
if (!v) {
|
||||
fprintf(stderr, "xorg_cli: failed to open viewer window\n");
|
||||
test_image_free(f);
|
||||
return 1;
|
||||
}
|
||||
|
||||
xorg_viewer_set_scale(v, scale);
|
||||
xorg_viewer_set_anchor(v, anchor);
|
||||
|
||||
/* Overlay: today's date, white text, top-left corner. */
|
||||
{
|
||||
char date_buf[32];
|
||||
time_t now = time(NULL);
|
||||
struct tm *tm = localtime(&now);
|
||||
strftime(date_buf, sizeof(date_buf), "%Y-%m-%d", tm);
|
||||
xorg_viewer_set_overlay_text(v, 0, 10, 10, date_buf, 1.0f, 1.0f, 1.0f);
|
||||
}
|
||||
|
||||
if (fmt == TEST_FMT_YUV420) {
|
||||
xorg_viewer_push_yuv420(v,
|
||||
f->plane[0], f->plane[1], f->plane[2],
|
||||
f->width, f->height);
|
||||
} else {
|
||||
xorg_viewer_push_bgra(v, f->plane[0], f->width, f->height);
|
||||
}
|
||||
|
||||
test_image_free(f);
|
||||
|
||||
while (xorg_viewer_poll(v)) { /* wait for window close */ }
|
||||
|
||||
xorg_viewer_close(v);
|
||||
return 0;
|
||||
}
|
||||
2
dev/web/.gitignore
vendored
Normal file
2
dev/web/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
9
dev/web/Makefile
Normal file
9
dev/web/Makefile
Normal file
@@ -0,0 +1,9 @@
|
||||
.PHONY: all clean
|
||||
|
||||
all: node_modules
|
||||
|
||||
node_modules: package.json
|
||||
npm install
|
||||
|
||||
clean:
|
||||
rm -rf node_modules
|
||||
90
dev/web/discovery.mjs
Normal file
90
dev/web/discovery.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* UDP multicast discovery — streaming callback API.
|
||||
*
|
||||
* Announcement wire format (matches discovery.c):
|
||||
* [u16 msg_type=0x0010][u32 payload_len] <- 6-byte frame header
|
||||
* [u8 protocol_version]
|
||||
* [u16 site_id]
|
||||
* [u16 tcp_port]
|
||||
* [u16 function_flags]
|
||||
* [u8 name_len]
|
||||
* [bytes name...]
|
||||
*/
|
||||
|
||||
import dgram from 'node:dgram';
|
||||
|
||||
const MULTICAST_GROUP = '224.0.0.251';
|
||||
const DISCOVERY_PORT = 5353;
|
||||
const ANNOUNCE_TYPE = 0x0010;
|
||||
const HEADER_SIZE = 6;
|
||||
const ANN_FIXED_SIZE = 8;
|
||||
|
||||
/*
|
||||
* Open a discovery socket, send an immediate announce to prompt replies,
|
||||
* and call on_peer for each newly-seen peer (deduplicated by addr+name).
|
||||
*
|
||||
* Returns a stop() function. Call it when done (e.g. user closed the picker
|
||||
* or selected a node). Safe to call multiple times.
|
||||
*/
|
||||
export function start_discovery(on_peer, on_error) {
|
||||
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
||||
const seen = new Set();
|
||||
let closed = false;
|
||||
|
||||
sock.on('error', err => {
|
||||
console.error('discovery socket error:', err.message);
|
||||
if (on_error) { on_error(err); }
|
||||
stop();
|
||||
});
|
||||
|
||||
sock.on('message', (msg, rinfo) => {
|
||||
if (msg.length < HEADER_SIZE) { return; }
|
||||
const msg_type = msg.readUInt16LE(0);
|
||||
const payload_len = msg.readUInt32LE(2);
|
||||
if (msg_type !== ANNOUNCE_TYPE) { return; }
|
||||
if (msg.length < HEADER_SIZE + payload_len) { return; }
|
||||
if (payload_len < ANN_FIXED_SIZE) { return; }
|
||||
|
||||
const p = msg.slice(HEADER_SIZE);
|
||||
const site_id = p.readUInt16LE(1);
|
||||
const tcp_port = p.readUInt16LE(3);
|
||||
const func_flags = p.readUInt16LE(5);
|
||||
const name_len = p.readUInt8(7);
|
||||
if (payload_len < ANN_FIXED_SIZE + name_len) { return; }
|
||||
const name = p.toString('utf8', 8, 8 + name_len);
|
||||
|
||||
const key = `${rinfo.address}:${name}`;
|
||||
if (seen.has(key)) { return; }
|
||||
seen.add(key);
|
||||
|
||||
on_peer({ addr: rinfo.address, tcp_port, site_id, function_flags: func_flags, name });
|
||||
});
|
||||
|
||||
sock.bind(DISCOVERY_PORT, () => {
|
||||
sock.addMembership(MULTICAST_GROUP);
|
||||
send_announce(sock);
|
||||
});
|
||||
|
||||
function stop() {
|
||||
if (closed) { return; }
|
||||
closed = true;
|
||||
try { sock.close(); } catch {}
|
||||
}
|
||||
|
||||
return stop;
|
||||
}
|
||||
|
||||
function send_announce(sock) {
|
||||
const name = Buffer.from('web-inspector', 'utf8');
|
||||
const payload_len = 8 + name.length;
|
||||
const buf = Buffer.allocUnsafe(6 + payload_len);
|
||||
buf.writeUInt16LE(ANNOUNCE_TYPE, 0);
|
||||
buf.writeUInt32LE(payload_len, 2);
|
||||
buf.writeUInt8(1, 6); /* protocol_version */
|
||||
buf.writeUInt16LE(0, 7); /* site_id */
|
||||
buf.writeUInt16LE(0, 9); /* tcp_port (0 = no server) */
|
||||
buf.writeUInt16LE(0, 11); /* function_flags */
|
||||
buf.writeUInt8(name.length, 13);
|
||||
name.copy(buf, 14);
|
||||
sock.send(buf, 0, buf.length, DISCOVERY_PORT, MULTICAST_GROUP);
|
||||
}
|
||||
138
dev/web/node_client.mjs
Normal file
138
dev/web/node_client.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* TCP client for video-node.
|
||||
* Handles frame reassembly, request/response correlation by request_id,
|
||||
* and reconnection state.
|
||||
*/
|
||||
|
||||
import net from 'node:net';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import {
|
||||
FRAME_HEADER_SIZE, MSG_CONTROL_RESPONSE,
|
||||
encode_enum_devices, encode_enum_controls,
|
||||
encode_get_control, encode_set_control,
|
||||
decode_enum_devices_response, decode_enum_controls_response,
|
||||
decode_get_control_response, decode_set_control_response,
|
||||
} from './protocol.mjs';
|
||||
|
||||
export class Node_Client extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this._socket = null;
|
||||
this._buf = Buffer.alloc(0);
|
||||
this._pending = new Map(); /* request_id -> { resolve, reject, decode } */
|
||||
this._next_id = 1;
|
||||
this.connected = false;
|
||||
this.host = null;
|
||||
this.port = null;
|
||||
}
|
||||
|
||||
connect(host, port) {
|
||||
if (this._socket) { this._socket.destroy(); }
|
||||
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let connected = false;
|
||||
const sock = net.createConnection({ host, port });
|
||||
|
||||
sock.once('connect', () => {
|
||||
connected = true;
|
||||
this._socket = sock;
|
||||
this.connected = true;
|
||||
this._buf = Buffer.alloc(0);
|
||||
this.emit('connect');
|
||||
resolve();
|
||||
});
|
||||
|
||||
/* Single persistent error handler — errors after connect are
|
||||
* non-fatal here; the 'close' event fires next and handles cleanup. */
|
||||
sock.on('error', err => {
|
||||
if (!connected) { reject(err); }
|
||||
else { console.error('video-node socket error:', err.message); }
|
||||
});
|
||||
|
||||
sock.on('data', chunk => this._on_data(chunk));
|
||||
|
||||
sock.on('close', () => {
|
||||
this.connected = false;
|
||||
this._socket = null;
|
||||
for (const { reject: rej } of this._pending.values()) {
|
||||
rej(new Error('disconnected'));
|
||||
}
|
||||
this._pending.clear();
|
||||
this.emit('disconnect');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this._socket) { this._socket.destroy(); }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
_on_data(chunk) {
|
||||
this._buf = Buffer.concat([this._buf, chunk]);
|
||||
while (true) {
|
||||
if (this._buf.length < FRAME_HEADER_SIZE) { break; }
|
||||
const msg_type = this._buf.readUInt16LE(0);
|
||||
const payload_len = this._buf.readUInt32LE(2);
|
||||
if (this._buf.length < FRAME_HEADER_SIZE + payload_len) { break; }
|
||||
const payload = this._buf.slice(FRAME_HEADER_SIZE, FRAME_HEADER_SIZE + payload_len);
|
||||
this._buf = this._buf.slice(FRAME_HEADER_SIZE + payload_len);
|
||||
this._on_frame(msg_type, payload);
|
||||
}
|
||||
}
|
||||
|
||||
_on_frame(msg_type, payload) {
|
||||
if (msg_type !== MSG_CONTROL_RESPONSE) { return; }
|
||||
if (payload.length < 4) { return; }
|
||||
const request_id = payload.readUInt16LE(0);
|
||||
const entry = this._pending.get(request_id);
|
||||
if (!entry) { return; }
|
||||
this._pending.delete(request_id);
|
||||
try {
|
||||
entry.resolve(entry.decode(payload));
|
||||
} catch (err) {
|
||||
entry.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
_request(frame, decode_fn) {
|
||||
if (!this.connected) { return Promise.reject(new Error('not connected')); }
|
||||
const id = this._alloc_id();
|
||||
/* patch request_id into the frame payload (bytes 6-7) */
|
||||
frame.writeUInt16LE(id, FRAME_HEADER_SIZE);
|
||||
return new Promise((resolve, reject) => {
|
||||
this._pending.set(id, { resolve, reject, decode: decode_fn });
|
||||
this._socket.write(frame, err => {
|
||||
if (err) { this._pending.delete(id); reject(err); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_alloc_id() {
|
||||
const id = this._next_id;
|
||||
this._next_id = (this._next_id % 0xFFFF) + 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
enum_devices() {
|
||||
return this._request(encode_enum_devices(0), decode_enum_devices_response);
|
||||
}
|
||||
|
||||
enum_controls(device_index) {
|
||||
return this._request(encode_enum_controls(0, device_index), decode_enum_controls_response);
|
||||
}
|
||||
|
||||
get_control(device_index, control_id) {
|
||||
return this._request(encode_get_control(0, device_index, control_id), decode_get_control_response);
|
||||
}
|
||||
|
||||
set_control(device_index, control_id, value) {
|
||||
return this._request(encode_set_control(0, device_index, control_id, value), decode_set_control_response);
|
||||
}
|
||||
}
|
||||
8
dev/web/package.json
Normal file
8
dev/web/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "video-web",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1"
|
||||
}
|
||||
}
|
||||
196
dev/web/protocol.mjs
Normal file
196
dev/web/protocol.mjs
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Binary protocol encoder/decoder for video-node.
|
||||
*
|
||||
* Frame layout (TCP):
|
||||
* [u16 message_type][u32 payload_length][payload...]
|
||||
*
|
||||
* CONTROL_REQUEST payload:
|
||||
* [u16 request_id][u16 command][command-specific...]
|
||||
*
|
||||
* CONTROL_RESPONSE payload:
|
||||
* [u16 request_id][u16 status][command-specific...]
|
||||
*
|
||||
* str8 encoding: [u8 length][bytes...] — not NUL-terminated on wire.
|
||||
*/
|
||||
|
||||
export const FRAME_HEADER_SIZE = 6;
|
||||
export const MSG_CONTROL_REQUEST = 0x0002;
|
||||
export const MSG_CONTROL_RESPONSE = 0x0003;
|
||||
|
||||
export const CMD_ENUM_DEVICES = 0x0003;
|
||||
export const CMD_ENUM_CONTROLS = 0x0004;
|
||||
export const CMD_GET_CONTROL = 0x0005;
|
||||
export const CMD_SET_CONTROL = 0x0006;
|
||||
|
||||
export const STATUS_OK = 0x0000;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Low-level buffer helpers
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
function read_str8(buf, offset) {
|
||||
const len = buf.readUInt8(offset);
|
||||
const value = buf.toString('utf8', offset + 1, offset + 1 + len);
|
||||
return { value, size: 1 + len };
|
||||
}
|
||||
|
||||
function read_i64(buf, offset) {
|
||||
/* Little-endian i64: low 4 bytes first, high 4 bytes second.
|
||||
* Returns a JS number — safe for the int32 values V4L2 uses in practice. */
|
||||
const lo = buf.readUInt32LE(offset);
|
||||
const hi = buf.readInt32LE(offset + 4);
|
||||
return hi * 0x100000000 + lo;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Frame builder
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export function build_frame(msg_type, payload) {
|
||||
const frame = Buffer.allocUnsafe(FRAME_HEADER_SIZE + payload.length);
|
||||
frame.writeUInt16LE(msg_type, 0);
|
||||
frame.writeUInt32LE(payload.length, 2);
|
||||
payload.copy(frame, FRAME_HEADER_SIZE);
|
||||
return frame;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Request encoders — return a complete TCP frame Buffer.
|
||||
* request_id must be provided by the caller (u16).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export function encode_enum_devices(request_id) {
|
||||
const p = Buffer.allocUnsafe(4);
|
||||
p.writeUInt16LE(request_id, 0);
|
||||
p.writeUInt16LE(CMD_ENUM_DEVICES, 2);
|
||||
return build_frame(MSG_CONTROL_REQUEST, p);
|
||||
}
|
||||
|
||||
export function encode_enum_controls(request_id, device_index) {
|
||||
const p = Buffer.allocUnsafe(6);
|
||||
p.writeUInt16LE(request_id, 0);
|
||||
p.writeUInt16LE(CMD_ENUM_CONTROLS, 2);
|
||||
p.writeUInt16LE(device_index, 4);
|
||||
return build_frame(MSG_CONTROL_REQUEST, p);
|
||||
}
|
||||
|
||||
export function encode_get_control(request_id, device_index, control_id) {
|
||||
const p = Buffer.allocUnsafe(10);
|
||||
p.writeUInt16LE(request_id, 0);
|
||||
p.writeUInt16LE(CMD_GET_CONTROL, 2);
|
||||
p.writeUInt16LE(device_index, 4);
|
||||
p.writeUInt32LE(control_id, 6);
|
||||
return build_frame(MSG_CONTROL_REQUEST, p);
|
||||
}
|
||||
|
||||
export function encode_set_control(request_id, device_index, control_id, value) {
|
||||
const p = Buffer.allocUnsafe(14);
|
||||
p.writeUInt16LE(request_id, 0);
|
||||
p.writeUInt16LE(CMD_SET_CONTROL, 2);
|
||||
p.writeUInt16LE(device_index, 4);
|
||||
p.writeUInt32LE(control_id, 6);
|
||||
p.writeInt32LE(value, 10);
|
||||
return build_frame(MSG_CONTROL_REQUEST, p);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Response decoders — take a payload Buffer (without frame header).
|
||||
* All throw on malformed data.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
export function decode_response_header(payload) {
|
||||
return {
|
||||
request_id: payload.readUInt16LE(0),
|
||||
status: payload.readUInt16LE(2),
|
||||
};
|
||||
}
|
||||
|
||||
export function decode_enum_devices_response(payload) {
|
||||
const hdr = decode_response_header(payload);
|
||||
if (hdr.status !== STATUS_OK) { return { ...hdr, media: [], standalone: [] }; }
|
||||
|
||||
let pos = 4;
|
||||
const media_count = payload.readUInt16LE(pos); pos += 2;
|
||||
const media = [];
|
||||
|
||||
for (let i = 0; i < media_count; i++) {
|
||||
let s;
|
||||
s = read_str8(payload, pos); pos += s.size; const path = s.value;
|
||||
s = read_str8(payload, pos); pos += s.size; const driver = s.value;
|
||||
s = read_str8(payload, pos); pos += s.size; const model = s.value;
|
||||
s = read_str8(payload, pos); pos += s.size; const bus_info = s.value;
|
||||
const vcount = payload.readUInt8(pos); pos++;
|
||||
|
||||
const video_nodes = [];
|
||||
for (let j = 0; j < vcount; j++) {
|
||||
s = read_str8(payload, pos); pos += s.size; const vpath = s.value;
|
||||
s = read_str8(payload, pos); pos += s.size; const entity_name = s.value;
|
||||
const entity_type = payload.readUInt32LE(pos); pos += 4;
|
||||
const entity_flags = payload.readUInt32LE(pos); pos += 4;
|
||||
const device_caps = payload.readUInt32LE(pos); pos += 4;
|
||||
const pad_flags = payload.readUInt8(pos); pos++;
|
||||
const is_capture = payload.readUInt8(pos); pos++;
|
||||
video_nodes.push({ path: vpath, entity_name, entity_type,
|
||||
entity_flags, device_caps, pad_flags, is_capture: !!is_capture });
|
||||
}
|
||||
|
||||
media.push({ path, driver, model, bus_info, video_nodes });
|
||||
}
|
||||
|
||||
const standalone_count = payload.readUInt16LE(pos); pos += 2;
|
||||
const standalone = [];
|
||||
for (let i = 0; i < standalone_count; i++) {
|
||||
let s;
|
||||
s = read_str8(payload, pos); pos += s.size; const path = s.value;
|
||||
s = read_str8(payload, pos); pos += s.size; const name = s.value;
|
||||
standalone.push({ path, name });
|
||||
}
|
||||
|
||||
return { ...hdr, media, standalone };
|
||||
}
|
||||
|
||||
export function decode_enum_controls_response(payload) {
|
||||
const hdr = decode_response_header(payload);
|
||||
if (hdr.status !== STATUS_OK) { return { ...hdr, controls: [] }; }
|
||||
|
||||
let pos = 4;
|
||||
const count = payload.readUInt16LE(pos); pos += 2;
|
||||
const controls = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = payload.readUInt32LE(pos); pos += 4;
|
||||
const type = payload.readUInt8(pos); pos++;
|
||||
const flags = payload.readUInt32LE(pos); pos += 4;
|
||||
const s = read_str8(payload, pos); pos += s.size;
|
||||
const name = s.value;
|
||||
const min = payload.readInt32LE(pos); pos += 4;
|
||||
const max = payload.readInt32LE(pos); pos += 4;
|
||||
const step = payload.readInt32LE(pos); pos += 4;
|
||||
const def = payload.readInt32LE(pos); pos += 4;
|
||||
const cur = payload.readInt32LE(pos); pos += 4;
|
||||
const menu_count = payload.readUInt8(pos); pos++;
|
||||
|
||||
const menu_items = [];
|
||||
for (let j = 0; j < menu_count; j++) {
|
||||
const midx = payload.readUInt32LE(pos); pos += 4;
|
||||
const ms = read_str8(payload, pos); pos += ms.size;
|
||||
const mval = read_i64(payload, pos); pos += 8;
|
||||
menu_items.push({ index: midx, name: ms.value, int_value: mval });
|
||||
}
|
||||
|
||||
controls.push({ id, type, flags, name, min, max, step,
|
||||
default_val: def, current_val: cur, menu_items });
|
||||
}
|
||||
|
||||
return { ...hdr, controls };
|
||||
}
|
||||
|
||||
export function decode_get_control_response(payload) {
|
||||
const hdr = decode_response_header(payload);
|
||||
const value = (hdr.status === STATUS_OK) ? payload.readInt32LE(4) : 0;
|
||||
return { ...hdr, value };
|
||||
}
|
||||
|
||||
export function decode_set_control_response(payload) {
|
||||
return decode_response_header(payload);
|
||||
}
|
||||
450
dev/web/public/app.mjs
Normal file
450
dev/web/public/app.mjs
Normal file
@@ -0,0 +1,450 @@
|
||||
import { by_id, qs, clone, show, hide } from '/lib/dom.mjs';
|
||||
|
||||
/* V4L2 control type codes (must match CTRL_TYPE_* in v4l2_ctrl.h) */
|
||||
const CTRL_INTEGER = 1;
|
||||
const CTRL_BOOLEAN = 2;
|
||||
const CTRL_MENU = 3;
|
||||
const CTRL_BUTTON = 4;
|
||||
const CTRL_INTEGER_MENU = 9;
|
||||
|
||||
/* V4L2 control flags */
|
||||
const FLAG_DISABLED = 0x0001;
|
||||
const FLAG_GRABBED = 0x0002;
|
||||
const FLAG_READ_ONLY = 0x0004;
|
||||
|
||||
/* V4L2_CAP bits (from linux/videodev2.h) */
|
||||
const CAP_VIDEO_CAPTURE = 0x00000001;
|
||||
const CAP_META_CAPTURE = 0x00800000;
|
||||
|
||||
/*
|
||||
* V4L2 control IDs with their class prefix (from linux/v4l2-controls.h).
|
||||
* Used for grouping controls into sections.
|
||||
*/
|
||||
const CTRL_CLASSES = [
|
||||
{ base: 0x00980000, name: 'User' },
|
||||
{ base: 0x009a0000, name: 'Camera' },
|
||||
{ base: 0x009b0000, name: 'Flash' },
|
||||
{ base: 0x009c0000, name: 'JPEG' },
|
||||
{ base: 0x009e0000, name: 'Image Source' },
|
||||
{ base: 0x009f0000, name: 'Image Proc' },
|
||||
{ base: 0x00a20000, name: 'Codec' },
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* State
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const known_peers = new Map(); /* key: addr:name -> peer */
|
||||
let selected_peer = null;
|
||||
let selected_device_idx = null;
|
||||
let selected_device_path = null;
|
||||
let device_data = null;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Utilities
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
function toast(msg, type = '') {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast' + (type ? ` ${type}` : '');
|
||||
el.textContent = msg;
|
||||
by_id('toast-container').appendChild(el);
|
||||
setTimeout(() => {
|
||||
el.classList.add('fading');
|
||||
setTimeout(() => el.remove(), 450);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body !== undefined) { opts.body = JSON.stringify(body); }
|
||||
const res = await fetch(path, opts);
|
||||
const data = await res.json();
|
||||
if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); }
|
||||
return data;
|
||||
}
|
||||
|
||||
function caps_tags(device_caps) {
|
||||
const tags = [];
|
||||
if (device_caps & CAP_VIDEO_CAPTURE) { tags.push('video'); }
|
||||
if (device_caps & CAP_META_CAPTURE) { tags.push('meta'); }
|
||||
return tags.join(' ');
|
||||
}
|
||||
|
||||
function ctrl_class_name(id) {
|
||||
for (const c of CTRL_CLASSES) {
|
||||
if ((id & 0xFFFF0000) === c.base) { return c.name; }
|
||||
}
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
function empty_el(msg) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'empty';
|
||||
el.textContent = msg;
|
||||
return el;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Node panel — live SSE-fed list
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const discovery_es = new EventSource('/api/discover');
|
||||
|
||||
discovery_es.onmessage = e => {
|
||||
const peer = JSON.parse(e.data);
|
||||
const key = `${peer.addr}:${peer.name}`;
|
||||
known_peers.set(key, peer);
|
||||
render_node_list();
|
||||
};
|
||||
|
||||
discovery_es.addEventListener('discovery_error', e => {
|
||||
try {
|
||||
const { error } = JSON.parse(e.data);
|
||||
toast(`discovery: ${error}`, 'err');
|
||||
} catch {}
|
||||
});
|
||||
|
||||
function render_node_list() {
|
||||
const list = by_id('node-list');
|
||||
if (known_peers.size === 0) {
|
||||
list.replaceChildren(empty_el('Discovering…'));
|
||||
return;
|
||||
}
|
||||
list.replaceChildren(...[...known_peers.values()].map(make_node_item));
|
||||
}
|
||||
|
||||
function make_node_item(peer) {
|
||||
const key = `${peer.addr}:${peer.name}`;
|
||||
const el = clone('t-node-item');
|
||||
if (selected_peer && `${selected_peer.addr}:${selected_peer.name}` === key) {
|
||||
el.classList.add('selected');
|
||||
}
|
||||
qs('.n-name', el).textContent = peer.name;
|
||||
qs('.n-addr', el).textContent = `${peer.addr}:${peer.tcp_port}`;
|
||||
el.addEventListener('click', () => select_node(peer));
|
||||
return el;
|
||||
}
|
||||
|
||||
async function select_node(peer) {
|
||||
try {
|
||||
await api('POST', '/api/connect', { host: peer.addr, port: peer.tcp_port });
|
||||
selected_peer = peer;
|
||||
selected_device_idx = null;
|
||||
device_data = null;
|
||||
render_node_list();
|
||||
await refresh_status();
|
||||
toast(`connected to ${peer.name}`, 'ok');
|
||||
} catch (err) {
|
||||
toast(err.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Status
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
async function refresh_status() {
|
||||
try {
|
||||
const s = await api('GET', '/api/status');
|
||||
by_id('status-dot').className = s.connected ? 'ok' : '';
|
||||
by_id('status-text').textContent = s.connected ? `${s.host}:${s.port}` : 'not connected';
|
||||
if (s.connected) {
|
||||
show(by_id('btn-disconnect'));
|
||||
await refresh_devices();
|
||||
} else {
|
||||
hide(by_id('btn-disconnect'));
|
||||
show_empty_devices();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Device panel
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
function show_empty_devices(msg = 'Select a node') {
|
||||
by_id('device-list').replaceChildren(empty_el(msg));
|
||||
}
|
||||
|
||||
async function refresh_devices() {
|
||||
try {
|
||||
device_data = await api('GET', '/api/devices');
|
||||
render_device_list();
|
||||
} catch (err) {
|
||||
toast(err.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function render_device_list() {
|
||||
const list = by_id('device-list');
|
||||
|
||||
if (!device_data) {
|
||||
list.replaceChildren(empty_el('Select a node'));
|
||||
return;
|
||||
}
|
||||
|
||||
let flat_idx = 0;
|
||||
const children = [];
|
||||
const { media, standalone } = device_data;
|
||||
|
||||
for (const md of media) {
|
||||
const group = clone('t-device-group');
|
||||
qs('.device-group-header', group).textContent = `${md.model} (${md.driver})`;
|
||||
for (const vn of md.video_nodes) {
|
||||
group.appendChild(make_device_item(flat_idx++, vn.path,
|
||||
vn.entity_name, vn.device_caps, vn.is_capture));
|
||||
}
|
||||
children.push(group);
|
||||
}
|
||||
|
||||
if (standalone.length > 0) {
|
||||
const group = clone('t-device-group');
|
||||
qs('.device-group-header', group).textContent = 'Standalone';
|
||||
for (const sd of standalone) {
|
||||
group.appendChild(make_device_item(flat_idx++, sd.path, sd.name, 0, false));
|
||||
}
|
||||
children.push(group);
|
||||
}
|
||||
|
||||
if (flat_idx === 0) {
|
||||
list.replaceChildren(empty_el('No devices found'));
|
||||
return;
|
||||
}
|
||||
|
||||
list.replaceChildren(...children);
|
||||
|
||||
if (selected_device_idx !== null) {
|
||||
const item = list.querySelector(`[data-idx="${selected_device_idx}"]`);
|
||||
if (item) { item.classList.add('selected'); }
|
||||
}
|
||||
}
|
||||
|
||||
function make_device_item(idx, path, label, device_caps, is_capture) {
|
||||
const el = clone('t-device-item');
|
||||
el.dataset.idx = idx;
|
||||
|
||||
const path_el = qs('.d-path', el);
|
||||
path_el.textContent = path;
|
||||
if (is_capture) { path_el.appendChild(clone('t-capture-badge')); }
|
||||
|
||||
qs('.d-meta', el).textContent = label;
|
||||
|
||||
const caps_text = caps_tags(device_caps);
|
||||
if (caps_text) {
|
||||
const caps_el = document.createElement('div');
|
||||
caps_el.className = 'd-caps';
|
||||
caps_el.textContent = caps_text;
|
||||
el.appendChild(caps_el);
|
||||
}
|
||||
|
||||
el.addEventListener('click', () => select_device(idx, path));
|
||||
return el;
|
||||
}
|
||||
|
||||
async function select_device(idx, path) {
|
||||
selected_device_idx = idx;
|
||||
selected_device_path = path;
|
||||
document.querySelectorAll('.device-item').forEach(el => {
|
||||
el.classList.toggle('selected', parseInt(el.dataset.idx) === idx);
|
||||
});
|
||||
await load_controls(idx, path);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Controls panel
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
function show_empty_controls(msg = 'Select a device') {
|
||||
by_id('controls-title').textContent = 'Controls';
|
||||
by_id('controls-scroll').replaceChildren(empty_el(msg));
|
||||
}
|
||||
|
||||
async function load_controls(device_idx, device_path) {
|
||||
by_id('controls-title').textContent = `Controls — ${device_path}`;
|
||||
by_id('controls-scroll').replaceChildren(empty_el('Loading…'));
|
||||
try {
|
||||
const result = await api('GET', `/api/devices/${device_idx}/controls`);
|
||||
render_controls(device_idx, result.controls ?? []);
|
||||
} catch (err) {
|
||||
toast(err.message, 'err');
|
||||
show_empty_controls('Failed to load controls');
|
||||
}
|
||||
}
|
||||
|
||||
/* Silent reload — no loading flash; used after a control change that may
|
||||
* alter other controls' flags (e.g. switching exposure mode ungrabs
|
||||
* exposure_absolute). */
|
||||
async function reload_controls() {
|
||||
if (selected_device_idx === null || selected_device_path === null) { return; }
|
||||
try {
|
||||
const result = await api('GET', `/api/devices/${selected_device_idx}/controls`);
|
||||
by_id('controls-title').textContent = `Controls — ${selected_device_path}`;
|
||||
render_controls(selected_device_idx, result.controls ?? []);
|
||||
} catch (err) {
|
||||
toast(err.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function render_controls(device_idx, controls) {
|
||||
const scroll = by_id('controls-scroll');
|
||||
|
||||
if (controls.length === 0) {
|
||||
scroll.replaceChildren(empty_el('No controls'));
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = new Map();
|
||||
for (const ctrl of controls) {
|
||||
const cls = ctrl_class_name(ctrl.id);
|
||||
if (!groups.has(cls)) { groups.set(cls, []); }
|
||||
groups.get(cls).push(ctrl);
|
||||
}
|
||||
|
||||
const children = [];
|
||||
for (const [cls_name, ctrls] of groups) {
|
||||
const group_el = clone('t-ctrl-group');
|
||||
qs('.ctrl-group-title', group_el).textContent = cls_name;
|
||||
for (const ctrl of ctrls) {
|
||||
const row = make_ctrl_row(device_idx, ctrl);
|
||||
if (row) { group_el.appendChild(row); }
|
||||
}
|
||||
children.push(group_el);
|
||||
}
|
||||
scroll.replaceChildren(...children);
|
||||
}
|
||||
|
||||
function make_ctrl_row(device_idx, ctrl) {
|
||||
const disabled = !!(ctrl.flags & FLAG_DISABLED);
|
||||
const read_only = !!(ctrl.flags & (FLAG_READ_ONLY | FLAG_GRABBED));
|
||||
|
||||
const row = clone('t-ctrl-row');
|
||||
if (disabled) { row.classList.add('disabled'); }
|
||||
if (read_only) { row.classList.add('readonly'); }
|
||||
|
||||
const label_el = qs('.ctrl-label', row);
|
||||
const input_wrap = qs('.ctrl-input', row);
|
||||
const value_el = qs('.ctrl-value-display', row);
|
||||
|
||||
label_el.textContent = ctrl.name;
|
||||
|
||||
let input_el = null;
|
||||
let get_value = null;
|
||||
|
||||
switch (ctrl.type) {
|
||||
case CTRL_BOOLEAN: {
|
||||
input_el = document.createElement('input');
|
||||
input_el.type = 'checkbox';
|
||||
input_el.checked = !!ctrl.current_val;
|
||||
get_value = () => input_el.checked ? 1 : 0;
|
||||
break;
|
||||
}
|
||||
case CTRL_MENU:
|
||||
case CTRL_INTEGER_MENU: {
|
||||
input_el = document.createElement('select');
|
||||
for (const item of ctrl.menu_items) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.index;
|
||||
opt.textContent = ctrl.type === CTRL_INTEGER_MENU
|
||||
? item.int_value.toString()
|
||||
: item.name;
|
||||
if (item.index === ctrl.current_val) { opt.selected = true; }
|
||||
input_el.appendChild(opt);
|
||||
}
|
||||
get_value = () => parseInt(input_el.value);
|
||||
break;
|
||||
}
|
||||
case CTRL_BUTTON: {
|
||||
input_el = document.createElement('button');
|
||||
input_el.textContent = ctrl.name;
|
||||
get_value = () => 1;
|
||||
break;
|
||||
}
|
||||
case CTRL_INTEGER:
|
||||
default: {
|
||||
input_el = document.createElement('input');
|
||||
input_el.type = 'range';
|
||||
input_el.min = ctrl.min;
|
||||
input_el.max = ctrl.max;
|
||||
input_el.step = ctrl.step || 1;
|
||||
input_el.value = ctrl.current_val;
|
||||
value_el.textContent = ctrl.current_val;
|
||||
input_el.addEventListener('input', () => { value_el.textContent = input_el.value; });
|
||||
get_value = () => parseInt(input_el.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!input_el) { return null; }
|
||||
|
||||
input_wrap.appendChild(input_el);
|
||||
|
||||
if (!read_only && !disabled) {
|
||||
const send = async () => {
|
||||
try {
|
||||
const id_hex = ctrl.id.toString(16).padStart(8, '0');
|
||||
await api('POST', `/api/devices/${device_idx}/controls/${id_hex}`,
|
||||
{ value: get_value() });
|
||||
} catch (err) {
|
||||
toast(`${ctrl.name}: ${err.message}`, 'err');
|
||||
}
|
||||
};
|
||||
|
||||
if (ctrl.type === CTRL_BUTTON) {
|
||||
input_el.addEventListener('click', async () => { await send(); await reload_controls(); });
|
||||
} else if (ctrl.type === CTRL_BOOLEAN || ctrl.type === CTRL_MENU
|
||||
|| ctrl.type === CTRL_INTEGER_MENU) {
|
||||
input_el.addEventListener('change', async () => { await send(); await reload_controls(); });
|
||||
} else {
|
||||
input_el.addEventListener('pointerup', send);
|
||||
input_el.addEventListener('keyup', send);
|
||||
input_el.addEventListener('input', () => {
|
||||
if (by_id('chk-continuous').checked) { send(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Event wiring
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
by_id('btn-connect').addEventListener('click', async () => {
|
||||
const host = by_id('inp-host').value.trim();
|
||||
const port = by_id('inp-port').value.trim();
|
||||
if (!host || !port) { toast('enter host and port', 'err'); return; }
|
||||
try {
|
||||
await api('POST', '/api/connect', { host, port: parseInt(port) });
|
||||
selected_peer = null;
|
||||
selected_device_idx = null;
|
||||
device_data = null;
|
||||
render_node_list();
|
||||
await refresh_status();
|
||||
toast('connected', 'ok');
|
||||
} catch (err) {
|
||||
toast(err.message, 'err');
|
||||
}
|
||||
});
|
||||
|
||||
by_id('btn-disconnect').addEventListener('click', async () => {
|
||||
await api('POST', '/api/disconnect');
|
||||
selected_peer = null;
|
||||
selected_device_idx = null;
|
||||
device_data = null;
|
||||
render_node_list();
|
||||
show_empty_devices();
|
||||
show_empty_controls();
|
||||
await refresh_status();
|
||||
});
|
||||
|
||||
by_id('btn-refresh-devices').addEventListener('click', refresh_devices);
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Init
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
refresh_status();
|
||||
94
dev/web/public/index.html
Normal file
94
dev/web/public/index.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Video Node Inspector</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="topbar">
|
||||
<div id="status-dot"></div>
|
||||
<h1>Video Node Inspector</h1>
|
||||
<span id="status-text">not connected</span>
|
||||
<button id="btn-disconnect" hidden>Disconnect</button>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<div id="node-panel" class="panel">
|
||||
<h2>Nodes</h2>
|
||||
<div id="node-list">
|
||||
<div class="empty">Discovering…</div>
|
||||
</div>
|
||||
<div id="manual-connect">
|
||||
<input type="text" id="inp-host" placeholder="host">
|
||||
<input type="number" id="inp-port" placeholder="port">
|
||||
<button id="btn-connect" class="primary">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="device-panel" class="panel">
|
||||
<h2>
|
||||
Devices
|
||||
<button id="btn-refresh-devices" class="compact" style="margin-left:auto">↻</button>
|
||||
</h2>
|
||||
<div id="device-list">
|
||||
<div class="empty">Select a node</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="controls-panel" class="panel">
|
||||
<h2>
|
||||
<span id="controls-title">Controls</span>
|
||||
<label class="continuous-label">
|
||||
<input type="checkbox" id="chk-continuous"> continuous
|
||||
</label>
|
||||
</h2>
|
||||
<div id="controls-scroll">
|
||||
<div class="empty">Select a device</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<template id="t-node-item">
|
||||
<div class="node-item">
|
||||
<div class="n-name"></div>
|
||||
<div class="n-addr"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="t-device-group">
|
||||
<div class="device-group">
|
||||
<div class="device-group-header"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="t-device-item">
|
||||
<div class="device-item">
|
||||
<div class="d-path"></div>
|
||||
<div class="d-meta"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="t-capture-badge">
|
||||
<span class="capture-badge">capture</span>
|
||||
</template>
|
||||
|
||||
<template id="t-ctrl-group">
|
||||
<div class="ctrl-group">
|
||||
<div class="ctrl-group-title"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="t-ctrl-row">
|
||||
<div class="ctrl-row">
|
||||
<div class="ctrl-label"></div>
|
||||
<div class="ctrl-input"></div>
|
||||
<div class="ctrl-value-display"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="module" src="/app.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
5
dev/web/public/lib/dom.mjs
Normal file
5
dev/web/public/lib/dom.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export const by_id = id => document.getElementById(id);
|
||||
export const qs = (sel, root = document) => root.querySelector(sel);
|
||||
export const clone = id => document.getElementById(id).content.cloneNode(true).firstElementChild;
|
||||
export const show = el => el.removeAttribute('hidden');
|
||||
export const hide = el => el.setAttribute('hidden', '');
|
||||
294
dev/web/public/style.css
Normal file
294
dev/web/public/style.css
Normal file
@@ -0,0 +1,294 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
[hidden] { display: none; }
|
||||
|
||||
:root {
|
||||
--bg: #1a1a1e;
|
||||
--surface: #25252b;
|
||||
--surface2: #2e2e36;
|
||||
--border: #3a3a44;
|
||||
--text: #e0e0e8;
|
||||
--text-dim: #888898;
|
||||
--accent: #5b8af0;
|
||||
--accent2: #3a5cc0;
|
||||
--ok: #4caf80;
|
||||
--warn: #e0a030;
|
||||
--err: #e05050;
|
||||
--radius: 6px;
|
||||
--font-mono: 'Cascadia Code', 'Fira Mono', monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* -- Top bar --------------------------------------------------------------- */
|
||||
|
||||
#topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#topbar h1 { font-size: 15px; font-weight: 600; }
|
||||
|
||||
#status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--err);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
#status-dot.ok { background: var(--ok); }
|
||||
|
||||
#status-text { font-size: 12px; color: var(--text-dim); margin-right: auto; }
|
||||
|
||||
/* -- Buttons + inputs ------------------------------------------------------ */
|
||||
|
||||
button {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: var(--border); }
|
||||
button.primary { background: var(--accent2); border-color: var(--accent); }
|
||||
button.primary:hover { background: var(--accent); }
|
||||
button.compact { font-size: 11px; padding: 2px 8px; }
|
||||
|
||||
input[type=text], input[type=number] {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* -- Main layout ----------------------------------------------------------- */
|
||||
|
||||
#main {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 260px 1fr;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* -- Shared panel ---------------------------------------------------------- */
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel:last-child { border-right: none; background: var(--bg); }
|
||||
|
||||
.panel > h2 {
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* -- Node panel ------------------------------------------------------------ */
|
||||
|
||||
#node-list { overflow-y: auto; flex: 1; }
|
||||
|
||||
.node-item {
|
||||
padding: 9px 14px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.node-item:hover { background: var(--surface2); }
|
||||
.node-item.selected {
|
||||
background: var(--surface2);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.node-item .n-name { font-size: 13px; font-weight: 500; }
|
||||
.node-item .n-addr {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
#manual-connect {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#manual-connect input[type=text] { flex: 1; min-width: 0; }
|
||||
#manual-connect input[type=number] { width: 60px; }
|
||||
#manual-connect button { padding: 5px 8px; }
|
||||
|
||||
/* -- Device panel ---------------------------------------------------------- */
|
||||
|
||||
#device-list { overflow-y: auto; flex: 1; }
|
||||
|
||||
.device-group { padding: 8px 0 4px; }
|
||||
|
||||
.device-group-header {
|
||||
padding: 4px 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.device-item {
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.device-item:hover { background: var(--surface2); }
|
||||
.device-item.selected {
|
||||
background: var(--surface2);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.device-item .d-path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.device-item .d-meta { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
|
||||
.device-item .d-caps {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.capture-badge {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent2);
|
||||
color: #fff;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* -- Controls panel -------------------------------------------------------- */
|
||||
|
||||
#controls-scroll { overflow-y: auto; flex: 1; padding: 12px 16px; }
|
||||
|
||||
.continuous-label {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: auto;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ctrl-group { margin-bottom: 16px; }
|
||||
|
||||
.ctrl-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 0 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ctrl-row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ctrl-row:last-child { border-bottom: none; }
|
||||
.ctrl-row.disabled { opacity: 0.45; pointer-events: none; }
|
||||
.ctrl-row.readonly { opacity: 0.65; }
|
||||
.ctrl-row.readonly .ctrl-input { pointer-events: none; }
|
||||
|
||||
.ctrl-label { font-size: 13px; }
|
||||
.ctrl-value-display {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
text-align: right;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.ctrl-input { width: 100%; }
|
||||
.ctrl-input button { width: 100%; }
|
||||
|
||||
input[type=range] { width: 100%; accent-color: var(--accent); }
|
||||
|
||||
select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||
|
||||
/* -- Toasts ---------------------------------------------------------------- */
|
||||
|
||||
#toast-container {
|
||||
position: fixed; bottom: 16px; right: 16px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
pointer-events: none; z-index: 100;
|
||||
}
|
||||
.toast {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
opacity: 1;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
.toast.err { border-color: var(--err); color: var(--err); }
|
||||
.toast.ok { border-color: var(--ok); color: var(--ok); }
|
||||
.toast.fading { opacity: 0; }
|
||||
|
||||
/* -- Empty states ---------------------------------------------------------- */
|
||||
|
||||
.empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
193
dev/web/server.mjs
Normal file
193
dev/web/server.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Express 5 web server — REST bridge to video-node.
|
||||
*
|
||||
* Usage:
|
||||
* node server.mjs [--host IP] [--port PORT] [--listen PORT]
|
||||
*
|
||||
* Discovery runs at startup unconditionally — the server joins the multicast
|
||||
* group and sends an announcement immediately so nodes respond right away.
|
||||
* The /api/discover SSE endpoint subscribes to the running peer feed.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { Node_Client } from './node_client.mjs';
|
||||
import { start_discovery } from './discovery.mjs';
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Argument parsing
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const arg = name => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : null;
|
||||
};
|
||||
|
||||
const opt_host = arg('--host');
|
||||
const opt_port = arg('--port') ? parseInt(arg('--port')) : null;
|
||||
const listen_port = arg('--listen') ? parseInt(arg('--listen')) : 3000;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Persistent discovery — runs from startup, feeds the SSE endpoint
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const peer_bus = new EventEmitter();
|
||||
const known_peers = new Map(); /* key: addr:name -> peer */
|
||||
|
||||
start_discovery(
|
||||
peer => {
|
||||
const key = `${peer.addr}:${peer.name}`;
|
||||
known_peers.set(key, peer);
|
||||
peer_bus.emit('peer', peer);
|
||||
console.log(`discovered: ${peer.name} at ${peer.addr}:${peer.tcp_port}`);
|
||||
},
|
||||
err => {
|
||||
console.error('discovery error:', err.message);
|
||||
peer_bus.emit('discovery_error', err);
|
||||
},
|
||||
);
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Node client (singleton)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const client = new Node_Client();
|
||||
|
||||
client.on('disconnect', () => {
|
||||
console.log('video-node disconnected');
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Express app
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
app.use(express.static(path.join(__dir, 'public')));
|
||||
|
||||
/* -- Status ---------------------------------------------------------------- */
|
||||
|
||||
app.get('/api/status', (_req, res) => {
|
||||
res.json({
|
||||
connected: client.connected,
|
||||
host: client.host,
|
||||
port: client.port,
|
||||
});
|
||||
});
|
||||
|
||||
/* -- Connect --------------------------------------------------------------- */
|
||||
|
||||
app.post('/api/connect', async (req, res) => {
|
||||
const { host, port } = req.body ?? {};
|
||||
if (!host || !port) {
|
||||
return res.status(400).json({ error: 'host and port required' });
|
||||
}
|
||||
try {
|
||||
await client.connect(host, parseInt(port));
|
||||
res.json({ ok: true, host: client.host, port: client.port });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/disconnect', (_req, res) => {
|
||||
client.disconnect();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/*
|
||||
* SSE stream — immediately replays already-known peers, then streams new ones
|
||||
* as they arrive. Client closes when done (user picked a node or dismissed).
|
||||
*/
|
||||
app.get('/api/discover', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const send_peer = peer => res.write(`data: ${JSON.stringify(peer)}\n\n`);
|
||||
const send_error = err => res.write(`event: discovery_error\ndata: ${JSON.stringify({ error: err.message })}\n\n`);
|
||||
|
||||
/* replay peers already seen before this SSE was opened */
|
||||
for (const peer of known_peers.values()) { send_peer(peer); }
|
||||
|
||||
peer_bus.on('peer', send_peer);
|
||||
peer_bus.on('discovery_error', send_error);
|
||||
|
||||
req.on('close', () => {
|
||||
peer_bus.off('peer', send_peer);
|
||||
peer_bus.off('discovery_error', send_error);
|
||||
});
|
||||
});
|
||||
|
||||
/* -- Devices --------------------------------------------------------------- */
|
||||
|
||||
app.get('/api/devices', async (_req, res) => {
|
||||
try {
|
||||
const result = await client.enum_devices();
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* -- Controls -------------------------------------------------------------- */
|
||||
|
||||
app.get('/api/devices/:idx/controls', async (req, res) => {
|
||||
const idx = parseInt(req.params.idx);
|
||||
if (isNaN(idx)) { return res.status(400).json({ error: 'invalid device index' }); }
|
||||
try {
|
||||
const result = await client.enum_controls(idx);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/devices/:idx/controls/:ctrl_id', async (req, res) => {
|
||||
const idx = parseInt(req.params.idx);
|
||||
const ctrl_id = parseInt(req.params.ctrl_id, 16) || parseInt(req.params.ctrl_id);
|
||||
if (isNaN(idx) || isNaN(ctrl_id)) {
|
||||
return res.status(400).json({ error: 'invalid params' });
|
||||
}
|
||||
try {
|
||||
const result = await client.get_control(idx, ctrl_id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/devices/:idx/controls/:ctrl_id', async (req, res) => {
|
||||
const idx = parseInt(req.params.idx);
|
||||
const ctrl_id = parseInt(req.params.ctrl_id, 16) || parseInt(req.params.ctrl_id);
|
||||
const value = req.body?.value;
|
||||
if (isNaN(idx) || isNaN(ctrl_id) || value === undefined) {
|
||||
return res.status(400).json({ error: 'invalid params' });
|
||||
}
|
||||
try {
|
||||
const result = await client.set_control(idx, ctrl_id, parseInt(value));
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Startup
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
app.listen(listen_port, () => {
|
||||
console.log(`listening on http://localhost:${listen_port}`);
|
||||
});
|
||||
|
||||
if (opt_host && opt_port) {
|
||||
client.connect(opt_host, opt_port)
|
||||
.then(() => console.log(`connected to ${opt_host}:${opt_port}`))
|
||||
.catch(err => console.error('connect failed:', err.message));
|
||||
}
|
||||
80
docs/cli/config_cli.md
Normal file
80
docs/cli/config_cli.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# config_cli
|
||||
|
||||
A development tool for loading and inspecting INI configuration files against the node's schema. Useful for verifying that a config file parses correctly and seeing what values would be applied.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Load a config file
|
||||
|
||||
```sh
|
||||
./config_cli <file>
|
||||
```
|
||||
|
||||
Parses the file and prints all resolved values. Values absent from the file are filled with schema defaults.
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
./config_cli /etc/video-node/node.cfg
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
[node]
|
||||
name = camera:0
|
||||
site_id = 0
|
||||
tcp_port = 8000
|
||||
function = source
|
||||
|
||||
[discovery]
|
||||
interval_ms = 5000
|
||||
timeout_intervals = 3
|
||||
|
||||
[transport]
|
||||
max_connections = 16
|
||||
```
|
||||
|
||||
### Print schema defaults
|
||||
|
||||
```sh
|
||||
./config_cli --defaults
|
||||
```
|
||||
|
||||
Prints all keys with their default values and types, without reading any file.
|
||||
|
||||
---
|
||||
|
||||
## Config Schema
|
||||
|
||||
| Section | Key | Type | Default |
|
||||
|---|---|---|---|
|
||||
| `node` | `name` | string | `unnamed:0` |
|
||||
| `node` | `site_id` | uint16 | `0` |
|
||||
| `node` | `tcp_port` | uint16 | `0` (auto) |
|
||||
| `node` | `function` | flags | `source` |
|
||||
| `discovery` | `interval_ms` | uint32 | `5000` |
|
||||
| `discovery` | `timeout_intervals` | uint32 | `3` |
|
||||
| `transport` | `max_connections` | uint32 | `16` |
|
||||
|
||||
`function` accepts a comma-separated list of role flags: `source`, `relay`, `sink`, `controller`.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`config_cli` exercises the `config` module used by the video node at startup. Both the node binary and `config_cli` share the same schema definition, so this tool is an accurate preview of how the node will interpret a config file.
|
||||
172
docs/cli/controller_cli.md
Normal file
172
docs/cli/controller_cli.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# controller_cli
|
||||
|
||||
An interactive REPL for controlling video nodes. Connects to a running node over TCP and lets you enumerate devices and controls, adjust camera parameters, start and stop ingest streams, and open and close display windows. Uses readline for line editing and history.
|
||||
|
||||
> **Note:** `controller_cli` is a temporary development tool. The long-term replacement is a dedicated `controller` binary that maintains simultaneous connections to all discovered nodes rather than switching between them.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`. Requires `libreadline`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./controller_cli [--host HOST] [--port PORT]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--host HOST` | — | Connect to this host on startup |
|
||||
| `--port PORT` | `8000` | TCP port to use with `--host` |
|
||||
|
||||
Without `--host`, the tool starts in discovery mode and waits for nodes to appear. Connect to a discovered node from the REPL using the `connect` command.
|
||||
|
||||
---
|
||||
|
||||
## REPL Commands
|
||||
|
||||
### Discovery
|
||||
|
||||
```
|
||||
peers
|
||||
```
|
||||
|
||||
List all currently discovered nodes with their index, name, host, and port.
|
||||
|
||||
```
|
||||
connect [idx | host:port]
|
||||
```
|
||||
|
||||
Connect to a node. With no argument, connects to the first discovered peer. Examples:
|
||||
|
||||
```
|
||||
connect # first discovered peer
|
||||
connect 1 # peer at index 1 in the peers list
|
||||
connect 192.168.1.42:8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Device enumeration
|
||||
|
||||
```
|
||||
enum-devices
|
||||
```
|
||||
|
||||
List all devices on the connected node: media controller devices and their video nodes (with device index), standalone V4L2 devices, and active display windows. Each entry shows its flat device index `[N]` for use in control commands.
|
||||
|
||||
```
|
||||
enum-controls <device_index>
|
||||
```
|
||||
|
||||
List all controls for a device. For V4L2 devices this returns camera parameters; for display windows it returns display controls.
|
||||
|
||||
**Display device controls:**
|
||||
|
||||
| ID | Name | Range | Description |
|
||||
|---|---|---|---|
|
||||
| `0x00D00001` | Scale | 0–3 | 0=stretch 1=fit 2=fill 3=1:1 |
|
||||
| `0x00D00002` | Anchor | 0–1 | 0=center 1=topleft |
|
||||
| `0x00D00003` | No-signal FPS | 1–60 | No-signal animation frame rate |
|
||||
|
||||
---
|
||||
|
||||
### Control get/set
|
||||
|
||||
```
|
||||
get-control <device_index> <control_id_hex>
|
||||
set-control <device_index> <control_id_hex> <value>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
get-control 0 0x00980900 # read Brightness on device 0
|
||||
set-control 0 0x00980900 200 # set Brightness to 200
|
||||
set-control 3 0x00D00001 1 # set Scale to 'fit' on display device 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Ingest (source node)
|
||||
|
||||
```
|
||||
start-ingest <stream_id> <device_path> <dest_host> <dest_port>
|
||||
[format] [width] [height] [fps_n] [fps_d]
|
||||
```
|
||||
|
||||
Tell the connected node to open a V4L2 device and stream to `dest_host:dest_port`. Format, resolution, and frame rate default to auto-select (best MJPEG) when omitted.
|
||||
|
||||
```
|
||||
stop-ingest <stream_id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Display (sink node)
|
||||
|
||||
```
|
||||
start-display <stream_id> [win_x] [win_y] [win_w] [win_h] [no_signal_fps]
|
||||
```
|
||||
|
||||
Tell the connected node to open a display window for `stream_id`. Position and size default to `0,0 1280×720`. No-signal FPS defaults to 15.
|
||||
|
||||
```
|
||||
stop-display <stream_id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Other
|
||||
|
||||
```
|
||||
help show command list
|
||||
quit exit
|
||||
exit exit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example session
|
||||
|
||||
```
|
||||
$ ./controller_cli
|
||||
listening for nodes...
|
||||
[discovered 0] camera:0 192.168.1.42:8000
|
||||
> connect
|
||||
connected to 192.168.1.42:8000
|
||||
> enum-devices
|
||||
devices:
|
||||
media /dev/media0 driver=unicam model=unicam ... (1 video node)
|
||||
[0] video /dev/video0 entity=unicam-image ... [capture]
|
||||
> start-ingest 1 /dev/video0 192.168.1.55 8001
|
||||
ok
|
||||
> connect 1
|
||||
[discovered 1] display:0 192.168.1.55:8000
|
||||
connected to 192.168.1.55:8000
|
||||
> start-display 1
|
||||
ok
|
||||
> enum-devices
|
||||
devices:
|
||||
[3] display stream=1 pos=0,0 size=1280x720 scale=stretch anchor=center
|
||||
> set-control 3 0x00D00001 1
|
||||
ok
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`controller_cli` is the primary human interface for the video routing system during development. It exercises the full control channel: discovery, TCP transport, and every protocol command type. It is the reference implementation for how a controller interacts with nodes.
|
||||
|
||||
See also: [`query_cli.md`](query_cli.md) for a simpler read-only query; [`stream_send_cli.md`](stream_send_cli.md) / [`stream_recv_cli.md`](stream_recv_cli.md) for testing streams without a full node.
|
||||
53
docs/cli/discovery_cli.md
Normal file
53
docs/cli/discovery_cli.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# discovery_cli
|
||||
|
||||
A development tool for testing the discovery layer. Announces a node on the local network via UDP multicast and prints peers as they appear and disappear.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
discovery_cli <name> <tcp_port> [flags]
|
||||
```
|
||||
|
||||
| Argument | Description |
|
||||
|---|---|
|
||||
| `name` | Node name in `namespace:instance` form, e.g. `v4l2:microscope` |
|
||||
| `tcp_port` | The TCP port this node listens on for transport connections |
|
||||
| `flags` | Comma-separated role list: `source`, `relay`, `sink`, `controller` (default: `source`) |
|
||||
|
||||
Example — announce a source node and watch for peers:
|
||||
|
||||
```sh
|
||||
./discovery_cli camera:0 8000 source
|
||||
```
|
||||
|
||||
Example output as peers are found and lost:
|
||||
|
||||
```
|
||||
found: unnamed:0 192.168.1.42:8001 site=0 flags=source
|
||||
found: display:0 192.168.1.55:8002 site=0 flags=sink
|
||||
lost: unnamed:0 192.168.1.42:8001
|
||||
```
|
||||
|
||||
Run two instances on the same machine (different ports) to verify mutual discovery.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`discovery_cli` exercises the `discovery` module, which the video node uses to find peers on the LAN without configuration. The node starts a discovery listener on startup; controllers use discovered peer tables to locate nodes before connecting.
|
||||
|
||||
See also: [`query_cli.md`](query_cli.md) for a combined discovery + protocol query.
|
||||
64
docs/cli/protocol_cli.md
Normal file
64
docs/cli/protocol_cli.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# protocol_cli
|
||||
|
||||
A development tool for testing the protocol layer in isolation. Runs a server that decodes and prints all incoming control messages, or a client that connects and sends a sample STREAM_OPEN request.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Server mode
|
||||
|
||||
```sh
|
||||
./protocol_cli --server [port]
|
||||
```
|
||||
|
||||
Default port: `8000`. Listens for connections and prints a decoded description of every received frame.
|
||||
|
||||
Example output on an incoming STREAM_OPEN:
|
||||
|
||||
```
|
||||
CONTROL_REQUEST request_id=1 STREAM_OPEN stream_id=1 format=0x0001 pixel_format=0x0000 origin=0x0001
|
||||
```
|
||||
|
||||
Recognised message types and commands:
|
||||
|
||||
| Type | Description |
|
||||
|---|---|
|
||||
| `VIDEO_FRAME` | stream_id + compressed payload |
|
||||
| `STREAM_EVENT` | stream_id + event code (INTERRUPTED / RESUMED) |
|
||||
| `CONTROL_REQUEST` | request_id + command (STREAM_OPEN, STREAM_CLOSE, ENUM_DEVICES, ENUM_CONTROLS, GET_CONTROL, SET_CONTROL, ENUM_MONITORS) |
|
||||
| `CONTROL_RESPONSE` | request_id + status (OK, ERROR, UNKNOWN_CMD, INVALID_PARAMS, NOT_FOUND) |
|
||||
|
||||
### Client mode
|
||||
|
||||
```sh
|
||||
./protocol_cli --client <host> <port>
|
||||
```
|
||||
|
||||
Connects to the server and sends a single STREAM_OPEN request, then waits for the response.
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
./protocol_cli --client 127.0.0.1 8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`protocol_cli` exercises the `protocol` module that all nodes use to encode and decode wire messages. Pairing the server with a running node lets you inspect raw control traffic; pairing the client with a running node lets you inject individual commands for debugging.
|
||||
|
||||
See also: [`transport_cli.md`](transport_cli.md) for raw frame-level testing without protocol parsing.
|
||||
64
docs/cli/query_cli.md
Normal file
64
docs/cli/query_cli.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# query_cli
|
||||
|
||||
An integration smoke test that combines discovery and protocol. Waits for a video node to appear on the local network, then sends an ENUM_DEVICES query and prints the results. Optionally enumerates controls on a specific device.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./query_cli [--timeout ms] [--controls device_index]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--timeout ms` | `5000` | How long to wait for a node to appear (milliseconds) |
|
||||
| `--controls idx` | — | After enumeration, also query controls for this device index |
|
||||
|
||||
Example — query the first node found within 5 s:
|
||||
|
||||
```sh
|
||||
./query_cli
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
discovered: camera:0 192.168.1.42:8000
|
||||
querying devices...
|
||||
media /dev/media0 driver=unicam model=unicam bus=platform:fe801000.csi (1 video node)
|
||||
[0] video /dev/video0 entity=unicam-image type=0x00010001 caps=[video-capture] [capture]
|
||||
[1] standalone /dev/video1 card=USB Camera
|
||||
```
|
||||
|
||||
Example with controls:
|
||||
|
||||
```sh
|
||||
./query_cli --controls 1
|
||||
```
|
||||
|
||||
```
|
||||
ctrl id=0x00980900 Brightness int min=0 max=255 step=1 default=128 current=127
|
||||
ctrl id=0x00980901 Contrast int min=0 max=255 step=1 default=128 current=128
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`query_cli` is an end-to-end integration test: it exercises discovery, transport, and protocol in one shot. It is the simplest way to verify that a node is reachable and responding correctly to control requests before using `controller_cli` for full interaction.
|
||||
|
||||
See also: [`discovery_cli.md`](discovery_cli.md), [`controller_cli.md`](controller_cli.md).
|
||||
83
docs/cli/reconciler_cli.md
Normal file
83
docs/cli/reconciler_cli.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# reconciler_cli
|
||||
|
||||
An interactive REPL for exploring the reconciler module. Sets up a simulated three-resource state machine (device, transport, stream) with declared dependencies and lets you drive reconciliation manually — useful for understanding reconciler behaviour before wiring it into the node.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./reconciler_cli
|
||||
```
|
||||
|
||||
Drops into an interactive prompt:
|
||||
|
||||
```
|
||||
reconciler> _
|
||||
```
|
||||
|
||||
### Demo resources
|
||||
|
||||
Three resources are pre-configured:
|
||||
|
||||
| Resource | States | Notes |
|
||||
|---|---|---|
|
||||
| `device` | CLOSED → OPEN → STREAMING | Simulates a V4L2 device |
|
||||
| `transport` | DISCONNECTED → CONNECTED | Depends on device=OPEN |
|
||||
| `stream` | INACTIVE → ACTIVE | Depends on transport=CONNECTED and device=STREAMING |
|
||||
|
||||
Dependencies are checked before each transition: the reconciler will not advance a resource until all its prerequisites are met. Blocked resources show the unmet dependency in the status output.
|
||||
|
||||
### Commands
|
||||
|
||||
```
|
||||
status print all resources with current/wanted state and transition status
|
||||
want <name> <state> set the desired state (by number or case-insensitive name)
|
||||
tick run one reconciler tick
|
||||
run tick until stable (max 20 ticks) or a failure occurs
|
||||
fail <name> make the next action for this resource fail (simulates an error)
|
||||
help show commands
|
||||
quit / exit exit
|
||||
```
|
||||
|
||||
### Example session
|
||||
|
||||
```
|
||||
reconciler> want device STREAMING
|
||||
reconciler> want transport CONNECTED
|
||||
reconciler> want stream ACTIVE
|
||||
reconciler> run
|
||||
[device] CLOSED → OPEN ok
|
||||
[device] OPEN → STREAMING ok
|
||||
[transport] DISCONNECTED → CONNECTED ok
|
||||
[stream] INACTIVE → ACTIVE ok
|
||||
reconciler> status
|
||||
device current=STREAMING wanted=STREAMING
|
||||
transport current=CONNECTED wanted=CONNECTED
|
||||
stream current=ACTIVE wanted=ACTIVE
|
||||
reconciler> fail transport
|
||||
reconciler> tick
|
||||
[transport] CONNECTED → DISCONNECTED error (simulated)
|
||||
reconciler> status
|
||||
device current=STREAMING wanted=STREAMING
|
||||
transport current=DISCONNECTED wanted=CONNECTED (will retry)
|
||||
stream current=ACTIVE wanted=ACTIVE (blocked: transport != CONNECTED)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
The reconciler module manages device open/close and transport connect/disconnect in the video node. `reconciler_cli` lets you exercise the BFS pathfinding, dependency checking, and event-driven tick logic without any real hardware — the same code paths that run in the node on every incoming command or timer event.
|
||||
67
docs/cli/stream_recv_cli.md
Normal file
67
docs/cli/stream_recv_cli.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# stream_recv_cli
|
||||
|
||||
Listens for an incoming TCP stream of `VIDEO_FRAME` protocol messages and displays the frames in an X11 window with a per-stream fps/Mbps overlay. Pair with [`stream_send_cli`](stream_send_cli.md) to test end-to-end video transport without a full node.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
Requires GLFW, OpenGL, and libjpeg-turbo.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./stream_recv_cli [--port PORT] [--stream-id N]
|
||||
[--scale stretch|fit|fill|1:1]
|
||||
[--anchor center|topleft]
|
||||
[--x N] [--y N]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--port PORT` | `7700` | TCP port to listen on |
|
||||
| `--stream-id N` | `0` | Filter by stream ID; `0` accepts any stream |
|
||||
| `--scale` | `fit` | Frame scaling in window |
|
||||
| `--anchor` | `center` | Frame alignment in window |
|
||||
| `--x N` | `0` | Window X position |
|
||||
| `--y N` | `0` | Window Y position |
|
||||
|
||||
Press **Q** or **Escape** to close the window.
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
./stream_recv_cli --port 7700 --scale fit
|
||||
```
|
||||
|
||||
### Statistics overlay
|
||||
|
||||
Updated every 0.5 seconds:
|
||||
|
||||
```
|
||||
stream 1: 30.1 fps 18.3 Mbps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture note
|
||||
|
||||
The tool is multi-threaded: transport receive runs on a background thread and deposits frames into a shared slot (mutex + condition variable); the main thread owns the GLFW/OpenGL context and pulls from that slot on each render cycle. This is the same pattern used by the node's display sink.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`stream_recv_cli` tests the sink end of the display pipeline: `VIDEO_FRAME` receive → MJPEG decode → xorg render. The node's display sink (started via `START_DISPLAY`) performs the same operation, driven by the control channel.
|
||||
|
||||
See also: [`stream_send_cli.md`](stream_send_cli.md) for the send side; [`controller_cli.md`](controller_cli.md) to use the full node pipeline.
|
||||
64
docs/cli/stream_send_cli.md
Normal file
64
docs/cli/stream_send_cli.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# stream_send_cli
|
||||
|
||||
Captures MJPEG from a V4L2 device and streams it to a receiver over TCP as `VIDEO_FRAME` protocol messages. Prints per-stream throughput statistics. Pair with [`stream_recv_cli`](stream_recv_cli.md) to test an end-to-end stream without running a full node.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./stream_send_cli [--device PATH] [--host HOST] [--port PORT] [--stream-id N]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--device PATH` | `/dev/video0` | V4L2 capture device |
|
||||
| `--host HOST` | `127.0.0.1` | Receiver hostname or IP |
|
||||
| `--port PORT` | `7700` | Receiver TCP port |
|
||||
| `--stream-id N` | `1` | Stream ID embedded in each `VIDEO_FRAME` message |
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
# Terminal 1 — start a receiver
|
||||
./stream_recv_cli --port 7700
|
||||
|
||||
# Terminal 2 — start the sender
|
||||
./stream_send_cli --device /dev/video0 --host 127.0.0.1 --port 7700 --stream-id 1
|
||||
```
|
||||
|
||||
### Statistics output
|
||||
|
||||
Printed to stderr every 0.5 seconds:
|
||||
|
||||
```
|
||||
stream 1: 30.2 fps 18.4 Mbps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The tool opens the device and selects the highest-FPS MJPEG mode automatically.
|
||||
- Frames are sent as-is (raw MJPEG from the kernel) wrapped in `VIDEO_FRAME` messages — no re-encoding.
|
||||
- The connection is outbound: `stream_send_cli` connects to the receiver, not the other way around. This mirrors the node's START_INGEST behaviour.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`stream_send_cli` tests the source end of the ingest pipeline: V4L2 capture → transport send → `VIDEO_FRAME` messages. The node's ingest module performs the same operation, but driven by `START_INGEST` commands over the control channel.
|
||||
|
||||
See also: [`stream_recv_cli.md`](stream_recv_cli.md) for the receive side; [`v4l2_view_cli.md`](v4l2_view_cli.md) for local-only viewing.
|
||||
70
docs/cli/test_image_cli.md
Normal file
70
docs/cli/test_image_cli.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# test_image_cli
|
||||
|
||||
A development tool for generating test images and writing them to PPM files. Used to verify the `test_image` module's pattern generators and pixel format output without needing a camera or display.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./test_image_cli [--pattern bars|ramp|grid]
|
||||
[--width N] [--height N]
|
||||
[--format yuv420|yuv422|bgra]
|
||||
--out FILE.ppm
|
||||
```
|
||||
|
||||
All options are optional except `--out`.
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--pattern` | `bars` | Test pattern to generate |
|
||||
| `--width N` | `1280` | Image width in pixels |
|
||||
| `--height N` | `720` | Image height in pixels |
|
||||
| `--format` | `yuv420` | Internal pixel format before PPM conversion |
|
||||
| `--out FILE` | required | Output file path |
|
||||
|
||||
### Patterns
|
||||
|
||||
| Name | Description |
|
||||
|---|---|
|
||||
| `bars` | Colour bars (SMPTE-style) |
|
||||
| `ramp` | Luminance ramp from black to white |
|
||||
| `grid` | Crosshatch grid |
|
||||
|
||||
### Formats
|
||||
|
||||
| Name | Description |
|
||||
|---|---|
|
||||
| `yuv420` | Planar YUV 4:2:0 |
|
||||
| `yuv422` | Packed YUV 4:2:2 |
|
||||
| `bgra` | Packed BGRA 8-bit |
|
||||
|
||||
The output is always written as a PPM (RGB) file regardless of format; the internal format affects how the pattern is generated and converted.
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
./test_image_cli --pattern bars --width 1920 --height 1080 --format yuv420 --out bars.ppm
|
||||
```
|
||||
|
||||
Open with any image viewer that supports PPM (e.g. `feh`, `eog`, GIMP).
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`test_image_cli` exercises the `test_image` module used by development tools to inject synthetic frames without a camera. The same module drives the xorg test pattern display in `xorg_cli`.
|
||||
|
||||
See also: [`xorg_cli.md`](xorg_cli.md) for live window rendering.
|
||||
69
docs/cli/transport_cli.md
Normal file
69
docs/cli/transport_cli.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# transport_cli
|
||||
|
||||
A development tool for exercising the transport layer. Starts a framed-TCP server (which echoes received frames) or a client that connects and sends a sequence of test frames.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Server mode
|
||||
|
||||
Listen on a port and echo back every frame received:
|
||||
|
||||
```sh
|
||||
./transport_cli server <port> [max_connections]
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
./transport_cli server 8000
|
||||
```
|
||||
|
||||
Output (per received frame):
|
||||
|
||||
```
|
||||
received frame: type=0x0001 len=8
|
||||
```
|
||||
|
||||
### Client mode
|
||||
|
||||
Connect to a server and send three test frames:
|
||||
|
||||
```sh
|
||||
./transport_cli client <host> <port>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
./transport_cli client 127.0.0.1 8000
|
||||
```
|
||||
|
||||
Each frame carries a fixed payload with a counter:
|
||||
|
||||
```
|
||||
frame 0: payload = deadbeef 00000000
|
||||
frame 1: payload = deadbeef 00000001
|
||||
frame 2: payload = deadbeef 00000002
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`transport_cli` exercises the `transport` module, which provides the framed TCP stream used by all node-to-node communication. The frame header (message type + payload length) and single-write send are the building blocks for the protocol layer.
|
||||
|
||||
See also: [`protocol_cli.md`](protocol_cli.md) for typed message-level testing.
|
||||
69
docs/cli/v4l2_view_cli.md
Normal file
69
docs/cli/v4l2_view_cli.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# v4l2_view_cli
|
||||
|
||||
A live camera viewer. Opens a V4L2 capture device, selects the best available format, and displays the video stream in an X11 window with a real-time FPS and format overlay. Bypasses the node system entirely — useful for verifying a camera works before wiring it into a node.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
Requires GLFW, OpenGL, libjpeg-turbo, and a V4L2-capable kernel.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./v4l2_view_cli [--device PATH]
|
||||
[--width N --height N]
|
||||
[--format mjpeg|yuyv]
|
||||
[--scale stretch|fit|fill|1:1]
|
||||
[--anchor center|topleft]
|
||||
[--x N] [--y N]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--device PATH` | `/dev/video0` | V4L2 device to open |
|
||||
| `--width N` | auto | Capture width; if omitted, selects highest-FPS mode |
|
||||
| `--height N` | auto | Capture height |
|
||||
| `--format` | auto | Prefer `mjpeg` or `yuyv`; auto selects best available |
|
||||
| `--scale` | `fit` | Frame scaling in window |
|
||||
| `--anchor` | `center` | Frame alignment in window |
|
||||
| `--x N` | `0` | Window X position |
|
||||
| `--y N` | `0` | Window Y position |
|
||||
|
||||
Press **Q** or **Escape** to close the window.
|
||||
|
||||
### Auto format selection
|
||||
|
||||
Without `--width`/`--height`, the tool selects the format with the highest frame rate, and within that the largest resolution. This is the same logic the node's ingest module uses.
|
||||
|
||||
### Overlay
|
||||
|
||||
Every 0.5 seconds the overlay updates with:
|
||||
|
||||
```
|
||||
MJPEG 1280x720 @ 30.0 fps
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
./v4l2_view_cli --device /dev/video0 --scale fit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`v4l2_view_cli` is a standalone sanity-check tool. It exercises the same V4L2 format enumeration, mmap capture, MJPEG decode, and xorg rendering path that the node's ingest + display pipeline uses — but without any transport or protocol overhead.
|
||||
|
||||
See also: [`stream_send_cli.md`](stream_send_cli.md) to capture and send over the network; [`xorg_cli.md`](xorg_cli.md) for static test patterns.
|
||||
73
docs/cli/xorg_cli.md
Normal file
73
docs/cli/xorg_cli.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# xorg_cli
|
||||
|
||||
A development tool for testing the xorg viewer sink. Opens an X11 window using GLFW/OpenGL, renders a test pattern at the chosen scale and anchor, and displays a text overlay showing the current date. The window stays open until the user presses Q, Escape, or closes it.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
make cli
|
||||
```
|
||||
|
||||
The binary is placed in `build/cli/`.
|
||||
|
||||
Requires the GLFW, OpenGL, and libjpeg-turbo libraries. If compiled without `HAVE_GLFW`, the binary will print an error and exit.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./xorg_cli [--pattern bars|ramp|grid]
|
||||
[--width N] [--height N]
|
||||
[--format yuv420|bgra]
|
||||
[--scale stretch|fit|fill|1:1]
|
||||
[--anchor center|topleft]
|
||||
[--x N] [--y N]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--pattern` | `bars` | Test pattern to render |
|
||||
| `--width N` | `1280` | Window width in pixels |
|
||||
| `--height N` | `720` | Window height in pixels |
|
||||
| `--format` | `yuv420` | Frame pixel format |
|
||||
| `--scale` | `stretch` | How the frame fills the window |
|
||||
| `--anchor` | `center` | Frame alignment within the window |
|
||||
| `--x N` | `0` | Window X position on screen |
|
||||
| `--y N` | `0` | Window Y position on screen |
|
||||
|
||||
### Scale modes
|
||||
|
||||
| Mode | Description |
|
||||
|---|---|
|
||||
| `stretch` | Fill the window, ignoring aspect ratio |
|
||||
| `fit` | Largest rect that fits, preserving aspect ratio (black bars) |
|
||||
| `fill` | Smallest rect that covers, preserving aspect ratio (crops edges) |
|
||||
| `1:1` | Native pixel size, no scaling |
|
||||
|
||||
### Anchor modes
|
||||
|
||||
Anchor applies when the frame does not fill the window (fit, fill, 1:1 modes):
|
||||
|
||||
| Mode | Description |
|
||||
|---|---|
|
||||
| `center` | Centre the frame in the window |
|
||||
| `topleft` | Align frame to the top-left corner |
|
||||
|
||||
### Example
|
||||
|
||||
```sh
|
||||
./xorg_cli --pattern grid --scale fit --anchor center --width 1920 --height 1080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to the Video Routing System
|
||||
|
||||
`xorg_cli` exercises the `xorg` module used by the video node for its display sink role. The same viewer, scale/anchor logic, and text overlay system are used by `stream_recv_cli` and the node's `START_DISPLAY` command.
|
||||
|
||||
See also: [`v4l2_view_cli.md`](v4l2_view_cli.md) for live camera feed display; [`stream_recv_cli.md`](stream_recv_cli.md) for network stream display.
|
||||
89
docs/codec.md
Normal file
89
docs/codec.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Codec Module
|
||||
|
||||
See [Architecture Overview](../architecture.md).
|
||||
|
||||
A `codec` module provides per-frame encode and decode operations for pixel data. It sits between raw pixel buffers and the transport — sources call encode before sending, sinks call decode after receiving. The relay and transport layers never need to understand pixel formats; they carry opaque payloads.
|
||||
|
||||
## Stream Metadata
|
||||
|
||||
Receivers must know what format a frame payload is in before they can decode it. This is communicated once at stream setup via a `stream_open` control message rather than tagging every frame header. The message carries three fields:
|
||||
|
||||
**`format` (u16)** — the wire format of the payload bytes; determines how the receiver decodes the frame:
|
||||
|
||||
| Value | Format |
|
||||
|---|---|
|
||||
| `0x0001` | MJPEG |
|
||||
| `0x0002` | H.264 |
|
||||
| `0x0003` | H.265 / HEVC |
|
||||
| `0x0004` | AV1 |
|
||||
| `0x0005` | FFV1 |
|
||||
| `0x0006` | ProRes |
|
||||
| `0x0007` | QOI |
|
||||
| `0x0008` | Raw pixels (see `pixel_format`) |
|
||||
| `0x0009` | Raw pixels + ZSTD (see `pixel_format`) |
|
||||
|
||||
**`pixel_format` (u16)** — pixel layout for raw formats; zero and ignored for compressed formats:
|
||||
|
||||
| Value | Layout |
|
||||
|---|---|
|
||||
| `0x0001` | BGRA 8:8:8:8 |
|
||||
| `0x0002` | RGBA 8:8:8:8 |
|
||||
| `0x0003` | BGR 8:8:8 |
|
||||
| `0x0004` | YUV 4:2:0 planar |
|
||||
| `0x0005` | YUV 4:2:2 packed |
|
||||
|
||||
**`origin` (u16)** — how the frame was produced; informational only, does not affect decoding; useful for diagnostics, quality inference, and routing decisions:
|
||||
|
||||
| Value | Origin |
|
||||
|---|---|
|
||||
| `0x0001` | Device native — camera or capture card encoded it directly |
|
||||
| `0x0002` | libjpeg-turbo |
|
||||
| `0x0003` | ffmpeg (libavcodec) |
|
||||
| `0x0004` | ffmpeg (subprocess) |
|
||||
| `0x0005` | VA-API direct |
|
||||
| `0x0006` | NVENC direct |
|
||||
| `0x0007` | Software (other) |
|
||||
|
||||
A V4L2 camera outputting MJPEG has `format=MJPEG, origin=device_native`. The same format re-encoded in process has `format=MJPEG, origin=libjpeg-turbo`. The receiver decodes both identically; the distinction is available for logging and diagnostics without polluting the format identifier.
|
||||
|
||||
## Format Negotiation
|
||||
|
||||
When a source node opens a stream channel it sends a `stream_open` control message that includes the codec identifier. The receiver can reject the codec if it has no decoder for it. This keeps codec knowledge at the edges — relay nodes are unaffected.
|
||||
|
||||
## libjpeg-turbo
|
||||
|
||||
JPEG is the natural first codec: libjpeg-turbo provides SIMD-accelerated encode on both x86 and ARM, the output format is identical to what V4L2 cameras already produce (so the ingest and archive paths treat them the same), and it is universally decodable including in browsers via `<img>` or `createImageBitmap`. Lossy, but quality is configurable.
|
||||
|
||||
## QOI
|
||||
|
||||
QOI (Quite OK Image Format) is a strong candidate for lossless screen grabs: it encodes and decodes in a single pass with no external dependencies, performs well on content with large uniform regions (UIs, text, diagrams), and the reference implementation is a single `.h` file. Output is larger than JPEG but decode is simpler and there is no quality loss. Worth benchmarking against JPEG at high quality settings for screen content.
|
||||
|
||||
## ZSTD over Raw Pixels
|
||||
|
||||
ZSTD at compression level 1 is extremely fast and can achieve meaningful ratios on screen content (which tends to be repetitive). No pixel format conversion is needed — capture raw, compress raw, decompress raw, display raw. This avoids any colour space or chroma subsampling decisions and is entirely lossless. The downside is that even compressed, the payload is larger than JPEG for photographic content; for UI-heavy screens it can be competitive.
|
||||
|
||||
## VA-API (Hardware H.264 Intra)
|
||||
|
||||
Intra-only H.264 via VA-API gives very high compression with GPU offload. This is the most complex option to set up and introduces a GPU dependency, but may be worthwhile for high-resolution grabs over constrained links. Deferred until simpler codecs are validated.
|
||||
|
||||
## ffmpeg Backend
|
||||
|
||||
ffmpeg (via libavcodec or subprocess) is a practical escape hatch that gives access to a large number of codecs, container formats, and hardware acceleration paths without implementing them from scratch. It is particularly useful for archival formats where the encode latency of a more complex codec is acceptable.
|
||||
|
||||
**Integration options:**
|
||||
|
||||
- **libavcodec** — link directly against the library; programmatic API, tight integration, same process; introduces a large build dependency but gives full control over codec parameters and hardware acceleration (NVENC, VA-API, VideoToolbox, etc.)
|
||||
- **subprocess pipe** — spawn `ffmpeg`, pipe raw frames to stdin, read encoded output from stdout; simpler, no build dependency, more isolated from the rest of the node process; latency is higher due to process overhead but acceptable for archival paths where real-time delivery is not required
|
||||
|
||||
The subprocess approach fits naturally into the completeness output path of the relay: frames arrive in order, there is no real-time drop pressure, and the ffmpeg process can be restarted independently if it crashes without taking down the node. libavcodec is the better fit for low-latency encoding (e.g. screen grab over a constrained link).
|
||||
|
||||
**Archival formats of interest:**
|
||||
|
||||
| Format | Notes |
|
||||
|---|---|
|
||||
| H.265 / HEVC | ~50% better compression than H.264 at same quality; NVENC and VA-API hardware support widely available |
|
||||
| AV1 | Best open-format compression; software encode is slow, hardware encode (AV1 NVENC on RTX 30+) is fast |
|
||||
| FFV1 | Lossless, designed for archival; good compression for video content; the format used by film archives |
|
||||
| ProRes | Near-lossless, widely accepted in post-production toolchains; large files but easy to edit downstream |
|
||||
|
||||
The encoder backend is recorded in the `origin` field of `stream_open` — the receiver cares only about `format`, not how the bytes were produced. Switching from a subprocess encode to libavcodec, or from software to hardware, requires no protocol change.
|
||||
62
docs/device-resilience.md
Normal file
62
docs/device-resilience.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Device Resilience
|
||||
|
||||
See [Architecture Overview](../architecture.md).
|
||||
|
||||
Nodes that read from hardware devices (V4L2 cameras, media devices) must handle transient device loss — a USB camera that disconnects and reconnects, a device node that briefly disappears during a mode switch, or a stream that errors out and can be retried. This is not an early implementation concern but has structural implications that should be respected from the start.
|
||||
|
||||
## The Problem by Layer
|
||||
|
||||
**Source node / device reader**
|
||||
|
||||
A device is opened by fd. On a transient disconnect, the fd becomes invalid — reads return errors or short counts. The device may reappear under the same path after some time. Recovery requires closing the bad fd, waiting or polling for the device to reappear, reopening, and restarting the capture loop. Any state tied to the old fd (ioctl configuration, stream-on status) must be re-established.
|
||||
|
||||
**Opaque stream edge**
|
||||
|
||||
The downstream receiver sees bytes stop. There is no mechanism in an opaque stream to distinguish "slow source", "dead source", or "recovered source". A reconnection produces a new byte stream that appears continuous to the receiver — but contains a hard discontinuity. The receiver has no way to know it should reset state. This is a known limitation of opaque mode. If the downstream consumer is sensitive to stream discontinuities (e.g. a frame parser), it must use encapsulated mode on that edge.
|
||||
|
||||
**Encapsulated stream edge**
|
||||
|
||||
The source node sends a `stream_event` message (`0x0004`) on the affected `channel_id` before the bytes stop (if possible) or as the first message when stream resumes. The payload carries an event code:
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0x01` | Stream interrupted — device lost, bytes will stop |
|
||||
| `0x02` | Stream resumed — device recovered, frames will follow |
|
||||
|
||||
On receiving `stream_interrupted`, downstream nodes know to discard any partial frame being assembled and reset parser state. On `stream_resumed`, they know a clean frame boundary follows and can restart cleanly.
|
||||
|
||||
**Ingest module (MJPEG parser)**
|
||||
|
||||
The two-pass EOI state machine is stateful per stream. It must expose an explicit reset operation that discards any partial frame in progress and returns the parser to a clean initial state. This reset is triggered by a `stream_interrupted` event, or by any read error from the device. Any frame allocation begun for the discarded partial frame must be released before the reset completes.
|
||||
|
||||
**Frame allocator**
|
||||
|
||||
A partial frame that was being assembled when the device dropped must be explicitly abandoned. The allocator must support an `abandon` operation distinct from a normal `release` — abandon means the allocation is invalid and any reference tracking for it should be unwound immediately. This prevents a partial allocation from sitting in the accounting tables and consuming budget.
|
||||
|
||||
## Source Node Recovery Loop
|
||||
|
||||
The general structure for a resilient device reader (not yet implemented, for design awareness):
|
||||
|
||||
1. Open device, configure, start capture
|
||||
2. On read error: emit `stream_interrupted` on the transport, close fd, enter retry loop
|
||||
3. Poll for device reappearance (inotify on `/dev`, or timed retry)
|
||||
4. On device back: reopen, reconfigure (ioctl state is lost), emit `stream_resumed`, resume capture
|
||||
5. Log reconnection events to the control plane as observable signals
|
||||
|
||||
The retry loop must be bounded — a device that never returns should eventually cause the node to report a permanent failure rather than loop indefinitely.
|
||||
|
||||
## Implications for Opaque Streams
|
||||
|
||||
If a source node is producing an opaque stream and the device drops, the TCP connection itself may remain open while bytes stop flowing. The downstream node only learns something is wrong via a timeout or its own read error. For this reason, **opaque streams should only be used on edges where the downstream consumer either does not care about discontinuities or has its own out-of-band mechanism to detect them**. Edges into an ingest node must use encapsulated mode.
|
||||
|
||||
---
|
||||
|
||||
## Audio (Future)
|
||||
|
||||
Audio streams are not in scope for the initial implementation but the transport is designed to accommodate them without structural changes.
|
||||
|
||||
A future audio stream is just another message type on an existing transport connection — no new connection type or header field is needed. `stream_id` in the payload already handles multiplexing. The message type table has room for an `audio_frame` type alongside `video_frame`.
|
||||
|
||||
The main open question is codec and container: raw PCM is trivial to handle but large; compressed formats (Opus, AAC) need framing conventions. This is deferred until video is solid.
|
||||
|
||||
The frame allocator, relay, and archive modules should not assume that a frame implies video — they operate on opaque byte payloads with a message type and length, so audio frames will pass through the same infrastructure unchanged.
|
||||
158
docs/discovery.md
Normal file
158
docs/discovery.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Node Discovery and Multi-Site
|
||||
|
||||
See [Architecture Overview](../architecture.md).
|
||||
|
||||
## Node Discovery
|
||||
|
||||
Standard mDNS (RFC 6762) uses UDP multicast over `224.0.0.251:5353` with DNS-SD service records. The wire protocol is well-defined and the multicast group is already in active use on most LANs. The standard service discovery stack (Avahi, Bonjour, `nss-mdns`) provides that transport but brings significant overhead: persistent daemons, D-Bus dependencies, complex configuration surface, and substantial resident memory. None of that is needed here.
|
||||
|
||||
The approach: **reuse the multicast transport, define our own wire format**.
|
||||
|
||||
Rather than DNS wire format, node announcements are encoded as binary frames using the same serialization layer (`serial`) and frame header used for video transport. A node joins the multicast group, broadcasts periodic announcements, and listens for announcements from peers.
|
||||
|
||||
### Announcement Frame
|
||||
|
||||
| Field | Size | Purpose |
|
||||
|---|---|---|
|
||||
| `message_type` | 2 bytes | Discovery message type (e.g. `0x0010` for node announcement) |
|
||||
| `channel_id` | 2 bytes | Reserved / zero |
|
||||
| `payload_length` | 4 bytes | Byte length of payload |
|
||||
| Payload | variable | Encoded node identity and capabilities |
|
||||
|
||||
Payload fields:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `protocol_version` | u8 | Wire format version |
|
||||
| `site_id` | u16 | Site this node belongs to (`0` = local / unassigned) |
|
||||
| `tcp_port` | u16 | Port where this node accepts transport connections |
|
||||
| `function_flags` | u16 | Bitfield declaring node capabilities (see below) |
|
||||
| `name_len` | u8 | Length of name string |
|
||||
| `name` | bytes | Node name (`namespace:instance`, e.g. `v4l2:microscope`) |
|
||||
|
||||
`function_flags` bits:
|
||||
|
||||
| Bit | Mask | Meaning |
|
||||
|---|---|---|
|
||||
| 0 | `0x0001` | Source — produces video |
|
||||
| 1 | `0x0002` | Relay — receives and distributes streams |
|
||||
| 2 | `0x0004` | Sink — consumes video (display, archiver, etc.) |
|
||||
| 3 | `0x0008` | Controller — participates in control plane coordination |
|
||||
|
||||
A node may set multiple bits — a relay that also archives sets both `RELAY` and `SINK`.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- 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 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
|
||||
|
||||
The system does not link against, depend on, or interact with Avahi or Bonjour. It opens a raw UDP multicast socket directly, which requires only standard POSIX socket APIs. This keeps the runtime dependency footprint minimal and the behaviour predictable.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Site (Forward Compatibility)
|
||||
|
||||
The immediate use case is a single LAN. A planned future use case is **site-to-site linking** — two independent networks (e.g. a lab and a remote location) connected by a tunnel (SSH port-forward, WireGuard, etc.), where nodes on both sites are reachable from either side.
|
||||
|
||||
### Site Identity
|
||||
|
||||
Every node carries a `site_id` (`u16`) in its announcement. In a single-site deployment this is always `0`. When sites are joined, each site is assigned a distinct non-zero ID; nodes retain their IDs across the join and are fully addressable by `(site_id, name)` from anywhere in the combined network.
|
||||
|
||||
This field is reserved from day one so that multi-site never requires a wire format change or a rename of existing identifiers.
|
||||
|
||||
### Site Gateway Node
|
||||
|
||||
A site gateway is a node that participates in both networks simultaneously — it has a connection on the local transport and a connection over the inter-site tunnel. It:
|
||||
|
||||
- Bridges discovery announcements between sites (rewriting `site_id` appropriately)
|
||||
- Forwards encapsulated transport frames across the tunnel on behalf of cross-site edges
|
||||
- Is itself a named node, so the control plane can see and reason about it
|
||||
|
||||
The tunnel transport is out of scope for now. The gateway is a node type, not a special infrastructure component — it uses the same wire protocol as everything else.
|
||||
|
||||
### Site ID Translation
|
||||
|
||||
Both sides of a site-to-site link will independently default to `site_id = 0`. A gateway cannot simply forward announcements across the boundary — every node on both sides would appear as site 0 and be indistinguishable.
|
||||
|
||||
The gateway is responsible for **site ID translation**: it assigns a distinct non-zero `site_id` to each side of the link and rewrites the `site_id` field in all announcements and any protocol messages that carry a `site_id` as they cross the boundary. From each side's perspective, remote nodes appear with the translated ID assigned by the gateway; local nodes retain their own IDs.
|
||||
|
||||
This means `site_id = 0` should be treated as "local / unassigned" and never forwarded across a site boundary without translation. A node that receives an announcement with `site_id = 0` on a cross-site link should treat it as a protocol error from the gateway.
|
||||
|
||||
### Addressing
|
||||
|
||||
A fully-qualified node address is `site_id:namespace:instance`. Within a single site, `site_id` is implicit and can be omitted. The control plane and discovery layer must store `site_id` alongside every peer record from the start, even if it is always `0`, so that the upgrade to multi-site addressing requires only configuration and a gateway node — not code changes.
|
||||
93
docs/node-state.md
Normal file
93
docs/node-state.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Node State Model
|
||||
|
||||
See [Architecture Overview](../architecture.md).
|
||||
|
||||
## Wanted vs Current State
|
||||
|
||||
Each node maintains two independent views of its configuration:
|
||||
|
||||
**Wanted state** — the declared intent for this node. Set by the controller via protocol commands and persisted independently of whether the underlying resources are actually running. Examples: "ingest /dev/video0 as stream 3, send to 192.168.1.2:8001", "display stream 3 in a window". Wanted state survives connection drops, device loss, and restarts — it represents what the node *should* be doing.
|
||||
|
||||
**Current state** — what the node is actually doing right now. Derived from which file descriptors are open, which transport connections are established, which processes are running. Changes as resources are acquired or released.
|
||||
|
||||
The controller queries both views to construct the graph. Wanted state gives the topology (what is configured). Current state gives the runtime overlay (what is live, with stats).
|
||||
|
||||
This separation means the web UI can show an edge as grey (configured but not connected), green (connected and streaming), or red (configured but failed) without any special-casing — the difference is just whether wanted and current state agree.
|
||||
|
||||
## Reconciler
|
||||
|
||||
A generic reconciler closes the gap between wanted and current state. It is invoked:
|
||||
|
||||
- **On event** — transport disconnect, device error, process exit, `STREAM_OPEN` received: fast response to state changes
|
||||
- **On periodic tick** — safety net; catches external failures that produce no callback (e.g. a device that silently disappears and reappears)
|
||||
|
||||
The reconciler does not know what a "stream" or a "V4L2 device" is. It operates on abstract state machines, each representing one resource. Resources declare their states, transitions, and dependencies; the reconciler finds the path from current to wanted state and executes the transitions in order.
|
||||
|
||||
## Resource State Machines
|
||||
|
||||
Each managed resource is described as a directed graph:
|
||||
|
||||
- **Nodes** are states (e.g. `CLOSED`, `OPEN`, `STREAMING`)
|
||||
- **Edges** are transitions with associated actions (e.g. `open_fd`, `close_fd`, `connect_transport`, `spawn_process`)
|
||||
- **Dependencies** between resources constrain ordering (e.g. transport connection requires device open)
|
||||
|
||||
The state graphs are small and defined at compile time. Pathfinding is BFS — with 3–5 states per resource the cost is negligible. The benefit is that adding a new resource type (e.g. an ffmpeg subprocess for codec work) requires only defining its state graph and declaring its dependencies; the reconciler's core logic is unchanged.
|
||||
|
||||
**Example resource state graphs:**
|
||||
|
||||
V4L2 capture device:
|
||||
```
|
||||
CLOSED → OPEN → STREAMING
|
||||
```
|
||||
Transitions: `open_fd` (CLOSED→OPEN), `start_capture` (OPEN→STREAMING), `stop_capture` (STREAMING→OPEN), `close_fd` (OPEN→CLOSED).
|
||||
|
||||
Outbound transport connection:
|
||||
```
|
||||
DISCONNECTED → CONNECTING → CONNECTED
|
||||
```
|
||||
Transitions: `connect` (DISCONNECTED→CONNECTING), `connected_cb` (CONNECTING→CONNECTED), `disconnect` (CONNECTED→DISCONNECTED).
|
||||
|
||||
External codec process:
|
||||
```
|
||||
STOPPED → STARTING → RUNNING
|
||||
```
|
||||
Transitions: `spawn` (STOPPED→STARTING), `ready_cb` (STARTING→RUNNING), `kill` (RUNNING→STOPPED).
|
||||
|
||||
Dependency example: "outbound transport connection" requires "V4L2 device open". The reconciler will not attempt to connect the transport until the device is in state `OPEN` or `STREAMING`.
|
||||
|
||||
## Generic Implementation
|
||||
|
||||
The reconciler is implemented as a standalone module (`reconciler`) that is not specific to video. It operates on:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
int state_count;
|
||||
int current_state;
|
||||
int wanted_state;
|
||||
/* transition table: [from][to] → action fn + dependency list */
|
||||
} Resource;
|
||||
```
|
||||
|
||||
This makes it reusable across any node component in the project — not just video ingest. The video node registers its resources (device, transport connection, display sink) and their dependencies, then calls `reconciler_tick()` on events and periodically.
|
||||
|
||||
## Per-Stream Stats
|
||||
|
||||
Live fps and throughput are tracked per stream using a header-only rolling-window tracker (`include/stream_stats.h`). It maintains a 0.5s window and recomputes `fps` and `mbps` each time `stream_stats_tick()` returns true. Stats are recorded by calling `stream_stats_record_frame()` on each frame. The tracker is used directly in the ingest and sink paths and feeds the runtime state reported to the controller.
|
||||
|
||||
## Node State Queries
|
||||
|
||||
Two protocol commands allow the controller to query a node's state (planned — not yet implemented in the protocol module):
|
||||
|
||||
**`GET_CONFIG_STATE`** — returns the wanted state: which streams the node is configured to produce or consume, their destinations/sources, format, stream ID. This is the topology view — what is configured regardless of whether it is currently active.
|
||||
|
||||
**`GET_RUNTIME_STATE`** — returns the current state: which resources are in which reconciler state, live fps/mbps per stream (from `stream_stats`), error codes for any failed resources.
|
||||
|
||||
The controller queries all discovered nodes, correlates streams by ID and peer address, and reconstructs the full graph from the union of responses. No central authority is needed — the graph emerges from the node state reports.
|
||||
|
||||
## Stream ID Assignment
|
||||
|
||||
Stream IDs are assigned by the controller, not by individual nodes. This ensures that when node A reports "I am sending stream 3 to B" and node B reports "I am receiving stream 3 from A", the IDs match and the edge can be reconstructed. Each `START_INGEST` or `START_SINK` command from the controller includes the stream ID to use.
|
||||
|
||||
## Connection Direction
|
||||
|
||||
The source node connects outbound to the sink's transport server port. A single TCP port per node is the default — all traffic (video frames, control messages, state queries) flows through it in both directions. Dedicated per-stream ports on separate listening sockets are a future option for high-bandwidth links and must be represented in state reporting so the graph reconstructs correctly regardless of which port a connection uses.
|
||||
340
docs/protocol.md
340
docs/protocol.md
@@ -28,16 +28,15 @@ flowchart TD
|
||||
|
||||
Every message on the wire is a frame:
|
||||
|
||||
```
|
||||
+------------------+------------------------+--------------------+
|
||||
| message_type: u16 | payload_length: u32 | payload: bytes ... |
|
||||
+------------------+------------------------+--------------------+
|
||||
2 bytes 4 bytes payload_length bytes
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 48}}}%%
|
||||
packet-beta
|
||||
0-15: "message_type"
|
||||
16-47: "payload_length"
|
||||
48-95: "payload …"
|
||||
```
|
||||
|
||||
Total header size: **6 bytes**.
|
||||
|
||||
`payload_length` is the byte count of the payload only — it does not include the 6-byte header itself.
|
||||
Total header size: **6 bytes**. `payload_length` is the byte count of the payload only — it does not include the 6-byte header itself.
|
||||
|
||||
A node that does not recognise `message_type` can skip the frame by consuming exactly `payload_length` bytes and discarding them. This allows relays and future nodes to forward or ignore unknown message types without understanding their structure.
|
||||
|
||||
@@ -69,22 +68,22 @@ Values not listed are reserved. A node receiving an unknown type must skip the p
|
||||
|
||||
### `VIDEO_FRAME` (0x0001)
|
||||
|
||||
```
|
||||
+---------------+----------------------------------------------+
|
||||
| stream_id: u16 | frame data (compressed, codec per stream_open) |
|
||||
+---------------+----------------------------------------------+
|
||||
2 bytes payload_length - 2 bytes
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 16}}}%%
|
||||
packet-beta
|
||||
0-15: "stream_id"
|
||||
16-31: "frame data …"
|
||||
```
|
||||
|
||||
`stream_id` identifies which video stream this frame belongs to. The codec is established at stream open time (see [Stream Lifecycle](#stream-lifecycle)) and does not appear in every frame.
|
||||
|
||||
### `CONTROL_REQUEST` (0x0002)
|
||||
|
||||
```
|
||||
+------------------+----------------+---------------------------+
|
||||
| request_id: u16 | command: u16 | command-specific fields |
|
||||
+------------------+----------------+---------------------------+
|
||||
2 bytes 2 bytes remaining bytes
|
||||
```mermaid
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "command"
|
||||
32-63: "command-specific …"
|
||||
```
|
||||
|
||||
`request_id` is chosen by the sender and echoed in the matching `CONTROL_RESPONSE`. It is used to correlate responses to requests when multiple requests are in flight simultaneously.
|
||||
@@ -100,14 +99,18 @@ Values not listed are reserved. A node receiving an unknown type must skip the p
|
||||
| `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)
|
||||
|
||||
```
|
||||
+------------------+---------------+---------------------------+
|
||||
| request_id: u16 | status: u16 | response-specific fields |
|
||||
+------------------+---------------+---------------------------+
|
||||
2 bytes 2 bytes remaining bytes
|
||||
```mermaid
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "status"
|
||||
32-63: "response-specific …"
|
||||
```
|
||||
|
||||
`request_id` matches the originating request. `status` values:
|
||||
@@ -122,11 +125,12 @@ Values not listed are reserved. A node receiving an unknown type must skip the p
|
||||
|
||||
### `STREAM_EVENT` (0x0004)
|
||||
|
||||
```
|
||||
+---------------+------------------+---------------------------+
|
||||
| stream_id: u16 | event_code: u8 | event-specific fields |
|
||||
+---------------+------------------+---------------------------+
|
||||
2 bytes 1 byte remaining bytes
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 8}}}%%
|
||||
packet-beta
|
||||
0-15: "stream_id"
|
||||
16-23: "event_code"
|
||||
24-55: "event-specific …"
|
||||
```
|
||||
|
||||
`event_code` values:
|
||||
@@ -144,12 +148,14 @@ Before video frames can flow, a stream must be opened. This establishes the code
|
||||
|
||||
### Opening a Stream (`STREAM_OPEN` request)
|
||||
|
||||
The sender issues a `CONTROL_REQUEST` with command `STREAM_OPEN`:
|
||||
|
||||
```
|
||||
+------------------+------------------+-----------------+------------------+-------------------+----------------+
|
||||
| request_id: u16 | command: u16 | stream_id: u16 | format: u16 | pixel_format: u16 | origin: u16 |
|
||||
+------------------+------------------+-----------------+------------------+-------------------+----------------+
|
||||
```mermaid
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "command = 0x0001"
|
||||
32-47: "stream_id"
|
||||
48-63: "format"
|
||||
64-79: "pixel_format"
|
||||
80-95: "origin"
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
@@ -163,10 +169,12 @@ The receiver responds with `CONTROL_RESPONSE`. On `status = OK` the stream is op
|
||||
|
||||
### Closing a Stream (`STREAM_CLOSE` request)
|
||||
|
||||
```
|
||||
+------------------+------------------+-----------------+
|
||||
| request_id: u16 | command: u16 | stream_id: u16 |
|
||||
+------------------+------------------+-----------------+
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 16}}}%%
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "command = 0x0002"
|
||||
32-47: "stream_id"
|
||||
```
|
||||
|
||||
After a close response, no further `VIDEO_FRAME` messages should be sent for that `stream_id`.
|
||||
@@ -225,10 +233,15 @@ Node discovery uses UDP multicast. The wire format is a standard transport frame
|
||||
|
||||
Sent periodically by every node and immediately on startup.
|
||||
|
||||
```
|
||||
+----------------------+------------+-----------------+------------------+-------------------+------------------+
|
||||
| protocol_version: u8 | site_id: u16 | tcp_port: u16 | function_flags: u16 | name_len: u8 | name: bytes |
|
||||
+----------------------+------------+-----------------+------------------+-------------------+------------------+
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 8}}}%%
|
||||
packet-beta
|
||||
0-7: "protocol_version"
|
||||
8-23: "site_id"
|
||||
24-39: "tcp_port"
|
||||
40-55: "function_flags"
|
||||
56-63: "name_len"
|
||||
64-95: "name …"
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
@@ -266,3 +279,246 @@ No Avahi or Bonjour dependency — nodes open a raw UDP multicast socket directl
|
||||
`site_id = 0` means "local / unassigned". Both sides of a site-to-site link will independently default to `0`, so a gateway cannot forward announcements across the boundary without rewriting the field — all nodes on both sides would appear identical.
|
||||
|
||||
The gateway assigns a distinct non-zero `site_id` to each side and rewrites `site_id` in all announcements (and any protocol messages carrying a `site_id`) as they cross the boundary. Receiving an announcement with `site_id = 0` on a cross-site link is a gateway protocol error.
|
||||
|
||||
---
|
||||
|
||||
## Serialisation Primitives
|
||||
|
||||
`str8` — a length-prefixed UTF-8 string, not NUL-terminated on the wire:
|
||||
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 8}}}%%
|
||||
packet-beta
|
||||
0-7: "length"
|
||||
8-15: "data (≤ 255 bytes)"
|
||||
```
|
||||
|
||||
Maximum string length is 255 bytes.
|
||||
|
||||
---
|
||||
|
||||
## Control Commands
|
||||
|
||||
All requests follow the `CONTROL_REQUEST` frame format (request_id + command + command-specific fields). All responses follow the `CONTROL_RESPONSE` frame format (request_id + status + response-specific fields).
|
||||
|
||||
### `ENUM_DEVICES` (0x0003)
|
||||
|
||||
**Request** — no extra fields beyond request_id and command.
|
||||
|
||||
**Response** on status `OK`:
|
||||
|
||||
Repeated `media_count` times (u16):
|
||||
|
||||
- `path` str8, `driver` str8, `model` str8, `bus_info` str8
|
||||
- `vnode_count` u8, then repeated `vnode_count` times:
|
||||
- `path` str8, `entity_name` str8
|
||||
- `entity_type` u32, `entity_flags` u32, `device_caps` u32
|
||||
- `pad_flags` u8, `is_capture` u8
|
||||
|
||||
Then repeated `standalone_count` times (u16):
|
||||
|
||||
- `path` str8, `name` str8
|
||||
|
||||
`device_caps` carries `V4L2_CAP_*` bits from `VIDIOC_QUERYCAP` (using `device_caps` if `V4L2_CAP_DEVICE_CAPS` is set, otherwise `capabilities`). Notable bits: `0x00000001` = `VIDEO_CAPTURE`, `0x00800000` = `META_CAPTURE`.
|
||||
|
||||
`is_capture` is `1` if `device_caps & V4L2_CAP_VIDEO_CAPTURE`, else `0`.
|
||||
|
||||
### `ENUM_CONTROLS` (0x0004)
|
||||
|
||||
**Request**:
|
||||
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 16}}}%%
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "command = 0x0004"
|
||||
32-47: "device_index"
|
||||
```
|
||||
|
||||
**Response** on status `OK` — repeated `count` times (u16):
|
||||
|
||||
Fixed prefix per control:
|
||||
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 8}}}%%
|
||||
packet-beta
|
||||
0-31: "id"
|
||||
32-39: "type"
|
||||
40-71: "flags"
|
||||
```
|
||||
|
||||
Followed by `name` str8, then the fixed suffix:
|
||||
|
||||
```mermaid
|
||||
packet-beta
|
||||
0-31: "min"
|
||||
32-63: "max"
|
||||
64-95: "step"
|
||||
96-127: "default_val"
|
||||
128-159: "current_val"
|
||||
160-167: "menu_count"
|
||||
```
|
||||
|
||||
Followed by `menu_count` menu items. Each menu item: `index` u32, then `name` str8, then `int_value` i64.
|
||||
|
||||
`type` values match `V4L2_CTRL_TYPE_*`:
|
||||
|
||||
| Value | Type |
|
||||
|---|---|
|
||||
| `1` | `INTEGER` — use a slider |
|
||||
| `2` | `BOOLEAN` — use a checkbox |
|
||||
| `3` | `MENU` — use a select; `name` of each menu item is the label |
|
||||
| `4` | `BUTTON` — use a button |
|
||||
| `9` | `INTEGER_MENU` — use a select; `int_value` of each menu item is the label |
|
||||
|
||||
`flags` bits (from `V4L2_CTRL_FLAG_*`): `0x0001` = disabled, `0x0002` = grabbed (read-only due to another active control), `0x0004` = read-only.
|
||||
|
||||
For `MENU` and `INTEGER_MENU` controls, set the control value to a menu item's `index` (not its `int_value`). `int_value` is informational display text only.
|
||||
|
||||
Menu items may have non-contiguous `index` values (gaps where the driver returns `EINVAL` for `VIDIOC_QUERYMENU`).
|
||||
|
||||
### `GET_CONTROL` (0x0005)
|
||||
|
||||
**Request**:
|
||||
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 16}}}%%
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "command = 0x0005"
|
||||
32-47: "device_index"
|
||||
48-79: "control_id"
|
||||
```
|
||||
|
||||
**Response** on status `OK`:
|
||||
|
||||
```mermaid
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "status"
|
||||
32-63: "value"
|
||||
```
|
||||
|
||||
### `SET_CONTROL` (0x0006)
|
||||
|
||||
**Request**:
|
||||
|
||||
```mermaid
|
||||
%%{init: {'packet': {'bitsPerRow': 16}}}%%
|
||||
packet-beta
|
||||
0-15: "request_id"
|
||||
16-31: "command = 0x0006"
|
||||
32-47: "device_index"
|
||||
48-79: "control_id"
|
||||
80-111: "value"
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
94
docs/relay.md
Normal file
94
docs/relay.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Relay Design
|
||||
|
||||
See [Architecture Overview](../architecture.md).
|
||||
|
||||
A relay receives frames from one or more upstream sources and distributes them to any number of outputs. Each output is independently configured with a **delivery mode** that determines how it handles the tension between latency and completeness.
|
||||
|
||||
## Output Delivery Modes
|
||||
|
||||
**Low-latency mode** — minimize delay, accept loss
|
||||
|
||||
The output holds at most one pending frame. When a new frame arrives:
|
||||
- If the slot is empty, the frame occupies it and is sent as soon as the transport allows
|
||||
- If the slot is already occupied (transport not ready), the incoming frame is dropped — the pending frame is already stale enough
|
||||
|
||||
The consumer always receives the most recent frame the transport could deliver. Frame loss is expected and acceptable.
|
||||
|
||||
**Completeness mode** — minimize loss, accept delay
|
||||
|
||||
The output maintains a queue. When a new frame arrives it is enqueued. The transport drains the queue in order. When the queue is full, a drop policy is applied — either drop the oldest frame (preserve recency) or drop the newest (preserve continuity). Which policy fits depends on the consumer: an archiver may prefer continuity; a scrubber may prefer recency.
|
||||
|
||||
## Memory Model
|
||||
|
||||
Compressed frames have variable sizes (I-frames vs P-frames, quality settings, scene complexity), so fixed-slot buffers waste memory unpredictably. The preferred model is **per-frame allocation** with explicit bookkeeping.
|
||||
|
||||
Each allocated frame is tracked with at minimum:
|
||||
- Byte size
|
||||
- Sequence number or timestamp
|
||||
- Which outputs still hold a reference
|
||||
|
||||
Limits are enforced per output independently — not as a shared pool — so a slow completeness output cannot starve a low-latency output or exhaust global memory. Per-output limits have two axes:
|
||||
- **Frame count** — cap on number of queued frames
|
||||
- **Byte budget** — cap on total bytes in flight for that output
|
||||
|
||||
Both limits should be configurable. Either limit being reached triggers the drop policy.
|
||||
|
||||
## Congestion: Two Sides
|
||||
|
||||
Congestion can arise at both ends of the relay and must be handled explicitly on each.
|
||||
|
||||
**Inbound congestion (upstream → relay)**
|
||||
|
||||
If the upstream source produces frames faster than any output can dispatch them:
|
||||
- Low-latency outputs are unaffected by design — they always hold at most one frame
|
||||
- Completeness outputs will see their queues grow; limits and drop policy absorb the excess
|
||||
|
||||
The relay never signals backpressure to the upstream. It is the upstream's concern to produce frames at a sustainable rate; the relay's concern is only to handle whatever arrives without blocking.
|
||||
|
||||
**Outbound congestion (relay → downstream transport)**
|
||||
|
||||
If the transport layer cannot accept a frame immediately:
|
||||
- Low-latency mode: the pending frame is dropped when the next frame arrives; the transport sends the newest frame it can when it becomes ready
|
||||
- Completeness mode: the frame stays in the queue; the queue grows until the transport catches up or limits are reached
|
||||
|
||||
The interaction between outbound congestion and the byte budget is important: a transport that is consistently slow will fill the completeness queue to its byte budget limit, at which point the drop policy engages. This is the intended safety valve — the budget defines the maximum acceptable latency inflation before the system reverts to dropping.
|
||||
|
||||
## Congestion Signals
|
||||
|
||||
Even though the relay does not apply backpressure, it should emit **observable congestion signals** — drop counts, queue depth, byte utilization — on the control plane so that the controller can make decisions: reduce upstream quality, reroute, alert, or adjust budgets dynamically.
|
||||
|
||||
## Multi-Input Scheduling
|
||||
|
||||
When a relay has multiple input sources feeding the same output, it needs a policy for which source's frame to forward next when the link is under pressure or when frames from multiple sources are ready simultaneously. This policy is the **scheduler**.
|
||||
|
||||
The scheduler is a separate concern from delivery mode (low-latency vs completeness) — delivery mode governs buffering and drop behaviour per output; the scheduler governs which input is served when multiple compete.
|
||||
|
||||
Candidate policies (not exhaustive — the design should keep the scheduler pluggable):
|
||||
|
||||
| Policy | Behaviour |
|
||||
|---|---|
|
||||
| **Strict priority** | Always prefer the highest-priority source; lower-priority sources are only forwarded when no higher-priority frame is pending |
|
||||
| **Round-robin** | Cycle evenly across all active inputs — one frame from each in turn |
|
||||
| **Weighted round-robin** | Each input has a weight; forwarding interleaves at the given ratio (e.g. 1:3 means one frame from source A per three from source B) |
|
||||
| **Deficit round-robin** | Byte-fair rather than frame-fair variant of weighted round-robin; useful when sources have very different frame sizes |
|
||||
| **Source suppression** | A congested or degraded link simply stops forwarding from a given input entirely until conditions improve |
|
||||
|
||||
Priority remains a property of the path (set at connection time). The scheduler uses those priorities plus runtime state (queue depths, drop rates) to make per-frame decisions.
|
||||
|
||||
The `relay` module should expose a scheduler interface so policies are interchangeable without touching routing logic. Which policies to implement first is an open question — see [Open Questions](../architecture.md#open-questions).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
UP1[Upstream Source A] -->|encapsulated stream| RELAY[Relay]
|
||||
UP2[Upstream Source B] -->|encapsulated stream| RELAY
|
||||
|
||||
RELAY --> LS[Low-latency Output<br>single-slot<br>drop on collision]
|
||||
RELAY --> CS[Completeness Output<br>queued<br>drop on budget exceeded]
|
||||
RELAY --> OB[Opaque Output<br>byte pipe<br>no frame awareness]
|
||||
|
||||
LS -->|encapsulated| LC[Low-latency Consumer<br>eg. preview display]
|
||||
CS -->|encapsulated| CC[Completeness Consumer<br>eg. archiver]
|
||||
OB -->|opaque| RAW[Raw Consumer<br>eg. disk writer]
|
||||
|
||||
RELAY -.->|drop count<br>queue depth<br>byte utilization| CTRL[Controller node]
|
||||
```
|
||||
131
docs/transport.md
Normal file
131
docs/transport.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Transport Protocol
|
||||
|
||||
See [Architecture Overview](../architecture.md).
|
||||
|
||||
Transport between nodes operates in one of two modes. The choice is per-edge and has direct implications for what the relay on that edge can do.
|
||||
|
||||
## Opaque Binary Stream
|
||||
|
||||
The transport forwards bytes as they arrive with no understanding of frame boundaries. The relay acts as a pure byte pipe.
|
||||
|
||||
- Zero framing overhead
|
||||
- Cannot drop frames (frame boundaries are unknown)
|
||||
- Cannot multiplex multiple streams (no way to distinguish them)
|
||||
- Cannot do per-frame accounting (byte budgets become byte-rate estimates only)
|
||||
- Low-latency output is not available — the relay cannot discard a partial frame
|
||||
|
||||
This mode is appropriate for simple point-to-point forwarding where the consumer handles all framing, and where the relay has no need for frame-level intelligence.
|
||||
|
||||
## Frame-Encapsulated Stream
|
||||
|
||||
Each message is prefixed with a small fixed-size header. This applies to both video frames and control messages — the transport is unified.
|
||||
|
||||
Header fields:
|
||||
|
||||
| Field | Size | Purpose |
|
||||
|---|---|---|
|
||||
| `message_type` | 2 bytes | Determines how the payload is interpreted |
|
||||
| `payload_length` | 4 bytes | Byte length of the following payload |
|
||||
|
||||
The header is intentionally minimal. Any node — including a relay that does not recognise a message type — can skip or forward the frame by reading exactly `payload_length` bytes without needing to understand the payload. All message-specific identifiers (stream ID, correlation ID, etc.) live inside the payload and are handled by the relevant message type handler.
|
||||
|
||||
**Message types and their payload structure:**
|
||||
|
||||
| Value | Type | Payload starts with |
|
||||
|---|---|---|
|
||||
| `0x0001` | Video frame | `stream_id` (u16), then compressed frame data |
|
||||
| `0x0002` | Control request | `request_id` (u16), then command-specific fields |
|
||||
| `0x0003` | Control response | `request_id` (u16), then result-specific fields |
|
||||
| `0x0004` | Stream event | `stream_id` (u16), `event_code` (u8), then event-specific fields |
|
||||
|
||||
Node-level messages (not tied to any stream or request) have no prefix beyond the header — the payload begins with the message-specific fields directly.
|
||||
|
||||
Control payloads are binary-serialized structures — see [Protocol Serialization](#protocol-serialization). Stream events carry lifecycle signals — see [Device Resilience](./device-resilience.md).
|
||||
|
||||
## Unified Control and Video on One Connection
|
||||
|
||||
By carrying control messages on the same transport as video frames, the system avoids managing separate connections per peer. A node that receives a video stream can be queried or commanded over the same socket.
|
||||
|
||||
This directly enables **remote device enumeration**: a connecting node can issue a control request asking what V4L2 devices the remote host exposes, and receive the list in a control response — before any video streams are established. Discovery and streaming share the same channel.
|
||||
|
||||
The V4L2 control operations map naturally to control request/response pairs:
|
||||
|
||||
| Operation | Direction |
|
||||
|---|---|
|
||||
| Enumerate devices | request → response |
|
||||
| Get device controls (parameters, ranges, menus) | request → response |
|
||||
| Get control values | request → response |
|
||||
| Set control values | request → response (ack/fail) |
|
||||
|
||||
Control messages are low-volume and can be interleaved with the video frame stream without meaningful overhead.
|
||||
|
||||
## Capability Implications
|
||||
|
||||
| Feature | Opaque | Encapsulated |
|
||||
|---|---|---|
|
||||
| Simple forwarding | yes | yes |
|
||||
| Low-latency drop | **no** | yes |
|
||||
| Per-frame byte accounting | **no** | yes |
|
||||
| Multi-stream over one transport | **no** | yes |
|
||||
| Sequence numbers / timestamps | **no** | yes (via extension) |
|
||||
| Control / command channel | **no** | yes |
|
||||
| Remote device enumeration | **no** | yes |
|
||||
| Stream lifecycle signals | **no** | yes |
|
||||
|
||||
The most important forcing function is **low-latency relay**: to drop a pending frame when a newer one arrives, the relay must know where frames begin and end. An opaque stream cannot support this, so any edge that requires low-latency output must use encapsulation.
|
||||
|
||||
Opaque streams are a valid optimization for leaf edges where the downstream consumer (e.g. an archiver writing raw bytes to disk) does its own framing, requires no relay intelligence, and has no need for remote control.
|
||||
|
||||
---
|
||||
|
||||
## Protocol Serialization
|
||||
|
||||
Control message payloads use a compact binary format. The wire encoding is **little-endian** throughout — all target platforms (Raspberry Pi ARM, x86 laptop) are little-endian, and little-endian is the convention of most modern protocols (USB, Bluetooth LE, etc.).
|
||||
|
||||
### Serialization Layer
|
||||
|
||||
A `serial` module provides the primitive read/write operations on byte buffers:
|
||||
|
||||
- `put_u8`, `put_u16`, `put_u32`, `put_i32`, `put_u64` — write a value at a position in a buffer
|
||||
- `get_u8`, `get_u16`, `get_u32`, `get_i32`, `get_u64` — read a value from a position in a buffer
|
||||
|
||||
These are pure buffer operations with no I/O. Fields are never written by casting a struct to bytes — each field is placed explicitly, which eliminates struct padding and alignment assumptions.
|
||||
|
||||
### Protocol Layer
|
||||
|
||||
A `protocol` module builds on `serial` and the transport to provide typed message functions:
|
||||
|
||||
```c
|
||||
write_v4l2_set_control(stream, id, value);
|
||||
write_v4l2_get_control(stream, id);
|
||||
write_v4l2_enumerate_controls(stream);
|
||||
```
|
||||
|
||||
Each `write_*` function knows the exact wire layout of its message, packs the full frame (header + payload) into a stack buffer using `put_*`, then issues a single write to the stream. The corresponding `read_*` functions unpack responses using `get_*`.
|
||||
|
||||
This gives a clean two-layer separation: `serial` handles byte layout, `protocol` handles message semantics and I/O.
|
||||
|
||||
### Web Interface as a Protocol Peer
|
||||
|
||||
The web interface (Node.js/Express) participates in the graph as a first-class protocol peer — it speaks the same binary protocol as any C node. There is no JSON bridge or special C code to serve the web layer. The boundary is:
|
||||
|
||||
- **Socket side**: binary protocol, framed messages, little-endian fields read with `DataView` (`dataView.getUint32(offset, true)` maps directly to `get_u32`)
|
||||
- **Browser side**: HTTP/WebSocket, JSON, standard web APIs
|
||||
|
||||
A `protocol.mjs` module in the web layer mirrors the C `protocol` module — same message types, same wire layout, different language. This lets the web interface connect to any video node, send control requests (V4L2 enumeration, parameter get/set, device discovery), and receive structured responses.
|
||||
|
||||
Treating the web node as a peer also means it exercises the real protocol, which surfaces bugs that a JSON bridge would hide.
|
||||
|
||||
### Future: Single Source of Truth via Preprocessor
|
||||
|
||||
The C `protocol` module and the JavaScript `protocol.mjs` currently encode the same wire format in two languages. This duplication is a drift risk — a change to a message layout must be applied in both places.
|
||||
|
||||
A future preprocessor will eliminate this. Protocol messages will be defined once in a language-agnostic schema, and the preprocessor will emit both:
|
||||
- C source — `put_*`/`get_*` calls, struct definitions, `write_*`/`read_*` functions
|
||||
- ESM JavaScript — `DataView`-based encode/decode, typed constants
|
||||
|
||||
The preprocessor is the same tool planned for generating error location codes (see `common/error`). The protocol schema becomes a single source of truth, and both the C and JavaScript implementations are derived artifacts.
|
||||
|
||||
---
|
||||
|
||||
For the full message payload schemas see [Protocol Reference](./protocol.md).
|
||||
126
docs/xorg.md
Normal file
126
docs/xorg.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# X11 / Xorg Integration
|
||||
|
||||
See [Architecture Overview](../architecture.md).
|
||||
|
||||
An `xorg` module provides two capabilities that complement the V4L2 camera pipeline: screen geometry queries and an X11-based video feed viewer. Both operate as first-class node roles.
|
||||
|
||||
## Screen Geometry Queries (XRandR)
|
||||
|
||||
Using the XRandR extension, the module can enumerate connected outputs and retrieve their geometry — resolution, position within the desktop coordinate space, physical size, and refresh rate. This is useful for:
|
||||
|
||||
- **Routing decisions**: knowing the resolution of the target display before deciding how to scale or crop an incoming stream
|
||||
- **Screen grab source**: determining the exact rectangle to capture for a given monitor
|
||||
- **Multi-monitor layouts**: placing viewer windows correctly in a multi-head setup without guessing offsets
|
||||
|
||||
Queries are exposed as control request/response pairs on the standard transport, so a remote node can ask "what monitors does this machine have?" and receive structured geometry data without any X11 code on the asking side.
|
||||
|
||||
## Screen Grab Source
|
||||
|
||||
The module can act as a video source by capturing the contents of a screen region using `XShmGetImage` (MIT-SHM extension) for zero-copy capture within the same machine. The captured region is a configurable rectangle — typically one full monitor by its XRandR geometry, but can be any sub-region.
|
||||
|
||||
Raw captured pixels are uncompressed — 1920×1080 at 32 bpp is ~8 MB per frame. Before the frame enters the transport it must be encoded. The grab loop calls the `codec` module to compress each frame, then encapsulates the result. The codec is configured per stream; see [Codec Module](./codec.md).
|
||||
|
||||
The grab loop produces frames at a configured rate, encapsulates them, and feeds them into the transport like any other video source. Combined with geometry queries, a remote controller can enumerate monitors, select one, and start a screen grab stream without manual coordinate configuration.
|
||||
|
||||
## Frame Viewer Sink
|
||||
|
||||
The module can act as a video sink by creating a window and rendering the latest received frame into it. The window:
|
||||
|
||||
- Geometry (size and monitor placement) is specified at stream open time, using XRandR data when targeting a specific output
|
||||
- Can be made fullscreen on a chosen output
|
||||
- Displays the most recently received frame — driven by the low-latency output mode of the relay; never buffers for completeness
|
||||
- Forwards keyboard and mouse events back upstream as `INPUT_EVENT` protocol messages, enabling remote control use cases
|
||||
|
||||
Scale and crop are applied in the renderer. Four display modes are supported (selected per viewer):
|
||||
|
||||
| Mode | Behaviour |
|
||||
|---|---|
|
||||
| `STRETCH` | Fill window, ignore aspect ratio |
|
||||
| `FIT` | Largest rect that fits, preserve aspect, black bars |
|
||||
| `FILL` | Scale to cover, preserve aspect, crop edges |
|
||||
| `1:1` | Native pixel size, no scaling; excess cropped |
|
||||
|
||||
Each mode combines with an anchor (`CENTER` or `TOP_LEFT`) that controls placement when the frame does not fill the window exactly.
|
||||
|
||||
This allows a high-resolution source (Pi camera, screen grab) to be displayed scaled-down on a different machine, or viewed at native resolution with panning.
|
||||
|
||||
This makes it the display-side counterpart of the V4L2 capture source: a frame grabbed from a camera on a Pi can be viewed on any machine in the network running a viewer sink node, with the relay handling the path and delivery mode.
|
||||
|
||||
### Renderer: GLFW + OpenGL
|
||||
|
||||
The initial implementation uses **GLFW** for window and input management and **OpenGL** for rendering.
|
||||
|
||||
GLFW handles window creation, the event loop, resize, and input callbacks — it also supports Vulkan surface creation using the same API, which makes a future renderer swap straightforward. Input events (keyboard, mouse) are normalised by GLFW before being encoded as protocol messages.
|
||||
|
||||
The OpenGL renderer:
|
||||
1. For **MJPEG**: calls `tjDecompressToYUVPlanes` (libjpeg-turbo) to decompress directly to planar YUV — no CPU-side color conversion. JPEG stores YCbCr internally so this is the minimal decode path: Huffman + DCT output lands directly in YUV planes.
|
||||
2. Uploads Y, Cb, Cr as separate `GL_RED` textures (chroma at half resolution for 4:2:0 / 4:2:2 as delivered by most V4L2 cameras).
|
||||
3. Fragment shader samples the three planes and applies the BT.601 matrix to produce RGB — a few lines of GLSL.
|
||||
4. Scaling and filtering happen in the same shader pass.
|
||||
5. Presents via GLFW's swap-buffers call.
|
||||
|
||||
For **raw pixel formats** (BGRA, YUV planar from the wire): uploaded directly without decode; shader handles any necessary swizzle or conversion.
|
||||
|
||||
This keeps CPU load minimal — the only CPU work for MJPEG is Huffman decode and DCT, which libjpeg-turbo runs with SIMD. All color conversion and scaling is on the GPU.
|
||||
|
||||
### Text overlays
|
||||
|
||||
Two tiers, implemented in order:
|
||||
|
||||
**Tier 1 — bitmap font atlas (done)**
|
||||
|
||||
`tools/gen_font_atlas/gen_font_atlas.py` (Python/Pillow) renders glyphs 32–255 from DejaVu Sans at 16pt into a packed grayscale atlas using a skyline bin packer and emits `build/gen/font_atlas.h` — a C header with the pixel data as a `static const uint8_t` array and a `Font_Glyph[256]` metrics table indexed by codepoint.
|
||||
|
||||
At runtime the atlas is uploaded as a `GL_R8` texture. Each overlay is rendered as a batch of alpha-blended glyph quads preceded by a semi-transparent dark background rect (using a separate minimal screen-space rect shader driven by `gl_VertexID`). The public API is `xorg_viewer_set_overlay_text(v, idx, x, y, text, r, g, b)` and `xorg_viewer_clear_overlays(v)`. Up to 8 independent overlays are supported.
|
||||
|
||||
The generator runs automatically as a `make` dependency before compiling `xorg.c`. The Pillow build tool is the only Python dependency; there are no runtime font deps.
|
||||
|
||||
**Tier 2 — HarfBuzz + FreeType (future)**
|
||||
|
||||
A proper runtime font stack for full typography: correct shaping, kerning, ligatures, bidirectional text, non-Latin scripts. Added as a feature flag with its own runtime deps alongside the blit path.
|
||||
|
||||
When Tier 2 is implemented, the Pillow build dependency may be replaced by a purpose-built atlas generator (removing the Python dep entirely), if the blit path is still useful alongside the full shaping path.
|
||||
|
||||
### Render loop
|
||||
|
||||
The viewer is driven by incoming frames rather than a fixed-rate loop. Two polling functions are provided depending on the use case:
|
||||
|
||||
**Static image / test tool** — `xorg_viewer_poll(v)` processes events then re-renders from existing textures:
|
||||
|
||||
```c
|
||||
while (xorg_viewer_poll(v)) { /* wait for close */ }
|
||||
```
|
||||
|
||||
**Live stream** — the push functions (`push_yuv420`, `push_mjpeg`, etc.) already upload and render. Use `xorg_viewer_handle_events(v)` to process window events without an extra render:
|
||||
|
||||
```c
|
||||
while (1) {
|
||||
/* block on V4L2/network fd until frame or timeout */
|
||||
if (frame_available) {
|
||||
xorg_viewer_push_mjpeg(v, data, size); /* upload + render */
|
||||
}
|
||||
if (!xorg_viewer_handle_events(v)) { break; }
|
||||
}
|
||||
```
|
||||
|
||||
A `framebuffer_size_callback` registered on the window calls `render()` synchronously during resize, so the image tracks the window edge without a one-frame lag.
|
||||
|
||||
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.
|
||||
|
||||
Vulkan offers more explicit control over presentation timing, multi-queue workloads, and compute shaders (e.g. on-GPU MJPEG decode via a compute pass if a suitable library is available). It is not needed for the initial viewer but worth having for high-frame-rate or multi-stream display scenarios.
|
||||
|
||||
The renderer selection should be a compile-time or runtime option — both implementations conform to the same internal interface (`render_frame(pixel_buffer, width, height, format)`).
|
||||
@@ -14,8 +14,8 @@ struct Syscall_Error_Detail {
|
||||
};
|
||||
|
||||
struct Invalid_Error_Detail {
|
||||
/* fields added as concrete cases arise */
|
||||
int placeholder;
|
||||
int config_line; /* source line number, or 0 if not applicable */
|
||||
const char *message; /* static string describing what was wrong */
|
||||
};
|
||||
|
||||
struct App_Error {
|
||||
@@ -51,6 +51,14 @@ void app_error_print(struct App_Error *e);
|
||||
.line = __LINE__, \
|
||||
})
|
||||
|
||||
#define APP_INVALID_ERROR_MSG(cfg_line, msg) \
|
||||
((struct App_Error){ \
|
||||
.code = ERR_INVALID, \
|
||||
.file = __FILE__, \
|
||||
.line = __LINE__, \
|
||||
.detail = { .invalid = { .config_line = (cfg_line), .message = (msg) } }, \
|
||||
})
|
||||
|
||||
#define APP_NOT_FOUND_ERROR() \
|
||||
((struct App_Error){ \
|
||||
.code = ERR_NOT_FOUND, \
|
||||
|
||||
66
include/ingest.h
Normal file
66
include/ingest.h
Normal file
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include "error.h"
|
||||
|
||||
typedef struct Ingest_Handle Ingest_Handle;
|
||||
|
||||
/*
|
||||
* Called from the capture thread for each dequeued frame.
|
||||
* data points into the mmap'd buffer — valid only for the duration of the call.
|
||||
* Do not free data; copy if you need to retain it beyond the callback.
|
||||
*/
|
||||
typedef void (*Ingest_Frame_Fn)(
|
||||
const uint8_t *data, uint32_t len,
|
||||
int width, int height, uint32_t pixfmt,
|
||||
void *userdata);
|
||||
|
||||
/*
|
||||
* Called from the capture thread when a fatal error terminates the capture loop.
|
||||
* After this callback returns, the thread exits and the handle is in a stopped
|
||||
* state (equivalent to after ingest_stop). msg is a static string.
|
||||
*/
|
||||
typedef void (*Ingest_Error_Fn)(const char *msg, void *userdata);
|
||||
|
||||
struct Ingest_Config {
|
||||
const char *device; /* e.g. "/dev/video0" */
|
||||
uint32_t pixfmt; /* V4L2_PIX_FMT_MJPEG etc.; 0 = auto (best MJPEG) */
|
||||
int width; /* 0 = auto */
|
||||
int height; /* 0 = auto */
|
||||
Ingest_Frame_Fn on_frame;
|
||||
Ingest_Error_Fn on_error; /* may be NULL */
|
||||
void *userdata;
|
||||
};
|
||||
|
||||
/*
|
||||
* Open the V4L2 device, negotiate format, allocate MMAP buffers.
|
||||
* Does NOT start streaming. on_frame must not be NULL.
|
||||
*/
|
||||
struct App_Error ingest_open(const struct Ingest_Config *cfg, Ingest_Handle **out);
|
||||
|
||||
/*
|
||||
* Enable streaming and start the capture thread.
|
||||
* Must be called on a handle in the OPEN (not streaming) state.
|
||||
*/
|
||||
struct App_Error ingest_start(Ingest_Handle *h);
|
||||
|
||||
/*
|
||||
* Signal the capture thread to stop and block until it exits.
|
||||
* Disables streaming. The handle returns to the OPEN state and can be
|
||||
* restarted with ingest_start or released with ingest_close.
|
||||
*/
|
||||
struct App_Error ingest_stop(Ingest_Handle *h);
|
||||
|
||||
/*
|
||||
* Release MMAP buffers and close the device fd.
|
||||
* Must be called only when the handle is not streaming (before ingest_start
|
||||
* or after ingest_stop).
|
||||
*/
|
||||
void ingest_close(Ingest_Handle *h);
|
||||
|
||||
/* Query the negotiated format — valid after a successful ingest_open. */
|
||||
int ingest_width(const Ingest_Handle *h);
|
||||
int ingest_height(const Ingest_Handle *h);
|
||||
uint32_t ingest_pixfmt(const Ingest_Handle *h);
|
||||
int ingest_fps_n(const Ingest_Handle *h);
|
||||
int ingest_fps_d(const Ingest_Handle *h);
|
||||
546
include/protocol.h
Normal file
546
include/protocol.h
Normal file
@@ -0,0 +1,546 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include "error.h"
|
||||
#include "transport.h"
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Message type constants
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define PROTO_MSG_VIDEO_FRAME 0x0001u
|
||||
#define PROTO_MSG_CONTROL_REQUEST 0x0002u
|
||||
#define PROTO_MSG_CONTROL_RESPONSE 0x0003u
|
||||
#define PROTO_MSG_STREAM_EVENT 0x0004u
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Command codes (carried in CONTROL_REQUEST payload offset 2)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define PROTO_CMD_STREAM_OPEN 0x0001u
|
||||
#define PROTO_CMD_STREAM_CLOSE 0x0002u
|
||||
#define PROTO_CMD_ENUM_DEVICES 0x0003u
|
||||
#define PROTO_CMD_ENUM_CONTROLS 0x0004u
|
||||
#define PROTO_CMD_GET_CONTROL 0x0005u
|
||||
#define PROTO_CMD_SET_CONTROL 0x0006u
|
||||
#define PROTO_CMD_ENUM_MONITORS 0x0007u
|
||||
#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)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define PROTO_STATUS_OK 0x0000u
|
||||
#define PROTO_STATUS_ERROR 0x0001u
|
||||
#define PROTO_STATUS_UNKNOWN_CMD 0x0002u
|
||||
#define PROTO_STATUS_INVALID_PARAMS 0x0003u
|
||||
#define PROTO_STATUS_NOT_FOUND 0x0004u
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Stream event codes (carried in STREAM_EVENT payload offset 2)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define PROTO_EVENT_INTERRUPTED 0x01u
|
||||
#define PROTO_EVENT_RESUMED 0x02u
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Codec format codes (STREAM_OPEN format field)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define PROTO_FORMAT_MJPEG 0x0001u
|
||||
#define PROTO_FORMAT_H264 0x0002u
|
||||
#define PROTO_FORMAT_H265 0x0003u
|
||||
#define PROTO_FORMAT_AV1 0x0004u
|
||||
#define PROTO_FORMAT_FFV1 0x0005u
|
||||
#define PROTO_FORMAT_PRORES 0x0006u
|
||||
#define PROTO_FORMAT_QOI 0x0007u
|
||||
#define PROTO_FORMAT_RAW 0x0008u
|
||||
#define PROTO_FORMAT_RAW_ZSTD 0x0009u
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Pixel format codes (STREAM_OPEN pixel_format field; 0 for compressed)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define PROTO_PIXEL_BGRA8888 0x0001u
|
||||
#define PROTO_PIXEL_RGBA8888 0x0002u
|
||||
#define PROTO_PIXEL_BGR888 0x0003u
|
||||
#define PROTO_PIXEL_YUV420P 0x0004u
|
||||
#define PROTO_PIXEL_YUV422 0x0005u
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* 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)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define PROTO_ORIGIN_DEVICE_NATIVE 0x0001u
|
||||
#define PROTO_ORIGIN_LIBJPEG_TURBO 0x0002u
|
||||
#define PROTO_ORIGIN_FFMPEG_LIBAV 0x0003u
|
||||
#define PROTO_ORIGIN_FFMPEG_PROC 0x0004u
|
||||
#define PROTO_ORIGIN_VAAPI 0x0005u
|
||||
#define PROTO_ORIGIN_NVENC 0x0006u
|
||||
#define PROTO_ORIGIN_SOFTWARE 0x0007u
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Structs used by write functions (variable-length response payloads)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
struct Proto_Menu_Item {
|
||||
uint32_t index;
|
||||
const char *name;
|
||||
int64_t int_value;
|
||||
};
|
||||
|
||||
struct Proto_Control_Info {
|
||||
uint32_t id;
|
||||
uint8_t type;
|
||||
uint32_t flags;
|
||||
const char *name;
|
||||
int32_t min, max, step, default_val, current_val;
|
||||
uint8_t menu_count;
|
||||
const struct Proto_Menu_Item *menu_items;
|
||||
};
|
||||
|
||||
/*
|
||||
* A video node associated with a media controller device.
|
||||
* entity_type and entity_flags are MEDIA_ENT_F_* / MEDIA_ENT_FL_* values.
|
||||
* pad_flags uses MEDIA_PAD_FLAG_SOURCE / MEDIA_PAD_FLAG_SINK.
|
||||
* is_capture: 1 if this node is the primary video capture output.
|
||||
*/
|
||||
struct Proto_Video_Node_Info {
|
||||
const char *path;
|
||||
const char *entity_name;
|
||||
uint32_t entity_type;
|
||||
uint32_t entity_flags;
|
||||
uint32_t device_caps; /* V4L2_CAP_* bits from VIDIOC_QUERYCAP */
|
||||
uint8_t pad_flags;
|
||||
uint8_t is_capture;
|
||||
};
|
||||
|
||||
/*
|
||||
* A media controller device and its associated video nodes.
|
||||
* video_node_count must be <= 255.
|
||||
*/
|
||||
struct Proto_Media_Device_Info {
|
||||
const char *path;
|
||||
const char *driver;
|
||||
const char *model;
|
||||
const char *bus_info;
|
||||
uint8_t video_node_count;
|
||||
const struct Proto_Video_Node_Info *video_nodes;
|
||||
};
|
||||
|
||||
/*
|
||||
* A standalone V4L2 device with no associated media controller.
|
||||
* name is the card name from VIDIOC_QUERYCAP.
|
||||
*/
|
||||
struct Proto_Standalone_Device_Info {
|
||||
const char *path;
|
||||
const char *name;
|
||||
};
|
||||
|
||||
/*
|
||||
* 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;
|
||||
const char *name;
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Structs used by read functions
|
||||
* Fields with pointer types point INTO the caller's payload buffer.
|
||||
* The caller must keep the payload alive while using those pointers.
|
||||
* Strings are NOT NUL-terminated.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
struct Proto_Video_Frame {
|
||||
uint16_t stream_id;
|
||||
const uint8_t *data;
|
||||
uint32_t data_len;
|
||||
};
|
||||
|
||||
struct Proto_Stream_Event {
|
||||
uint16_t stream_id;
|
||||
uint8_t event_code;
|
||||
};
|
||||
|
||||
struct Proto_Request_Header {
|
||||
uint16_t request_id;
|
||||
uint16_t command;
|
||||
};
|
||||
|
||||
struct Proto_Stream_Open {
|
||||
uint16_t request_id;
|
||||
uint16_t stream_id;
|
||||
uint16_t format;
|
||||
uint16_t pixel_format;
|
||||
uint16_t origin;
|
||||
};
|
||||
|
||||
struct Proto_Stream_Close {
|
||||
uint16_t request_id;
|
||||
uint16_t stream_id;
|
||||
};
|
||||
|
||||
struct Proto_Enum_Controls_Req {
|
||||
uint16_t request_id;
|
||||
uint16_t device_index;
|
||||
};
|
||||
|
||||
struct Proto_Get_Control_Req {
|
||||
uint16_t request_id;
|
||||
uint16_t device_index;
|
||||
uint32_t control_id;
|
||||
};
|
||||
|
||||
struct Proto_Set_Control_Req {
|
||||
uint16_t request_id;
|
||||
uint16_t device_index;
|
||||
uint32_t control_id;
|
||||
int32_t value;
|
||||
};
|
||||
|
||||
/*
|
||||
* 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;
|
||||
};
|
||||
|
||||
struct Proto_Get_Control_Resp {
|
||||
uint16_t request_id;
|
||||
uint16_t status;
|
||||
int32_t value;
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Write functions — serialize and send via transport_send_frame.
|
||||
* All return APP_OK or an error from the transport layer.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* VIDEO_FRAME: prepends stream_id (2 bytes) to data and sends.
|
||||
* data/data_len is the compressed frame; the stream must already be open.
|
||||
*/
|
||||
struct App_Error proto_write_video_frame(struct Transport_Conn *conn,
|
||||
uint16_t stream_id, const uint8_t *data, uint32_t data_len);
|
||||
|
||||
/* STREAM_EVENT (3 bytes) */
|
||||
struct App_Error proto_write_stream_event(struct Transport_Conn *conn,
|
||||
uint16_t stream_id, uint8_t event_code);
|
||||
|
||||
/* CONTROL_REQUEST: STREAM_OPEN (12 bytes) */
|
||||
struct App_Error proto_write_stream_open(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t stream_id,
|
||||
uint16_t format, uint16_t pixel_format, uint16_t origin);
|
||||
|
||||
/* CONTROL_REQUEST: STREAM_CLOSE (6 bytes) */
|
||||
struct App_Error proto_write_stream_close(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t stream_id);
|
||||
|
||||
/* CONTROL_REQUEST: ENUM_DEVICES (4 bytes, no extra fields) */
|
||||
struct App_Error proto_write_enum_devices(struct Transport_Conn *conn,
|
||||
uint16_t request_id);
|
||||
|
||||
/* CONTROL_REQUEST: ENUM_CONTROLS (6 bytes) */
|
||||
struct App_Error proto_write_enum_controls(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t device_index);
|
||||
|
||||
/* CONTROL_REQUEST: GET_CONTROL (10 bytes) */
|
||||
struct App_Error proto_write_get_control(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t device_index, uint32_t control_id);
|
||||
|
||||
/* CONTROL_REQUEST: SET_CONTROL (14 bytes) */
|
||||
struct App_Error proto_write_set_control(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t device_index,
|
||||
uint32_t control_id, int32_t value);
|
||||
|
||||
/* CONTROL_REQUEST: ENUM_MONITORS (4 bytes, no extra fields) */
|
||||
struct App_Error proto_write_enum_monitors(struct Transport_Conn *conn,
|
||||
uint16_t request_id);
|
||||
|
||||
/* CONTROL_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.
|
||||
* Pass payload=NULL, payload_len=0 for responses with no extra fields
|
||||
* (e.g. STREAM_OPEN ok, STREAM_CLOSE, SET_CONTROL).
|
||||
*/
|
||||
struct App_Error proto_write_control_response(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t status,
|
||||
const uint8_t *payload, uint32_t payload_len);
|
||||
|
||||
/* CONTROL_RESPONSE: ENUM_DEVICES */
|
||||
struct App_Error proto_write_enum_devices_response(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t status,
|
||||
const struct Proto_Media_Device_Info *media_devices, uint16_t media_count,
|
||||
const struct Proto_Standalone_Device_Info *standalone, uint16_t standalone_count,
|
||||
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,
|
||||
uint16_t request_id, uint16_t status,
|
||||
const struct Proto_Control_Info *controls, uint16_t count);
|
||||
|
||||
/* CONTROL_RESPONSE: GET_CONTROL */
|
||||
struct App_Error proto_write_get_control_response(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t status, int32_t value);
|
||||
|
||||
/* CONTROL_RESPONSE: ENUM_MONITORS */
|
||||
struct App_Error proto_write_enum_monitors_response(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t status,
|
||||
const struct Proto_Monitor_Info *monitors, uint16_t count);
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Read functions — parse raw payload bytes into typed structs.
|
||||
* All return APP_INVALID_ERROR_MSG(0, ...) on malformed payloads.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
struct App_Error proto_read_video_frame(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Video_Frame *out);
|
||||
|
||||
struct App_Error proto_read_stream_event(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Stream_Event *out);
|
||||
|
||||
/*
|
||||
* Read the common 4-byte request header (request_id + command).
|
||||
* Dispatch on header.command, then call the appropriate specific reader.
|
||||
* ENUM_DEVICES and ENUM_MONITORS have no extra fields beyond the header.
|
||||
*/
|
||||
struct App_Error proto_read_request_header(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Request_Header *out);
|
||||
|
||||
struct App_Error proto_read_stream_open(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Stream_Open *out);
|
||||
|
||||
struct App_Error proto_read_stream_close(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Stream_Close *out);
|
||||
|
||||
struct App_Error proto_read_enum_controls_req(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Enum_Controls_Req *out);
|
||||
|
||||
struct App_Error proto_read_get_control_req(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Get_Control_Req *out);
|
||||
|
||||
struct App_Error proto_read_set_control_req(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Set_Control_Req *out);
|
||||
|
||||
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),
|
||||
* this is the complete parse.
|
||||
*/
|
||||
struct App_Error proto_read_response_header(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Response_Header *out);
|
||||
|
||||
struct App_Error proto_read_get_control_response(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Get_Control_Resp *out);
|
||||
|
||||
/*
|
||||
* Variable-length response readers use callbacks to avoid heap allocation.
|
||||
* Strings point into the payload and are NOT NUL-terminated; use *_len.
|
||||
*/
|
||||
/*
|
||||
* on_media_device is called once per media controller device.
|
||||
* on_video_node is called video_node_count times immediately after,
|
||||
* once per video node belonging to that media device.
|
||||
* on_standalone is called once per V4L2 device with no media controller.
|
||||
* Any callback may be NULL.
|
||||
*/
|
||||
struct App_Error proto_read_enum_devices_response(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Response_Header *header_out,
|
||||
void (*on_media_device)(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *driver, uint8_t driver_len,
|
||||
const char *model, uint8_t model_len,
|
||||
const char *bus_info, uint8_t bus_info_len,
|
||||
uint8_t video_node_count,
|
||||
void *userdata),
|
||||
void (*on_video_node)(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *entity_name, uint8_t entity_name_len,
|
||||
uint32_t entity_type, uint32_t entity_flags,
|
||||
uint32_t device_caps,
|
||||
uint8_t pad_flags, uint8_t is_capture,
|
||||
void *userdata),
|
||||
void (*on_standalone)(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *name, uint8_t name_len,
|
||||
void *userdata),
|
||||
void (*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);
|
||||
|
||||
/*
|
||||
* on_control is called once per control.
|
||||
* on_menu_item is called once per menu item immediately after its on_control
|
||||
* call; menu_count in on_control says how many to expect.
|
||||
* on_menu_item may be NULL if the caller does not need menu items.
|
||||
*/
|
||||
struct App_Error proto_read_enum_controls_response(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Response_Header *header_out,
|
||||
void (*on_control)(
|
||||
uint32_t id, uint8_t type, uint32_t flags,
|
||||
const char *name, uint8_t name_len,
|
||||
int32_t min, int32_t max, int32_t step,
|
||||
int32_t default_val, int32_t current_val,
|
||||
uint8_t menu_count, void *userdata),
|
||||
void (*on_menu_item)(
|
||||
uint32_t index,
|
||||
const char *name, uint8_t name_len,
|
||||
int64_t int_value,
|
||||
void *userdata),
|
||||
void *userdata);
|
||||
|
||||
struct App_Error proto_read_enum_monitors_response(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Response_Header *header_out,
|
||||
void (*on_monitor)(
|
||||
int32_t x, int32_t y, uint32_t width, uint32_t height,
|
||||
const char *name, uint8_t name_len,
|
||||
void *userdata),
|
||||
void *userdata);
|
||||
108
include/reconciler.h
Normal file
108
include/reconciler.h
Normal file
@@ -0,0 +1,108 @@
|
||||
#pragma once
|
||||
|
||||
/*
|
||||
* Generic declarative state machine reconciler.
|
||||
*
|
||||
* Each managed resource is described as a directed graph of states
|
||||
* with labelled transitions. The reconciler finds the shortest path
|
||||
* (BFS) from a resource's current state to its wanted state and
|
||||
* executes one transition per tick.
|
||||
*
|
||||
* Dependencies between resources prevent a resource from advancing
|
||||
* past a threshold state until a prerequisite resource reaches a
|
||||
* minimum state.
|
||||
*
|
||||
* Usage:
|
||||
* struct Reconciler *r = reconciler_create();
|
||||
*
|
||||
* static const struct Rec_Transition dev_trans[] = {
|
||||
* {0, 1, open_device},
|
||||
* {1, 0, close_device},
|
||||
* {1, 2, start_capture},
|
||||
* {2, 1, stop_capture},
|
||||
* {-1, -1, NULL}
|
||||
* };
|
||||
* static const char *dev_states[] = {"CLOSED", "OPEN", "STREAMING"};
|
||||
* struct Rec_Resource *dev = reconciler_add_resource(r, "device",
|
||||
* dev_trans, 3, dev_states, 0, &my_device);
|
||||
*
|
||||
* reconciler_set_wanted(dev, 2);
|
||||
* while (!reconciler_is_stable(r)) {
|
||||
* reconciler_tick(r);
|
||||
* }
|
||||
*/
|
||||
|
||||
/* Transition table entry. Sentinel: {-1, -1, NULL}.
|
||||
* action: return 1 on success, 0 on failure.
|
||||
* On failure the resource stays in 'from' state. */
|
||||
struct Rec_Transition {
|
||||
int from;
|
||||
int to;
|
||||
int (*action)(void *userdata);
|
||||
};
|
||||
|
||||
typedef enum {
|
||||
REC_STATUS_STABLE, /* current == wanted */
|
||||
REC_STATUS_WORKING, /* current != wanted, next transition is eligible */
|
||||
REC_STATUS_BLOCKED, /* current != wanted, a dependency is unsatisfied */
|
||||
REC_STATUS_NO_PATH, /* current != wanted, no transition path exists */
|
||||
} Rec_Status;
|
||||
|
||||
struct Reconciler;
|
||||
struct Rec_Resource;
|
||||
|
||||
/* Optional log callback — called after each transition attempt. */
|
||||
typedef void (*Rec_Log_Fn)(
|
||||
const struct Rec_Resource *res,
|
||||
int from, int to, int success,
|
||||
void *userdata);
|
||||
|
||||
struct Reconciler *reconciler_create(void);
|
||||
void reconciler_destroy(struct Reconciler *r);
|
||||
|
||||
/* Set a log callback. Called after every transition attempt. */
|
||||
void reconciler_set_log(struct Reconciler *r, Rec_Log_Fn fn, void *userdata);
|
||||
|
||||
/* Add a resource.
|
||||
* transitions: caller-owned, sentinel-terminated {-1,-1,NULL}.
|
||||
* state_names: optional array of state_count strings; NULL for numeric display.
|
||||
* initial_state: sets both current and wanted initially. */
|
||||
struct Rec_Resource *reconciler_add_resource(
|
||||
struct Reconciler *r,
|
||||
const char *name,
|
||||
const struct Rec_Transition *transitions,
|
||||
int state_count,
|
||||
const char **state_names,
|
||||
int initial_state,
|
||||
void *userdata);
|
||||
|
||||
/* Add a dependency: resource cannot reach state >= blocked_below
|
||||
* unless dep is currently in state >= dep_min_state. */
|
||||
void reconciler_add_dep(
|
||||
struct Rec_Resource *resource,
|
||||
int blocked_below,
|
||||
struct Rec_Resource *dep,
|
||||
int dep_min_state);
|
||||
|
||||
void reconciler_set_wanted(struct Rec_Resource *r, int wanted_state);
|
||||
|
||||
/*
|
||||
* Force current state without executing a transition.
|
||||
* Use when an external event pushes a resource into a new state —
|
||||
* e.g. a transport connection drops unexpectedly, or a device error
|
||||
* causes the capture thread to exit. The reconciler will drive back
|
||||
* toward wanted state on the next tick.
|
||||
*/
|
||||
void reconciler_force_current(struct Rec_Resource *r, int state);
|
||||
int reconciler_get_current(const struct Rec_Resource *r);
|
||||
int reconciler_get_wanted(const struct Rec_Resource *r);
|
||||
const char *reconciler_get_name(const struct Rec_Resource *r);
|
||||
const char *reconciler_state_name(const struct Rec_Resource *r, int state);
|
||||
Rec_Status reconciler_get_status(const struct Rec_Resource *r);
|
||||
|
||||
/* Run one reconciliation pass over all resources.
|
||||
* Returns number of transitions attempted (success or failure). */
|
||||
int reconciler_tick(struct Reconciler *r);
|
||||
|
||||
/* Returns 1 if all resources have current == wanted. */
|
||||
int reconciler_is_stable(const struct Reconciler *r);
|
||||
76
include/stream_stats.h
Normal file
76
include/stream_stats.h
Normal file
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
|
||||
/*
|
||||
* Lightweight per-stream statistics tracker.
|
||||
* Header-only; include wherever stream send/receive happens.
|
||||
*
|
||||
* Usage:
|
||||
* Stream_Stats s;
|
||||
* stream_stats_init(&s, stream_id);
|
||||
*
|
||||
* // on each frame:
|
||||
* stream_stats_record_frame(&s, byte_count);
|
||||
*
|
||||
* // periodically (e.g. after every frame):
|
||||
* if (stream_stats_tick(&s)) {
|
||||
* printf("%.1f fps %.2f Mbps\n", s.fps, s.mbps);
|
||||
* }
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#define STREAM_STATS_INTERVAL 0.5 /* recompute rates every 0.5 s */
|
||||
|
||||
typedef struct {
|
||||
uint16_t stream_id;
|
||||
|
||||
/* Lifetime counters — never reset. */
|
||||
uint64_t total_frames;
|
||||
uint64_t total_bytes;
|
||||
|
||||
/* Rolling window — reset each time rates are computed. */
|
||||
uint64_t window_frames;
|
||||
uint64_t window_bytes;
|
||||
struct timespec window_start;
|
||||
|
||||
/* Last computed rates. */
|
||||
float fps;
|
||||
float mbps;
|
||||
} Stream_Stats;
|
||||
|
||||
static inline void stream_stats_init(Stream_Stats *s, uint16_t stream_id)
|
||||
{
|
||||
memset(s, 0, sizeof(*s));
|
||||
s->stream_id = stream_id;
|
||||
clock_gettime(CLOCK_MONOTONIC, &s->window_start);
|
||||
}
|
||||
|
||||
/* Call once per received/sent frame. */
|
||||
static inline void stream_stats_record_frame(Stream_Stats *s, uint32_t nbytes)
|
||||
{
|
||||
s->total_frames++;
|
||||
s->total_bytes += nbytes;
|
||||
s->window_frames++;
|
||||
s->window_bytes += nbytes;
|
||||
}
|
||||
|
||||
/*
|
||||
* Recompute fps and mbps if enough time has elapsed.
|
||||
* Returns 1 when rates were updated, 0 otherwise.
|
||||
*/
|
||||
static inline int stream_stats_tick(Stream_Stats *s)
|
||||
{
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
double elapsed = (double)(now.tv_sec - s->window_start.tv_sec) +
|
||||
(double)(now.tv_nsec - s->window_start.tv_nsec) * 1e-9;
|
||||
if (elapsed < STREAM_STATS_INTERVAL) { return 0; }
|
||||
s->fps = (float)((double)s->window_frames / elapsed);
|
||||
s->mbps = (float)((double)s->window_bytes * 8.0 / elapsed / 1e6);
|
||||
s->window_frames = 0;
|
||||
s->window_bytes = 0;
|
||||
s->window_start = now;
|
||||
return 1;
|
||||
}
|
||||
35
include/test_image.h
Normal file
35
include/test_image.h
Normal file
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef enum Test_Pattern {
|
||||
TEST_PATTERN_BARS, /* SMPTE 75% colour bars */
|
||||
TEST_PATTERN_RAMP, /* greyscale ramp, left to right */
|
||||
TEST_PATTERN_GRID, /* white grid lines on black */
|
||||
} Test_Pattern;
|
||||
|
||||
typedef enum Test_Fmt {
|
||||
TEST_FMT_YUV420, /* planar YUV 4:2:0 */
|
||||
TEST_FMT_YUV422, /* planar YUV 4:2:2 */
|
||||
TEST_FMT_BGRA, /* packed BGRA 8:8:8:8 */
|
||||
} Test_Fmt;
|
||||
|
||||
typedef struct Test_Frame {
|
||||
uint8_t *plane[3]; /* Y/Cb/Cr; only plane[0] used for BGRA */
|
||||
int stride[3]; /* bytes per row for each plane */
|
||||
int width;
|
||||
int height;
|
||||
Test_Fmt fmt;
|
||||
} Test_Frame;
|
||||
|
||||
/*
|
||||
* Allocate a Test_Frame with correctly-sized plane buffers.
|
||||
* Width and height must be >= 2 and even.
|
||||
* Returns NULL on allocation failure.
|
||||
*/
|
||||
Test_Frame *test_image_alloc(int width, int height, Test_Fmt fmt);
|
||||
|
||||
/* Fill all planes of f with the given pattern. */
|
||||
void test_image_generate(Test_Frame *f, Test_Pattern pat);
|
||||
|
||||
void test_image_free(Test_Frame *f);
|
||||
@@ -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.
|
||||
|
||||
155
include/v4l2_fmt.h
Normal file
155
include/v4l2_fmt.h
Normal file
@@ -0,0 +1,155 @@
|
||||
#pragma once
|
||||
|
||||
/*
|
||||
* Header-only V4L2 format enumeration.
|
||||
* Enumerates (pixfmt, size, fps) combinations for MJPEG/YUYV capture devices.
|
||||
*
|
||||
* Usage:
|
||||
* V4l2_Fmt_Option opts[V4L2_FMT_MAX_OPTS];
|
||||
* int n = v4l2_enumerate_formats(fd, opts, V4L2_FMT_MAX_OPTS, 0);
|
||||
* const V4l2_Fmt_Option *best = v4l2_select_best(opts, n);
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <errno.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <linux/videodev2.h>
|
||||
|
||||
#define V4L2_FMT_MAX_OPTS 1024
|
||||
|
||||
typedef struct {
|
||||
uint32_t pixfmt;
|
||||
int w, h;
|
||||
int fps_n; /* fps = fps_n / fps_d */
|
||||
int fps_d;
|
||||
} V4l2_Fmt_Option;
|
||||
|
||||
static inline int v4l2_xioctl(int fd, unsigned long req, void *arg)
|
||||
{
|
||||
int r;
|
||||
do { r = ioctl(fd, req, arg); } while (r == -1 && errno == EINTR);
|
||||
return r;
|
||||
}
|
||||
|
||||
static inline int v4l2_fmt_fps_gt(const V4l2_Fmt_Option *a, const V4l2_Fmt_Option *b)
|
||||
{
|
||||
return (long long)a->fps_n * b->fps_d > (long long)b->fps_n * a->fps_d;
|
||||
}
|
||||
|
||||
static inline int v4l2_fmt_fps_eq(const V4l2_Fmt_Option *a, const V4l2_Fmt_Option *b)
|
||||
{
|
||||
return (long long)a->fps_n * b->fps_d == (long long)b->fps_n * a->fps_d;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
V4l2_Fmt_Option *opts;
|
||||
int n;
|
||||
int max;
|
||||
} V4l2_Opt_List;
|
||||
|
||||
static inline void v4l2_opt_push(V4l2_Opt_List *l, uint32_t pixfmt,
|
||||
int w, int h, int fps_n, int fps_d)
|
||||
{
|
||||
if (l->n >= l->max) { return; }
|
||||
l->opts[l->n++] = (V4l2_Fmt_Option){ pixfmt, w, h, fps_n, fps_d };
|
||||
}
|
||||
|
||||
static inline void v4l2_collect_intervals(int fd, uint32_t pixfmt, int w, int h,
|
||||
V4l2_Opt_List *l)
|
||||
{
|
||||
struct v4l2_frmivalenum fie = {0};
|
||||
fie.pixel_format = pixfmt;
|
||||
fie.width = (uint32_t)w;
|
||||
fie.height = (uint32_t)h;
|
||||
|
||||
for (fie.index = 0;
|
||||
v4l2_xioctl(fd, VIDIOC_ENUM_FRAMEINTERVALS, &fie) == 0;
|
||||
fie.index++) {
|
||||
if (fie.type == V4L2_FRMIVAL_TYPE_DISCRETE) {
|
||||
v4l2_opt_push(l, pixfmt, w, h,
|
||||
(int)fie.discrete.denominator,
|
||||
(int)fie.discrete.numerator);
|
||||
} else {
|
||||
/* Stepwise/continuous: record the fastest (minimum) interval. */
|
||||
v4l2_opt_push(l, pixfmt, w, h,
|
||||
(int)fie.stepwise.min.denominator,
|
||||
(int)fie.stepwise.min.numerator);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static inline void v4l2_collect_sizes(int fd, uint32_t pixfmt, V4l2_Opt_List *l)
|
||||
{
|
||||
struct v4l2_frmsizeenum fse = {0};
|
||||
fse.pixel_format = pixfmt;
|
||||
|
||||
for (fse.index = 0;
|
||||
v4l2_xioctl(fd, VIDIOC_ENUM_FRAMESIZES, &fse) == 0;
|
||||
fse.index++) {
|
||||
if (fse.type == V4L2_FRMSIZE_TYPE_DISCRETE) {
|
||||
v4l2_collect_intervals(fd, pixfmt,
|
||||
(int)fse.discrete.width,
|
||||
(int)fse.discrete.height, l);
|
||||
} else {
|
||||
/* Stepwise/continuous: only probe the maximum size. */
|
||||
v4l2_collect_intervals(fd, pixfmt,
|
||||
(int)fse.stepwise.max_width,
|
||||
(int)fse.stepwise.max_height, l);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Enumerate all (pixfmt, size, fps) combos the device supports.
|
||||
* Filtered to MJPEG and YUYV. fmt_filter=0 accepts both.
|
||||
* Returns the count written to buf.
|
||||
*/
|
||||
static inline int v4l2_enumerate_formats(int fd, V4l2_Fmt_Option *buf, int buf_max,
|
||||
uint32_t fmt_filter)
|
||||
{
|
||||
V4l2_Opt_List l = { buf, 0, buf_max };
|
||||
|
||||
struct v4l2_fmtdesc fd_desc = {0};
|
||||
fd_desc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
|
||||
for (fd_desc.index = 0;
|
||||
v4l2_xioctl(fd, VIDIOC_ENUM_FMT, &fd_desc) == 0;
|
||||
fd_desc.index++) {
|
||||
uint32_t pf = fd_desc.pixelformat;
|
||||
if (pf != V4L2_PIX_FMT_MJPEG && pf != V4L2_PIX_FMT_YUYV) { continue; }
|
||||
if (fmt_filter && pf != fmt_filter) { continue; }
|
||||
v4l2_collect_sizes(fd, pf, &l);
|
||||
}
|
||||
return l.n;
|
||||
}
|
||||
|
||||
/*
|
||||
* Select the best option from the list:
|
||||
* 1. Highest FPS
|
||||
* 2. Largest area (w×h) among equal-FPS entries
|
||||
* 3. MJPEG preferred over YUYV on equal FPS and area
|
||||
*/
|
||||
static inline const V4l2_Fmt_Option *v4l2_select_best(const V4l2_Fmt_Option *opts, int n)
|
||||
{
|
||||
if (n == 0) { return NULL; }
|
||||
const V4l2_Fmt_Option *best = &opts[0];
|
||||
for (int i = 1; i < n; i++) {
|
||||
const V4l2_Fmt_Option *o = &opts[i];
|
||||
if (v4l2_fmt_fps_gt(o, best)) {
|
||||
best = o;
|
||||
} else if (v4l2_fmt_fps_eq(o, best)) {
|
||||
int o_area = o->w * o->h;
|
||||
int b_area = best->w * best->h;
|
||||
if (o_area > b_area) {
|
||||
best = o;
|
||||
} else if (o_area == b_area &&
|
||||
o->pixfmt == V4L2_PIX_FMT_MJPEG &&
|
||||
best->pixfmt != V4L2_PIX_FMT_MJPEG) {
|
||||
best = o;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
90
include/xorg.h
Normal file
90
include/xorg.h
Normal file
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct Xorg_Viewer Xorg_Viewer;
|
||||
|
||||
/*
|
||||
* How the frame is scaled to fit the window.
|
||||
* SCALE_STRETCH is the only implemented mode; others are reserved.
|
||||
*/
|
||||
typedef enum Xorg_Scale {
|
||||
XORG_SCALE_STRETCH, /* fill window, ignore aspect ratio (default) */
|
||||
XORG_SCALE_FIT, /* largest rect that fits, preserve aspect, black bars */
|
||||
XORG_SCALE_FILL, /* smallest rect that covers, preserve aspect, crop edges */
|
||||
XORG_SCALE_1_1, /* native pixel size, no scaling */
|
||||
} Xorg_Scale;
|
||||
|
||||
/*
|
||||
* Where the frame is positioned within the window.
|
||||
* Used with XORG_SCALE_FIT, XORG_SCALE_FILL, and XORG_SCALE_1_1.
|
||||
*/
|
||||
typedef enum Xorg_Anchor {
|
||||
XORG_ANCHOR_CENTER, /* center frame in window (default) */
|
||||
XORG_ANCHOR_TOP_LEFT, /* align frame to top-left corner */
|
||||
} Xorg_Anchor;
|
||||
|
||||
/* Returns false when compiled without HAVE_GLFW. */
|
||||
bool xorg_available(void);
|
||||
|
||||
/*
|
||||
* Open a viewer window at screen position (x, y) with the given size.
|
||||
* Returns NULL if xorg is unavailable or if window/context creation fails.
|
||||
*/
|
||||
Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
|
||||
const char *title);
|
||||
|
||||
/* Push a YUV 4:2:0 planar frame for immediate display. */
|
||||
bool xorg_viewer_push_yuv420(Xorg_Viewer *v,
|
||||
const uint8_t *y, const uint8_t *cb, const uint8_t *cr,
|
||||
int width, int height);
|
||||
|
||||
/* Push a packed BGRA frame for immediate display. */
|
||||
bool xorg_viewer_push_bgra(Xorg_Viewer *v,
|
||||
const uint8_t *data, int width, int height);
|
||||
|
||||
/*
|
||||
* Push a MJPEG frame. Decoded via libjpeg-turbo to planar YUV before upload.
|
||||
* Returns false if compiled without HAVE_TURBOJPEG.
|
||||
*/
|
||||
bool xorg_viewer_push_mjpeg(Xorg_Viewer *v,
|
||||
const uint8_t *data, size_t size);
|
||||
|
||||
/* Change scale/anchor at any time; takes effect on the next render. */
|
||||
void xorg_viewer_set_scale(Xorg_Viewer *v, Xorg_Scale scale);
|
||||
void xorg_viewer_set_anchor(Xorg_Viewer *v, Xorg_Anchor anchor);
|
||||
|
||||
/*
|
||||
* Set a text overlay at window-pixel position (x, y) — top-left of the text.
|
||||
* Up to 8 overlays (idx 0..7); calling with the same idx replaces it.
|
||||
* r, g, b are in [0, 1].
|
||||
*/
|
||||
void xorg_viewer_set_overlay_text(Xorg_Viewer *v, int idx, int x, int y,
|
||||
const char *text, float r, float g, float b);
|
||||
|
||||
/* 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.
|
||||
* Must be called from the thread that created the viewer.
|
||||
*
|
||||
* poll() — processes events then re-renders; use for static images.
|
||||
* handle_events() — processes events only, no render; use when push_* drives
|
||||
* rendering (e.g. live camera feed).
|
||||
*/
|
||||
bool xorg_viewer_poll(Xorg_Viewer *v);
|
||||
bool xorg_viewer_handle_events(Xorg_Viewer *v);
|
||||
|
||||
void xorg_viewer_close(Xorg_Viewer *v);
|
||||
64
planning.md
64
planning.md
@@ -15,18 +15,27 @@ 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
|
||||
node/ - video node entry point and top-level integration (later)
|
||||
test_image/ - test pattern generator (colour bars, ramp, grid; YUV420/BGRA)
|
||||
xorg/ - GLFW+OpenGL viewer sink; stub for headless builds
|
||||
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
|
||||
web/ - development web UI (Node.js/Express); browser-side equivalent
|
||||
of the CLI tools; depends on protocol being finalised
|
||||
experiments/ - freeform experiments
|
||||
tools/
|
||||
gen_font_atlas/ - build-time bitmap font atlas generator (Python/Pillow);
|
||||
outputs build/gen/font_atlas.h consumed by xorg module
|
||||
tests/ - automated tests (later)
|
||||
Makefile
|
||||
architecture.md
|
||||
@@ -43,21 +52,24 @@ Modules are listed in intended build order. Each depends only on modules above i
|
||||
| # | Module | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | `common` | done | Error types, base definitions — no dependencies |
|
||||
| — | `config` | done | INI file loader with schema-driven defaults, typed getters, FLAGS type for bitmask values |
|
||||
| 2 | `media_ctrl` | done | Media Controller API — device and topology enumeration, pad format config |
|
||||
| 3 | `v4l2_ctrl` | done | V4L2 controls — enumerate, get, set camera parameters |
|
||||
| 4 | `serial` | done | `put`/`get` primitives for little-endian binary serialization into byte buffers |
|
||||
| 5 | `transport` | done | Encapsulated transport — frame header, TCP stream abstraction, single-write send |
|
||||
| 6 | `discovery` | done | UDP multicast announcements, peer table, found/lost callbacks |
|
||||
| 8 | `protocol` | not started | Typed `write_*`/`read_*` functions for all message types; builds on serial + transport |
|
||||
| 9 | `frame_alloc` | not started | Per-frame allocation with bookkeeping (byte budget, ref counting) |
|
||||
| 10 | `relay` | not started | Input dispatch to output queues (low-latency and completeness modes) |
|
||||
| 11 | `ingest` | not started | V4L2 capture loop — dequeue buffers, emit one encapsulated frame per buffer |
|
||||
| 12 | `archive` | not started | Write frames to disk, control messages to binary log |
|
||||
| 13 | `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 |
|
||||
| 14 | `xorg` | not started | X11 screen geometry queries (XRandR), screen grab source (calls codec), frame viewer sink — see architecture.md |
|
||||
| 15 | `web node` | not started | Node.js/Express peer — speaks binary protocol on socket side, HTTP/WebSocket to browser; `protocol.mjs` mirrors C protocol module |
|
||||
| 7 | `protocol` | done | Typed `write_*`/`read_*` functions for all message types; builds on serial + transport |
|
||||
| 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; 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 |
|
||||
| — | `mjpeg_scan` | future | EOI marker scanner for misbehaving hardware that does not deliver clean per-buffer frames; not part of the primary pipeline |
|
||||
| — | `config` | done | INI file loader with schema-driven defaults, typed getters, FLAGS type for bitmask values |
|
||||
|
||||
---
|
||||
|
||||
@@ -72,12 +84,30 @@ 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/`)
|
||||
|
||||
A Node.js/Express development web UI — the browser-side equivalent of the CLI tools. Connects to a running video node as a binary protocol peer and exposes its capabilities through a browser interface: V4L2 control inspection and adjustment, media topology view, stream state.
|
||||
|
||||
**Prerequisite**: the `transport` and `protocol` modules must be finalised before `dev/web/` can be implemented — the web UI depends on a stable wire format and a `protocol.mjs` that mirrors the C protocol module.
|
||||
A Node.js/Express development web UI that connects to running video nodes as a binary protocol peer. Exposes V4L2 control inspection and adjustment, media topology view, and stream state through a browser interface. Supports live peer discovery via SSE — discovered nodes appear automatically in the UI.
|
||||
|
||||
This is a development aid, not the production dashboard. The production dashboard (full stream configuration UI) is a later, separate project.
|
||||
|
||||
@@ -100,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.
|
||||
|
||||
@@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/common
|
||||
|
||||
all: $(MODULE_BUILD)/error.o
|
||||
|
||||
$(MODULE_BUILD)/error.o: error.c $(ROOT)/include/error.h | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) -c -o $@ $<
|
||||
$(MODULE_BUILD)/error.o: error.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/error.o
|
||||
rm -f $(MODULE_BUILD)/error.o $(MODULE_BUILD)/error.d
|
||||
|
||||
-include $(MODULE_BUILD)/error.d
|
||||
|
||||
@@ -17,7 +17,15 @@ void app_error_print(struct App_Error *e) {
|
||||
fprintf(stderr, "syscall error: %s\n", strerror(e->detail.syscall.err_no));
|
||||
break;
|
||||
case ERR_INVALID:
|
||||
if (e->detail.invalid.config_line > 0) {
|
||||
fprintf(stderr, "config error at line %d: %s\n",
|
||||
e->detail.invalid.config_line,
|
||||
e->detail.invalid.message ? e->detail.invalid.message : "invalid value");
|
||||
} else if (e->detail.invalid.message) {
|
||||
fprintf(stderr, "invalid: %s\n", e->detail.invalid.message);
|
||||
} else {
|
||||
fprintf(stderr, "invalid argument\n");
|
||||
}
|
||||
break;
|
||||
case ERR_NOT_FOUND:
|
||||
fprintf(stderr, "not found\n");
|
||||
|
||||
@@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/config
|
||||
|
||||
all: $(MODULE_BUILD)/config.o
|
||||
|
||||
$(MODULE_BUILD)/config.o: config.c $(ROOT)/include/config.h | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) -c -o $@ $<
|
||||
$(MODULE_BUILD)/config.o: config.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/config.o
|
||||
rm -f $(MODULE_BUILD)/config.o $(MODULE_BUILD)/config.d
|
||||
|
||||
-include $(MODULE_BUILD)/config.d
|
||||
|
||||
@@ -54,7 +54,10 @@ static void normalise_separators(char *s) {
|
||||
|
||||
/* -- parse typed value ----------------------------------------------------- */
|
||||
|
||||
static uint32_t parse_flags(const char *raw, const struct Config_Flag_Def *defs) {
|
||||
/* returns -1 on unknown token, otherwise 0 and sets *out */
|
||||
static int parse_flags(const char *raw, const struct Config_Flag_Def *defs,
|
||||
uint32_t *out, char *bad_token, size_t bad_token_len)
|
||||
{
|
||||
char buf[MAX_STR];
|
||||
strncpy(buf, raw, MAX_STR - 1);
|
||||
buf[MAX_STR - 1] = '\0';
|
||||
@@ -63,16 +66,24 @@ static uint32_t parse_flags(const char *raw, const struct Config_Flag_Def *defs)
|
||||
uint32_t bits = 0;
|
||||
char *tok = strtok(buf, " \t");
|
||||
while (tok) {
|
||||
int found = 0;
|
||||
for (const struct Config_Flag_Def *fd = defs; fd && fd->token; fd++) {
|
||||
if (strcmp(tok, fd->token) == 0) { bits |= fd->value; break; }
|
||||
if (strcmp(tok, fd->token) == 0) { bits |= fd->value; found = 1; break; }
|
||||
}
|
||||
if (!found) {
|
||||
strncpy(bad_token, tok, bad_token_len - 1);
|
||||
bad_token[bad_token_len - 1] = '\0';
|
||||
return -1;
|
||||
}
|
||||
tok = strtok(NULL, " \t");
|
||||
}
|
||||
return bits;
|
||||
*out = bits;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void set_entry_value(struct Config_Entry *e,
|
||||
const struct Config_Def *def, const char *raw)
|
||||
/* returns APP_OK or an error with config_line set */
|
||||
static struct App_Error set_entry_value(struct Config_Entry *e,
|
||||
const struct Config_Def *def, const char *raw, int cfg_line)
|
||||
{
|
||||
e->type = def->type;
|
||||
switch (def->type) {
|
||||
@@ -80,16 +91,36 @@ static void set_entry_value(struct Config_Entry *e,
|
||||
strncpy(e->val.s, raw, MAX_STR - 1);
|
||||
e->val.s[MAX_STR - 1] = '\0';
|
||||
break;
|
||||
case CONFIG_UINT16:
|
||||
e->val.u16 = (uint16_t)strtoul(raw, NULL, 10);
|
||||
break;
|
||||
case CONFIG_UINT32:
|
||||
e->val.u32 = (uint32_t)strtoul(raw, NULL, 10);
|
||||
break;
|
||||
case CONFIG_FLAGS:
|
||||
e->val.flags = parse_flags(raw, def->flags);
|
||||
case CONFIG_UINT16: {
|
||||
char *end;
|
||||
unsigned long v = strtoul(raw, &end, 10);
|
||||
if (end == raw || *end != '\0') {
|
||||
return APP_INVALID_ERROR_MSG(cfg_line, "expected integer");
|
||||
}
|
||||
if (v > 0xFFFF) {
|
||||
return APP_INVALID_ERROR_MSG(cfg_line, "value out of range for u16");
|
||||
}
|
||||
e->val.u16 = (uint16_t)v;
|
||||
break;
|
||||
}
|
||||
case CONFIG_UINT32: {
|
||||
char *end;
|
||||
unsigned long v = strtoul(raw, &end, 10);
|
||||
if (end == raw || *end != '\0') {
|
||||
return APP_INVALID_ERROR_MSG(cfg_line, "expected integer");
|
||||
}
|
||||
e->val.u32 = (uint32_t)v;
|
||||
break;
|
||||
}
|
||||
case CONFIG_FLAGS: {
|
||||
char bad[MAX_STR];
|
||||
if (parse_flags(raw, def->flags, &e->val.flags, bad, sizeof(bad)) != 0) {
|
||||
return APP_INVALID_ERROR_MSG(cfg_line, "unknown flag token");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
/* -- entry table ----------------------------------------------------------- */
|
||||
@@ -130,13 +161,15 @@ static struct Config_Entry *add_entry(struct Config *cfg,
|
||||
|
||||
/* -- fill defaults --------------------------------------------------------- */
|
||||
|
||||
static void fill_defaults(struct Config *cfg) {
|
||||
static struct App_Error fill_defaults(struct Config *cfg) {
|
||||
for (const struct Config_Def *d = cfg->schema; d->section; d++) {
|
||||
if (find_entry(cfg, d->section, d->key)) { continue; }
|
||||
struct Config_Entry *e = add_entry(cfg, d->section, d->key);
|
||||
if (!e) { continue; }
|
||||
set_entry_value(e, d, d->default_val ? d->default_val : "");
|
||||
struct App_Error err = set_entry_value(e, d, d->default_val ? d->default_val : "", 0);
|
||||
if (!APP_IS_OK(err)) { return err; }
|
||||
}
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
/* -- file parser ----------------------------------------------------------- */
|
||||
@@ -147,8 +180,10 @@ static struct App_Error parse_file(struct Config *cfg, const char *path) {
|
||||
|
||||
char line[MAX_LINE];
|
||||
char section[MAX_STR] = "";
|
||||
int lineno = 0;
|
||||
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
lineno++;
|
||||
strip_comment(line);
|
||||
strip(line);
|
||||
if (line[0] == '\0') { continue; }
|
||||
@@ -178,7 +213,9 @@ static struct App_Error parse_file(struct Config *cfg, const char *path) {
|
||||
struct Config_Entry *e = find_entry(cfg, section, key);
|
||||
if (!e) { e = add_entry(cfg, section, key); }
|
||||
if (!e) { continue; }
|
||||
set_entry_value(e, def, val);
|
||||
|
||||
struct App_Error err = set_entry_value(e, def, val, lineno);
|
||||
if (!APP_IS_OK(err)) { fclose(f); return err; }
|
||||
}
|
||||
|
||||
fclose(f);
|
||||
@@ -197,7 +234,9 @@ struct App_Error config_load(struct Config **out, const char *path,
|
||||
struct App_Error err = parse_file(cfg, path);
|
||||
if (!APP_IS_OK(err)) { free(cfg); return err; }
|
||||
|
||||
fill_defaults(cfg);
|
||||
err = fill_defaults(cfg);
|
||||
if (!APP_IS_OK(err)) { free(cfg); return err; }
|
||||
|
||||
*out = cfg;
|
||||
return APP_OK;
|
||||
}
|
||||
@@ -208,7 +247,10 @@ struct App_Error config_defaults(struct Config **out,
|
||||
struct Config *cfg = calloc(1, sizeof(*cfg));
|
||||
if (!cfg) { return APP_SYSCALL_ERROR(); }
|
||||
cfg->schema = schema;
|
||||
fill_defaults(cfg);
|
||||
|
||||
struct App_Error err = fill_defaults(cfg);
|
||||
if (!APP_IS_OK(err)) { free(cfg); return err; }
|
||||
|
||||
*out = cfg;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/discovery
|
||||
|
||||
all: $(MODULE_BUILD)/discovery.o
|
||||
|
||||
$(MODULE_BUILD)/discovery.o: discovery.c $(ROOT)/include/discovery.h | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) -c -o $@ $<
|
||||
$(MODULE_BUILD)/discovery.o: discovery.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/discovery.o
|
||||
rm -f $(MODULE_BUILD)/discovery.o $(MODULE_BUILD)/discovery.d
|
||||
|
||||
-include $(MODULE_BUILD)/discovery.d
|
||||
|
||||
@@ -40,7 +40,9 @@ struct Discovery {
|
||||
pthread_t announce_thread;
|
||||
pthread_t receive_thread;
|
||||
atomic_int running;
|
||||
atomic_int early_announce; /* set when a new peer is seen */
|
||||
|
||||
pthread_mutex_t announce_mutex;
|
||||
pthread_cond_t announce_cond; /* signaled to wake announce thread early */
|
||||
|
||||
pthread_mutex_t peers_mutex;
|
||||
struct Peer_Entry peers[DISCOVERY_MAX_PEERS];
|
||||
@@ -54,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.tcp_port == tcp_port) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
@@ -74,19 +76,14 @@ static int find_slot(struct Discovery *d) {
|
||||
|
||||
/* -- send ------------------------------------------------------------------ */
|
||||
|
||||
static void send_announcement(struct Discovery *d) {
|
||||
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);
|
||||
@@ -95,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) {
|
||||
@@ -131,23 +144,26 @@ static void *announce_thread_fn(void *arg) {
|
||||
|
||||
send_announcement(d);
|
||||
|
||||
pthread_mutex_lock(&d->announce_mutex);
|
||||
while (atomic_load(&d->running)) {
|
||||
/* sleep in 100 ms increments; breaks early if a new peer is detected
|
||||
* or if destroy is called */
|
||||
uint32_t elapsed = 0;
|
||||
while (atomic_load(&d->running) && elapsed < d->config.interval_ms) {
|
||||
if (atomic_load(&d->early_announce)) { break; }
|
||||
struct timespec ts = { .tv_sec = 0, .tv_nsec = 100 * 1000000L };
|
||||
nanosleep(&ts, NULL);
|
||||
elapsed += 100;
|
||||
struct timespec abs;
|
||||
clock_gettime(CLOCK_REALTIME, &abs);
|
||||
uint32_t ms = d->config.interval_ms;
|
||||
abs.tv_sec += ms / 1000u;
|
||||
abs.tv_nsec += (long)(ms % 1000u) * 1000000L;
|
||||
if (abs.tv_nsec >= 1000000000L) {
|
||||
abs.tv_sec++;
|
||||
abs.tv_nsec -= 1000000000L;
|
||||
}
|
||||
/* blocks until signaled (new peer / shutdown) or interval elapses */
|
||||
pthread_cond_timedwait(&d->announce_cond, &d->announce_mutex, &abs);
|
||||
|
||||
if (!atomic_load(&d->running)) { break; }
|
||||
|
||||
atomic_store(&d->early_announce, 0);
|
||||
send_announcement(d);
|
||||
check_timeouts(d);
|
||||
}
|
||||
pthread_mutex_unlock(&d->announce_mutex);
|
||||
|
||||
return NULL;
|
||||
}
|
||||
@@ -193,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 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;
|
||||
@@ -228,11 +247,15 @@ static void *receive_thread_fn(void *arg) {
|
||||
|
||||
pthread_mutex_unlock(&d->peers_mutex);
|
||||
|
||||
if (is_new) {
|
||||
atomic_store(&d->early_announce, 1);
|
||||
if (d->config.on_peer_found) {
|
||||
d->config.on_peer_found(&peer_copy, d->config.userdata);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +285,8 @@ struct App_Error discovery_create(struct Discovery **out,
|
||||
inet_pton(AF_INET, DISCOVERY_MULTICAST_GROUP, &d->mcast_addr.sin_addr);
|
||||
|
||||
atomic_init(&d->running, 0);
|
||||
atomic_init(&d->early_announce, 0);
|
||||
pthread_mutex_init(&d->announce_mutex, NULL);
|
||||
pthread_cond_init(&d->announce_cond, NULL);
|
||||
pthread_mutex_init(&d->peers_mutex, NULL);
|
||||
|
||||
*out = d;
|
||||
@@ -326,9 +350,15 @@ struct App_Error discovery_start(struct Discovery *d) {
|
||||
|
||||
void discovery_destroy(struct Discovery *d) {
|
||||
atomic_store(&d->running, 0);
|
||||
/* wake announce thread so it exits without waiting for the full interval */
|
||||
pthread_mutex_lock(&d->announce_mutex);
|
||||
pthread_cond_signal(&d->announce_cond);
|
||||
pthread_mutex_unlock(&d->announce_mutex);
|
||||
close(d->sock);
|
||||
pthread_join(d->announce_thread, NULL);
|
||||
pthread_join(d->receive_thread, NULL);
|
||||
pthread_cond_destroy(&d->announce_cond);
|
||||
pthread_mutex_destroy(&d->announce_mutex);
|
||||
pthread_mutex_destroy(&d->peers_mutex);
|
||||
free(d);
|
||||
}
|
||||
|
||||
19
src/modules/ingest/Makefile
Normal file
19
src/modules/ingest/Makefile
Normal file
@@ -0,0 +1,19 @@
|
||||
ROOT := $(abspath ../../..)
|
||||
include $(ROOT)/common.mk
|
||||
|
||||
MODULE_BUILD = $(BUILD)/ingest
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(MODULE_BUILD)/ingest.o
|
||||
|
||||
$(MODULE_BUILD)/ingest.o: ingest.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/ingest.o $(MODULE_BUILD)/ingest.d
|
||||
|
||||
-include $(MODULE_BUILD)/ingest.d
|
||||
292
src/modules/ingest/ingest.c
Normal file
292
src/modules/ingest/ingest.c
Normal file
@@ -0,0 +1,292 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/select.h>
|
||||
#include <pthread.h>
|
||||
#include <stdatomic.h>
|
||||
#include <linux/videodev2.h>
|
||||
|
||||
#include "ingest.h"
|
||||
#include "v4l2_fmt.h"
|
||||
#include "error.h"
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Internal types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define INGEST_N_BUFS 4
|
||||
|
||||
struct Mmap_Buf {
|
||||
void *start;
|
||||
size_t length;
|
||||
};
|
||||
|
||||
struct Ingest_Handle {
|
||||
int fd;
|
||||
struct Mmap_Buf bufs[INGEST_N_BUFS];
|
||||
int buf_count;
|
||||
|
||||
int width, height;
|
||||
uint32_t pixfmt;
|
||||
int fps_n, fps_d;
|
||||
|
||||
Ingest_Frame_Fn on_frame;
|
||||
Ingest_Error_Fn on_error;
|
||||
void *userdata;
|
||||
|
||||
pthread_t thread;
|
||||
atomic_int running; /* 1 = thread should keep going; 0 = stop */
|
||||
int started; /* 1 = pthread_create was called */
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Capture thread
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static void *capture_thread(void *arg)
|
||||
{
|
||||
struct Ingest_Handle *h = arg;
|
||||
|
||||
while (atomic_load(&h->running)) {
|
||||
fd_set fds;
|
||||
FD_ZERO(&fds);
|
||||
FD_SET(h->fd, &fds);
|
||||
struct timeval tv = { 0, 100000 }; /* 100 ms — keeps stop latency short */
|
||||
|
||||
int r = select(h->fd + 1, &fds, NULL, NULL, &tv);
|
||||
if (r < 0) {
|
||||
if (errno == EINTR) { continue; }
|
||||
if (h->on_error) { h->on_error("select failed", h->userdata); }
|
||||
break;
|
||||
}
|
||||
if (r == 0) {
|
||||
continue; /* timeout — recheck running flag */
|
||||
}
|
||||
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_DQBUF, &buf) < 0) {
|
||||
if (errno == EAGAIN) { continue; }
|
||||
if (h->on_error) { h->on_error("VIDIOC_DQBUF failed", h->userdata); }
|
||||
break;
|
||||
}
|
||||
|
||||
h->on_frame(
|
||||
(const uint8_t *)h->bufs[buf.index].start,
|
||||
buf.bytesused,
|
||||
h->width, h->height, h->pixfmt,
|
||||
h->userdata);
|
||||
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_QBUF, &buf) < 0) {
|
||||
if (h->on_error) { h->on_error("VIDIOC_QBUF failed", h->userdata); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
atomic_store(&h->running, 0);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Public API
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
struct App_Error ingest_open(const struct Ingest_Config *cfg, Ingest_Handle **out)
|
||||
{
|
||||
struct Ingest_Handle *h = calloc(1, sizeof(*h));
|
||||
if (!h) { return APP_SYSCALL_ERROR(); }
|
||||
|
||||
h->fd = -1;
|
||||
h->on_frame = cfg->on_frame;
|
||||
h->on_error = cfg->on_error;
|
||||
h->userdata = cfg->userdata;
|
||||
atomic_init(&h->running, 0);
|
||||
|
||||
/* Open device */
|
||||
h->fd = open(cfg->device, O_RDWR | O_NONBLOCK);
|
||||
if (h->fd < 0) {
|
||||
free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
|
||||
/* Verify capture + streaming capability */
|
||||
struct v4l2_capability cap = {0};
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_QUERYCAP, &cap) < 0) {
|
||||
close(h->fd); free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE) ||
|
||||
!(cap.capabilities & V4L2_CAP_STREAMING)) {
|
||||
close(h->fd); free(h);
|
||||
return APP_INVALID_ERROR_MSG(0, "device does not support MJPEG streaming capture");
|
||||
}
|
||||
|
||||
/* Format selection */
|
||||
uint32_t want_pixfmt = cfg->pixfmt ? cfg->pixfmt : V4L2_PIX_FMT_MJPEG;
|
||||
|
||||
V4l2_Fmt_Option opts[V4L2_FMT_MAX_OPTS];
|
||||
int n = v4l2_enumerate_formats(h->fd, opts, V4L2_FMT_MAX_OPTS, want_pixfmt);
|
||||
if (n == 0) {
|
||||
close(h->fd); free(h);
|
||||
return APP_INVALID_ERROR_MSG(0, "no matching formats found on device");
|
||||
}
|
||||
|
||||
/* If caller specified exact w/h use that, otherwise auto-select best */
|
||||
const V4l2_Fmt_Option *chosen;
|
||||
if (cfg->width > 0 && cfg->height > 0) {
|
||||
chosen = NULL;
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (opts[i].w == cfg->width && opts[i].h == cfg->height) {
|
||||
if (!chosen || v4l2_fmt_fps_gt(&opts[i], chosen)) {
|
||||
chosen = &opts[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!chosen) {
|
||||
/* Exact size not found — fall back to best available */
|
||||
chosen = v4l2_select_best(opts, n);
|
||||
}
|
||||
} else {
|
||||
chosen = v4l2_select_best(opts, n);
|
||||
}
|
||||
|
||||
/* Apply format */
|
||||
struct v4l2_format fmt = {0};
|
||||
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
fmt.fmt.pix.pixelformat = chosen->pixfmt;
|
||||
fmt.fmt.pix.width = (uint32_t)chosen->w;
|
||||
fmt.fmt.pix.height = (uint32_t)chosen->h;
|
||||
fmt.fmt.pix.field = V4L2_FIELD_ANY;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_S_FMT, &fmt) < 0) {
|
||||
close(h->fd); free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
|
||||
h->width = (int)fmt.fmt.pix.width;
|
||||
h->height = (int)fmt.fmt.pix.height;
|
||||
h->pixfmt = fmt.fmt.pix.pixelformat;
|
||||
|
||||
/* Apply frame rate */
|
||||
{
|
||||
struct v4l2_streamparm parm = {0};
|
||||
parm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
parm.parm.capture.timeperframe.numerator = (uint32_t)chosen->fps_d;
|
||||
parm.parm.capture.timeperframe.denominator = (uint32_t)chosen->fps_n;
|
||||
v4l2_xioctl(h->fd, VIDIOC_S_PARM, &parm);
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_G_PARM, &parm) == 0 &&
|
||||
parm.parm.capture.timeperframe.denominator > 0) {
|
||||
h->fps_n = (int)parm.parm.capture.timeperframe.denominator;
|
||||
h->fps_d = (int)parm.parm.capture.timeperframe.numerator;
|
||||
} else {
|
||||
h->fps_n = chosen->fps_n;
|
||||
h->fps_d = chosen->fps_d;
|
||||
}
|
||||
}
|
||||
|
||||
/* Allocate MMAP buffers */
|
||||
struct v4l2_requestbuffers req = {0};
|
||||
req.count = INGEST_N_BUFS;
|
||||
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
req.memory = V4L2_MEMORY_MMAP;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_REQBUFS, &req) < 0) {
|
||||
close(h->fd); free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
|
||||
h->buf_count = (int)req.count;
|
||||
for (int i = 0; i < h->buf_count; i++) {
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
buf.index = (uint32_t)i;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_QUERYBUF, &buf) < 0) {
|
||||
/* Unmap already-mapped buffers before returning */
|
||||
for (int j = 0; j < i; j++) {
|
||||
munmap(h->bufs[j].start, h->bufs[j].length);
|
||||
}
|
||||
close(h->fd); free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
h->bufs[i].length = buf.length;
|
||||
h->bufs[i].start = mmap(NULL, buf.length,
|
||||
PROT_READ | PROT_WRITE, MAP_SHARED, h->fd, buf.m.offset);
|
||||
if (h->bufs[i].start == MAP_FAILED) {
|
||||
for (int j = 0; j < i; j++) {
|
||||
munmap(h->bufs[j].start, h->bufs[j].length);
|
||||
}
|
||||
close(h->fd); free(h);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
}
|
||||
|
||||
*out = h;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error ingest_start(Ingest_Handle *h)
|
||||
{
|
||||
/* Queue all buffers */
|
||||
for (int i = 0; i < h->buf_count; i++) {
|
||||
struct v4l2_buffer buf = {0};
|
||||
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
buf.memory = V4L2_MEMORY_MMAP;
|
||||
buf.index = (uint32_t)i;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_QBUF, &buf) < 0) {
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
}
|
||||
|
||||
/* Enable streaming */
|
||||
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
if (v4l2_xioctl(h->fd, VIDIOC_STREAMON, &type) < 0) {
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
|
||||
/* Start capture thread */
|
||||
atomic_store(&h->running, 1);
|
||||
if (pthread_create(&h->thread, NULL, capture_thread, h) != 0) {
|
||||
atomic_store(&h->running, 0);
|
||||
v4l2_xioctl(h->fd, VIDIOC_STREAMOFF, &type);
|
||||
return APP_SYSCALL_ERROR();
|
||||
}
|
||||
h->started = 1;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error ingest_stop(Ingest_Handle *h)
|
||||
{
|
||||
if (!h->started) {
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
atomic_store(&h->running, 0);
|
||||
pthread_join(h->thread, NULL);
|
||||
h->started = 0;
|
||||
|
||||
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
v4l2_xioctl(h->fd, VIDIOC_STREAMOFF, &type);
|
||||
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
void ingest_close(Ingest_Handle *h)
|
||||
{
|
||||
if (!h) { return; }
|
||||
for (int i = 0; i < h->buf_count; i++) {
|
||||
if (h->bufs[i].start && h->bufs[i].start != MAP_FAILED) {
|
||||
munmap(h->bufs[i].start, h->bufs[i].length);
|
||||
}
|
||||
}
|
||||
if (h->fd >= 0) { close(h->fd); }
|
||||
free(h);
|
||||
}
|
||||
|
||||
int ingest_width(const Ingest_Handle *h) { return h->width; }
|
||||
int ingest_height(const Ingest_Handle *h) { return h->height; }
|
||||
uint32_t ingest_pixfmt(const Ingest_Handle *h) { return h->pixfmt; }
|
||||
int ingest_fps_n(const Ingest_Handle *h) { return h->fps_n; }
|
||||
int ingest_fps_d(const Ingest_Handle *h) { return h->fps_d; }
|
||||
@@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/media_ctrl
|
||||
|
||||
all: $(MODULE_BUILD)/media_ctrl.o
|
||||
|
||||
$(MODULE_BUILD)/media_ctrl.o: media_ctrl.c $(ROOT)/include/media_ctrl.h $(ROOT)/include/error.h | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) -c -o $@ $<
|
||||
$(MODULE_BUILD)/media_ctrl.o: media_ctrl.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/media_ctrl.o
|
||||
rm -f $(MODULE_BUILD)/media_ctrl.o $(MODULE_BUILD)/media_ctrl.d
|
||||
|
||||
-include $(MODULE_BUILD)/media_ctrl.d
|
||||
|
||||
19
src/modules/protocol/Makefile
Normal file
19
src/modules/protocol/Makefile
Normal file
@@ -0,0 +1,19 @@
|
||||
ROOT := $(abspath ../../..)
|
||||
include $(ROOT)/common.mk
|
||||
|
||||
MODULE_BUILD = $(BUILD)/protocol
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(MODULE_BUILD)/protocol.o
|
||||
|
||||
$(MODULE_BUILD)/protocol.o: protocol.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/protocol.o $(MODULE_BUILD)/protocol.d
|
||||
|
||||
-include $(MODULE_BUILD)/protocol.d
|
||||
905
src/modules/protocol/protocol.c
Normal file
905
src/modules/protocol/protocol.c
Normal file
@@ -0,0 +1,905 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "protocol.h"
|
||||
#include "serial.h"
|
||||
|
||||
/* -- growable write buffer ------------------------------------------------- */
|
||||
|
||||
struct Wbuf {
|
||||
uint8_t *data;
|
||||
uint32_t len;
|
||||
uint32_t cap;
|
||||
};
|
||||
|
||||
static struct App_Error wbuf_init(struct Wbuf *b, uint32_t initial) {
|
||||
b->data = malloc(initial);
|
||||
if (!b->data) { return APP_SYSCALL_ERROR(); }
|
||||
b->len = 0;
|
||||
b->cap = initial;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
static void wbuf_free(struct Wbuf *b) {
|
||||
free(b->data);
|
||||
}
|
||||
|
||||
static struct App_Error wbuf_grow(struct Wbuf *b, uint32_t extra) {
|
||||
if (b->len + extra <= b->cap) { return APP_OK; }
|
||||
uint32_t newcap = b->cap ? b->cap : 64;
|
||||
while (newcap < b->len + extra) { newcap *= 2; }
|
||||
uint8_t *p = realloc(b->data, newcap);
|
||||
if (!p) { return APP_SYSCALL_ERROR(); }
|
||||
b->data = p;
|
||||
b->cap = newcap;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
static struct App_Error wbuf_u8(struct Wbuf *b, uint8_t v) {
|
||||
struct App_Error e = wbuf_grow(b, 1);
|
||||
if (!APP_IS_OK(e)) { return e; }
|
||||
put_u8(b->data, b->len, v);
|
||||
b->len += 1;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
static struct App_Error wbuf_u16(struct Wbuf *b, uint16_t v) {
|
||||
struct App_Error e = wbuf_grow(b, 2);
|
||||
if (!APP_IS_OK(e)) { return e; }
|
||||
put_u16(b->data, b->len, v);
|
||||
b->len += 2;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
static struct App_Error wbuf_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; }
|
||||
put_u32(b->data, b->len, v);
|
||||
b->len += 4;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
static struct App_Error wbuf_i32(struct Wbuf *b, int32_t v) {
|
||||
struct App_Error e = wbuf_grow(b, 4);
|
||||
if (!APP_IS_OK(e)) { return e; }
|
||||
put_i32(b->data, b->len, v);
|
||||
b->len += 4;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
static struct App_Error wbuf_i64(struct Wbuf *b, int64_t v) {
|
||||
struct App_Error e = wbuf_grow(b, 8);
|
||||
if (!APP_IS_OK(e)) { return e; }
|
||||
put_i64(b->data, b->len, v);
|
||||
b->len += 8;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
/* Serialise a string as u8 length prefix + bytes (truncates at 255). */
|
||||
static struct App_Error wbuf_str8(struct Wbuf *b, const char *s) {
|
||||
size_t slen = s ? strlen(s) : 0;
|
||||
uint8_t n = (slen > 255u) ? 255u : (uint8_t)slen;
|
||||
struct App_Error e = wbuf_grow(b, (uint32_t)1 + n);
|
||||
if (!APP_IS_OK(e)) { return e; }
|
||||
put_u8(b->data, b->len, n);
|
||||
b->len += 1;
|
||||
memcpy(b->data + b->len, s, n);
|
||||
b->len += n;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
static struct App_Error wbuf_bytes(struct Wbuf *b,
|
||||
const uint8_t *data, uint32_t len)
|
||||
{
|
||||
struct App_Error e = wbuf_grow(b, len);
|
||||
if (!APP_IS_OK(e)) { return e; }
|
||||
memcpy(b->data + b->len, data, len);
|
||||
b->len += len;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
/* -- sequential read cursor ------------------------------------------------ */
|
||||
|
||||
struct Cursor {
|
||||
const uint8_t *buf;
|
||||
uint32_t len;
|
||||
uint32_t pos;
|
||||
int ok;
|
||||
};
|
||||
|
||||
static void cur_init(struct Cursor *c, const uint8_t *buf, uint32_t len) {
|
||||
c->buf = buf;
|
||||
c->len = len;
|
||||
c->pos = 0;
|
||||
c->ok = 1;
|
||||
}
|
||||
|
||||
static void cur_need(struct Cursor *c, uint32_t n) {
|
||||
if (c->ok && c->pos + n > c->len) { c->ok = 0; }
|
||||
}
|
||||
|
||||
static uint8_t cur_u8(struct Cursor *c) {
|
||||
cur_need(c, 1);
|
||||
if (!c->ok) { return 0; }
|
||||
uint8_t v = get_u8(c->buf, c->pos);
|
||||
c->pos += 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
static uint16_t cur_u16(struct Cursor *c) {
|
||||
cur_need(c, 2);
|
||||
if (!c->ok) { return 0; }
|
||||
uint16_t v = get_u16(c->buf, c->pos);
|
||||
c->pos += 2;
|
||||
return v;
|
||||
}
|
||||
|
||||
static uint32_t cur_u32(struct Cursor *c) {
|
||||
cur_need(c, 4);
|
||||
if (!c->ok) { return 0; }
|
||||
uint32_t v = get_u32(c->buf, c->pos);
|
||||
c->pos += 4;
|
||||
return v;
|
||||
}
|
||||
|
||||
static int32_t cur_i32(struct Cursor *c) {
|
||||
cur_need(c, 4);
|
||||
if (!c->ok) { return 0; }
|
||||
int32_t v = get_i32(c->buf, c->pos);
|
||||
c->pos += 4;
|
||||
return v;
|
||||
}
|
||||
|
||||
static int64_t cur_i64(struct Cursor *c) {
|
||||
cur_need(c, 8);
|
||||
if (!c->ok) { return 0; }
|
||||
int64_t v = get_i64(c->buf, c->pos);
|
||||
c->pos += 8;
|
||||
return v;
|
||||
}
|
||||
|
||||
/* Read a u8-length-prefixed string; returns pointer into buf and sets *len. */
|
||||
static const char *cur_str8(struct Cursor *c, uint8_t *len_out) {
|
||||
uint8_t n = cur_u8(c);
|
||||
cur_need(c, n);
|
||||
if (!c->ok) { *len_out = 0; return NULL; }
|
||||
const char *p = (const char *)(c->buf + c->pos);
|
||||
c->pos += n;
|
||||
*len_out = n;
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
#define CUR_CHECK(c) \
|
||||
do { if (!(c).ok) { return APP_INVALID_ERROR_MSG(0, "payload too short"); } } while (0)
|
||||
|
||||
/* -- write helpers --------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* Build a CONTROL_RESPONSE base (request_id + status) into b, then append
|
||||
* extra, then send — all in one call. Used by the specific response writers.
|
||||
*/
|
||||
static struct App_Error send_response(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t status,
|
||||
const uint8_t *extra, uint32_t extra_len)
|
||||
{
|
||||
struct Wbuf b;
|
||||
struct App_Error e = wbuf_init(&b, 4 + extra_len);
|
||||
if (!APP_IS_OK(e)) { return e; }
|
||||
|
||||
e = wbuf_u16(&b, request_id); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u16(&b, status); if (!APP_IS_OK(e)) { goto fail; }
|
||||
if (extra && extra_len > 0) {
|
||||
e = wbuf_bytes(&b, extra, extra_len);
|
||||
if (!APP_IS_OK(e)) { goto fail; }
|
||||
}
|
||||
|
||||
e = transport_send_frame(conn, PROTO_MSG_CONTROL_RESPONSE, b.data, b.len);
|
||||
fail:
|
||||
wbuf_free(&b);
|
||||
return e;
|
||||
}
|
||||
|
||||
/* -- write functions ------------------------------------------------------- */
|
||||
|
||||
struct App_Error proto_write_video_frame(struct Transport_Conn *conn,
|
||||
uint16_t stream_id, const uint8_t *data, uint32_t data_len)
|
||||
{
|
||||
uint32_t total = 2u + data_len;
|
||||
uint8_t *buf = malloc(total);
|
||||
if (!buf) { return APP_SYSCALL_ERROR(); }
|
||||
put_u16(buf, 0, stream_id);
|
||||
memcpy(buf + 2, data, data_len);
|
||||
struct App_Error e = transport_send_frame(conn, PROTO_MSG_VIDEO_FRAME, buf, total);
|
||||
free(buf);
|
||||
return e;
|
||||
}
|
||||
|
||||
struct App_Error proto_write_stream_event(struct Transport_Conn *conn,
|
||||
uint16_t stream_id, uint8_t event_code)
|
||||
{
|
||||
uint8_t buf[3];
|
||||
put_u16(buf, 0, stream_id);
|
||||
put_u8 (buf, 2, event_code);
|
||||
return transport_send_frame(conn, PROTO_MSG_STREAM_EVENT, buf, 3);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_stream_open(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t stream_id,
|
||||
uint16_t format, uint16_t pixel_format, uint16_t origin)
|
||||
{
|
||||
uint8_t buf[12];
|
||||
put_u16(buf, 0, request_id);
|
||||
put_u16(buf, 2, PROTO_CMD_STREAM_OPEN);
|
||||
put_u16(buf, 4, stream_id);
|
||||
put_u16(buf, 6, format);
|
||||
put_u16(buf, 8, pixel_format);
|
||||
put_u16(buf, 10, origin);
|
||||
return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 12);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_stream_close(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t stream_id)
|
||||
{
|
||||
uint8_t buf[6];
|
||||
put_u16(buf, 0, request_id);
|
||||
put_u16(buf, 2, PROTO_CMD_STREAM_CLOSE);
|
||||
put_u16(buf, 4, stream_id);
|
||||
return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 6);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_enum_devices(struct Transport_Conn *conn,
|
||||
uint16_t request_id)
|
||||
{
|
||||
uint8_t buf[4];
|
||||
put_u16(buf, 0, request_id);
|
||||
put_u16(buf, 2, PROTO_CMD_ENUM_DEVICES);
|
||||
return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 4);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_enum_controls(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t device_index)
|
||||
{
|
||||
uint8_t buf[6];
|
||||
put_u16(buf, 0, request_id);
|
||||
put_u16(buf, 2, PROTO_CMD_ENUM_CONTROLS);
|
||||
put_u16(buf, 4, device_index);
|
||||
return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 6);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_get_control(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t device_index, uint32_t control_id)
|
||||
{
|
||||
uint8_t buf[10];
|
||||
put_u16(buf, 0, request_id);
|
||||
put_u16(buf, 2, PROTO_CMD_GET_CONTROL);
|
||||
put_u16(buf, 4, device_index);
|
||||
put_u32(buf, 6, control_id);
|
||||
return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 10);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_set_control(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t device_index,
|
||||
uint32_t control_id, int32_t value)
|
||||
{
|
||||
uint8_t buf[14];
|
||||
put_u16(buf, 0, request_id);
|
||||
put_u16(buf, 2, PROTO_CMD_SET_CONTROL);
|
||||
put_u16(buf, 4, device_index);
|
||||
put_u32(buf, 6, control_id);
|
||||
put_i32(buf, 10, value);
|
||||
return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 14);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_enum_monitors(struct Transport_Conn *conn,
|
||||
uint16_t request_id)
|
||||
{
|
||||
uint8_t buf[4];
|
||||
put_u16(buf, 0, request_id);
|
||||
put_u16(buf, 2, PROTO_CMD_ENUM_MONITORS);
|
||||
return transport_send_frame(conn, PROTO_MSG_CONTROL_REQUEST, buf, 4);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_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)
|
||||
{
|
||||
return send_response(conn, request_id, status, payload, payload_len);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_get_control_response(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t status, int32_t value)
|
||||
{
|
||||
uint8_t extra[4];
|
||||
put_i32(extra, 0, value);
|
||||
return send_response(conn, request_id, status, extra, 4);
|
||||
}
|
||||
|
||||
struct App_Error proto_write_enum_devices_response(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t status,
|
||||
const struct Proto_Media_Device_Info *media_devices, uint16_t media_count,
|
||||
const struct Proto_Standalone_Device_Info *standalone, uint16_t standalone_count,
|
||||
const struct Proto_Display_Device_Info *displays, uint16_t display_count)
|
||||
{
|
||||
struct Wbuf b;
|
||||
struct App_Error e = wbuf_init(&b, 128);
|
||||
if (!APP_IS_OK(e)) { return e; }
|
||||
|
||||
e = wbuf_u16(&b, request_id); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u16(&b, status); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u16(&b, media_count); if (!APP_IS_OK(e)) { goto fail; }
|
||||
|
||||
for (uint16_t i = 0; i < media_count; i++) {
|
||||
const struct Proto_Media_Device_Info *m = &media_devices[i];
|
||||
e = wbuf_str8(&b, m->path); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_str8(&b, m->driver); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_str8(&b, m->model); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_str8(&b, m->bus_info); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u8 (&b, m->video_node_count); if (!APP_IS_OK(e)) { goto fail; }
|
||||
|
||||
for (uint8_t j = 0; j < m->video_node_count; j++) {
|
||||
const struct Proto_Video_Node_Info *v = &m->video_nodes[j];
|
||||
e = wbuf_str8(&b, v->path); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_str8(&b, v->entity_name); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u32 (&b, v->entity_type); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u32 (&b, v->entity_flags);if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u32 (&b, v->device_caps); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u8 (&b, v->pad_flags); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u8 (&b, v->is_capture); if (!APP_IS_OK(e)) { goto fail; }
|
||||
}
|
||||
}
|
||||
|
||||
e = wbuf_u16(&b, standalone_count); if (!APP_IS_OK(e)) { goto fail; }
|
||||
for (uint16_t i = 0; i < standalone_count; i++) {
|
||||
e = wbuf_str8(&b, standalone[i].path); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_str8(&b, standalone[i].name); if (!APP_IS_OK(e)) { goto fail; }
|
||||
}
|
||||
|
||||
e = 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);
|
||||
return e;
|
||||
}
|
||||
|
||||
struct App_Error proto_write_enum_controls_response(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t status,
|
||||
const struct Proto_Control_Info *controls, uint16_t count)
|
||||
{
|
||||
struct Wbuf b;
|
||||
struct App_Error e = wbuf_init(&b, 256);
|
||||
if (!APP_IS_OK(e)) { return e; }
|
||||
|
||||
e = wbuf_u16(&b, request_id); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u16(&b, status); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u16(&b, count); if (!APP_IS_OK(e)) { goto fail; }
|
||||
|
||||
for (uint16_t i = 0; i < count; i++) {
|
||||
const struct Proto_Control_Info *c = &controls[i];
|
||||
e = wbuf_u32(&b, c->id); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u8 (&b, c->type); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u32(&b, c->flags); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_str8(&b, c->name); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_i32(&b, c->min); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_i32(&b, c->max); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_i32(&b, c->step); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_i32(&b, c->default_val); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_i32(&b, c->current_val); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u8 (&b, c->menu_count); if (!APP_IS_OK(e)) { goto fail; }
|
||||
|
||||
for (uint8_t j = 0; j < c->menu_count; j++) {
|
||||
const struct Proto_Menu_Item *m = &c->menu_items[j];
|
||||
e = wbuf_u32(&b, m->index); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_str8(&b, m->name); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_i64(&b, m->int_value); if (!APP_IS_OK(e)) { goto fail; }
|
||||
}
|
||||
}
|
||||
|
||||
e = transport_send_frame(conn, PROTO_MSG_CONTROL_RESPONSE, b.data, b.len);
|
||||
fail:
|
||||
wbuf_free(&b);
|
||||
return e;
|
||||
}
|
||||
|
||||
struct App_Error proto_write_enum_monitors_response(struct Transport_Conn *conn,
|
||||
uint16_t request_id, uint16_t status,
|
||||
const struct Proto_Monitor_Info *monitors, uint16_t count)
|
||||
{
|
||||
struct Wbuf b;
|
||||
struct App_Error e = wbuf_init(&b, 64);
|
||||
if (!APP_IS_OK(e)) { return e; }
|
||||
|
||||
e = wbuf_u16(&b, request_id); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u16(&b, status); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u16(&b, count); if (!APP_IS_OK(e)) { goto fail; }
|
||||
|
||||
for (uint16_t i = 0; i < count; i++) {
|
||||
const struct Proto_Monitor_Info *m = &monitors[i];
|
||||
e = wbuf_i32(&b, m->x); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_i32(&b, m->y); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u32(&b, m->width); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_u32(&b, m->height); if (!APP_IS_OK(e)) { goto fail; }
|
||||
e = wbuf_str8(&b, m->name); if (!APP_IS_OK(e)) { goto fail; }
|
||||
}
|
||||
|
||||
e = transport_send_frame(conn, PROTO_MSG_CONTROL_RESPONSE, b.data, b.len);
|
||||
fail:
|
||||
wbuf_free(&b);
|
||||
return e;
|
||||
}
|
||||
|
||||
/* -- read functions -------------------------------------------------------- */
|
||||
|
||||
struct App_Error proto_read_video_frame(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Video_Frame *out)
|
||||
{
|
||||
if (length < 2) { return APP_INVALID_ERROR_MSG(0, "VIDEO_FRAME payload too short"); }
|
||||
out->stream_id = get_u16(payload, 0);
|
||||
out->data = payload + 2;
|
||||
out->data_len = length - 2;
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_stream_event(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Stream_Event *out)
|
||||
{
|
||||
if (length < 3) { return APP_INVALID_ERROR_MSG(0, "STREAM_EVENT payload too short"); }
|
||||
out->stream_id = get_u16(payload, 0);
|
||||
out->event_code = get_u8 (payload, 2);
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_request_header(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Request_Header *out)
|
||||
{
|
||||
if (length < 4) { return APP_INVALID_ERROR_MSG(0, "CONTROL_REQUEST payload too short"); }
|
||||
out->request_id = get_u16(payload, 0);
|
||||
out->command = get_u16(payload, 2);
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_stream_open(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Stream_Open *out)
|
||||
{
|
||||
if (length < 12) { return APP_INVALID_ERROR_MSG(0, "STREAM_OPEN payload too short"); }
|
||||
out->request_id = get_u16(payload, 0);
|
||||
out->stream_id = get_u16(payload, 4);
|
||||
out->format = get_u16(payload, 6);
|
||||
out->pixel_format = get_u16(payload, 8);
|
||||
out->origin = get_u16(payload, 10);
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_stream_close(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Stream_Close *out)
|
||||
{
|
||||
if (length < 6) { return APP_INVALID_ERROR_MSG(0, "STREAM_CLOSE payload too short"); }
|
||||
out->request_id = get_u16(payload, 0);
|
||||
out->stream_id = get_u16(payload, 4);
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_enum_controls_req(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Enum_Controls_Req *out)
|
||||
{
|
||||
if (length < 6) { return APP_INVALID_ERROR_MSG(0, "ENUM_CONTROLS request payload too short"); }
|
||||
out->request_id = get_u16(payload, 0);
|
||||
out->device_index = get_u16(payload, 4);
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_get_control_req(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Get_Control_Req *out)
|
||||
{
|
||||
if (length < 10) { return APP_INVALID_ERROR_MSG(0, "GET_CONTROL request payload too short"); }
|
||||
out->request_id = get_u16(payload, 0);
|
||||
out->device_index = get_u16(payload, 4);
|
||||
out->control_id = get_u32(payload, 6);
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_set_control_req(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Set_Control_Req *out)
|
||||
{
|
||||
if (length < 14) { return APP_INVALID_ERROR_MSG(0, "SET_CONTROL request payload too short"); }
|
||||
out->request_id = get_u16(payload, 0);
|
||||
out->device_index = get_u16(payload, 4);
|
||||
out->control_id = get_u32(payload, 6);
|
||||
out->value = get_i32(payload, 10);
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_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)
|
||||
{
|
||||
if (length < 4) { return APP_INVALID_ERROR_MSG(0, "CONTROL_RESPONSE payload too short"); }
|
||||
out->request_id = get_u16(payload, 0);
|
||||
out->status = get_u16(payload, 2);
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_get_control_response(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Get_Control_Resp *out)
|
||||
{
|
||||
if (length < 8) { return APP_INVALID_ERROR_MSG(0, "GET_CONTROL response payload too short"); }
|
||||
out->request_id = get_u16(payload, 0);
|
||||
out->status = get_u16(payload, 2);
|
||||
out->value = get_i32(payload, 4);
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_enum_devices_response(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Response_Header *header_out,
|
||||
void (*on_media_device)(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *driver, uint8_t driver_len,
|
||||
const char *model, uint8_t model_len,
|
||||
const char *bus_info, uint8_t bus_info_len,
|
||||
uint8_t video_node_count,
|
||||
void *userdata),
|
||||
void (*on_video_node)(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *entity_name, uint8_t entity_name_len,
|
||||
uint32_t entity_type, uint32_t entity_flags,
|
||||
uint32_t device_caps,
|
||||
uint8_t pad_flags, uint8_t is_capture,
|
||||
void *userdata),
|
||||
void (*on_standalone)(
|
||||
const char *path, uint8_t path_len,
|
||||
const char *name, uint8_t name_len,
|
||||
void *userdata),
|
||||
void (*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;
|
||||
cur_init(&c, payload, length);
|
||||
|
||||
header_out->request_id = cur_u16(&c);
|
||||
header_out->status = cur_u16(&c);
|
||||
uint16_t media_count = cur_u16(&c);
|
||||
CUR_CHECK(c);
|
||||
|
||||
for (uint16_t i = 0; i < media_count; i++) {
|
||||
uint8_t path_len, driver_len, model_len, bus_info_len;
|
||||
const char *path = cur_str8(&c, &path_len);
|
||||
const char *driver = cur_str8(&c, &driver_len);
|
||||
const char *model = cur_str8(&c, &model_len);
|
||||
const char *bus_info = cur_str8(&c, &bus_info_len);
|
||||
uint8_t vcount = cur_u8(&c);
|
||||
CUR_CHECK(c);
|
||||
|
||||
if (on_media_device) {
|
||||
on_media_device(path, path_len, driver, driver_len,
|
||||
model, model_len, bus_info, bus_info_len,
|
||||
vcount, userdata);
|
||||
}
|
||||
|
||||
for (uint8_t j = 0; j < vcount; j++) {
|
||||
uint8_t vpath_len, ename_len;
|
||||
const char *vpath = cur_str8(&c, &vpath_len);
|
||||
const char *ename = cur_str8(&c, &ename_len);
|
||||
uint32_t etype = cur_u32(&c);
|
||||
uint32_t eflags = cur_u32(&c);
|
||||
uint32_t dcaps = cur_u32(&c);
|
||||
uint8_t pflags = cur_u8(&c);
|
||||
uint8_t iscap = cur_u8(&c);
|
||||
CUR_CHECK(c);
|
||||
|
||||
if (on_video_node) {
|
||||
on_video_node(vpath, vpath_len, ename, ename_len,
|
||||
etype, eflags, dcaps, pflags, iscap, userdata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t standalone_count = cur_u16(&c);
|
||||
CUR_CHECK(c);
|
||||
|
||||
for (uint16_t i = 0; i < standalone_count; i++) {
|
||||
uint8_t path_len, name_len;
|
||||
const char *path = cur_str8(&c, &path_len);
|
||||
const char *name = cur_str8(&c, &name_len);
|
||||
CUR_CHECK(c);
|
||||
if (on_standalone) { on_standalone(path, path_len, name, name_len, userdata); }
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_enum_controls_response(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Response_Header *header_out,
|
||||
void (*on_control)(
|
||||
uint32_t id, uint8_t type, uint32_t flags,
|
||||
const char *name, uint8_t name_len,
|
||||
int32_t min, int32_t max, int32_t step,
|
||||
int32_t default_val, int32_t current_val,
|
||||
uint8_t menu_count, void *userdata),
|
||||
void (*on_menu_item)(
|
||||
uint32_t index,
|
||||
const char *name, uint8_t name_len,
|
||||
int64_t int_value,
|
||||
void *userdata),
|
||||
void *userdata)
|
||||
{
|
||||
struct Cursor c;
|
||||
cur_init(&c, payload, length);
|
||||
|
||||
header_out->request_id = cur_u16(&c);
|
||||
header_out->status = cur_u16(&c);
|
||||
CUR_CHECK(c);
|
||||
if (header_out->status != PROTO_STATUS_OK) { return APP_OK; }
|
||||
uint16_t count = cur_u16(&c);
|
||||
CUR_CHECK(c);
|
||||
|
||||
for (uint16_t i = 0; i < count; i++) {
|
||||
uint32_t id = cur_u32(&c);
|
||||
uint8_t type = cur_u8 (&c);
|
||||
uint32_t flags = cur_u32(&c);
|
||||
uint8_t name_len;
|
||||
const char *name = cur_str8(&c, &name_len);
|
||||
int32_t min = cur_i32(&c);
|
||||
int32_t max = cur_i32(&c);
|
||||
int32_t step = cur_i32(&c);
|
||||
int32_t def = cur_i32(&c);
|
||||
int32_t cur = cur_i32(&c);
|
||||
uint8_t menu_count = cur_u8(&c);
|
||||
CUR_CHECK(c);
|
||||
|
||||
if (on_control) {
|
||||
on_control(id, type, flags, name, name_len,
|
||||
min, max, step, def, cur, menu_count, userdata);
|
||||
}
|
||||
|
||||
for (uint8_t j = 0; j < menu_count; j++) {
|
||||
uint32_t midx = cur_u32(&c);
|
||||
uint8_t mname_len;
|
||||
const char *mname = cur_str8(&c, &mname_len);
|
||||
int64_t mval = cur_i64(&c);
|
||||
CUR_CHECK(c);
|
||||
if (on_menu_item) {
|
||||
on_menu_item(midx, mname, mname_len, mval, userdata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return APP_OK;
|
||||
}
|
||||
|
||||
struct App_Error proto_read_enum_monitors_response(
|
||||
const uint8_t *payload, uint32_t length,
|
||||
struct Proto_Response_Header *header_out,
|
||||
void (*on_monitor)(
|
||||
int32_t x, int32_t y, uint32_t width, uint32_t height,
|
||||
const char *name, uint8_t name_len,
|
||||
void *userdata),
|
||||
void *userdata)
|
||||
{
|
||||
struct Cursor c;
|
||||
cur_init(&c, payload, length);
|
||||
|
||||
header_out->request_id = cur_u16(&c);
|
||||
header_out->status = cur_u16(&c);
|
||||
uint16_t count = cur_u16(&c);
|
||||
CUR_CHECK(c);
|
||||
|
||||
for (uint16_t i = 0; i < count; i++) {
|
||||
int32_t x = cur_i32(&c);
|
||||
int32_t y = cur_i32(&c);
|
||||
uint32_t width = cur_u32(&c);
|
||||
uint32_t height = cur_u32(&c);
|
||||
uint8_t name_len;
|
||||
const char *name = cur_str8(&c, &name_len);
|
||||
CUR_CHECK(c);
|
||||
if (on_monitor) {
|
||||
on_monitor(x, y, width, height, name, name_len, userdata);
|
||||
}
|
||||
}
|
||||
|
||||
return APP_OK;
|
||||
}
|
||||
19
src/modules/reconciler/Makefile
Normal file
19
src/modules/reconciler/Makefile
Normal file
@@ -0,0 +1,19 @@
|
||||
ROOT := $(abspath ../../..)
|
||||
include $(ROOT)/common.mk
|
||||
|
||||
MODULE_BUILD = $(BUILD)/reconciler
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(MODULE_BUILD)/reconciler.o
|
||||
|
||||
$(MODULE_BUILD)/reconciler.o: reconciler.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/reconciler.o $(MODULE_BUILD)/reconciler.d
|
||||
|
||||
-include $(MODULE_BUILD)/reconciler.d
|
||||
278
src/modules/reconciler/reconciler.c
Normal file
278
src/modules/reconciler/reconciler.c
Normal file
@@ -0,0 +1,278 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "reconciler.h"
|
||||
|
||||
#define REC_MAX_RESOURCES 32
|
||||
#define REC_MAX_STATES 16
|
||||
#define REC_MAX_DEPS 8
|
||||
|
||||
struct Rec_Dep {
|
||||
struct Rec_Resource *dep;
|
||||
int dep_min_state;
|
||||
int blocked_below;
|
||||
};
|
||||
|
||||
struct Rec_Resource {
|
||||
char name[32];
|
||||
const struct Rec_Transition *transitions;
|
||||
int state_count;
|
||||
const char **state_names;
|
||||
int current_state;
|
||||
int wanted_state;
|
||||
void *userdata;
|
||||
struct Rec_Dep deps[REC_MAX_DEPS];
|
||||
int dep_count;
|
||||
};
|
||||
|
||||
struct Reconciler {
|
||||
struct Rec_Resource resources[REC_MAX_RESOURCES];
|
||||
int count;
|
||||
Rec_Log_Fn log_fn;
|
||||
void *log_userdata;
|
||||
};
|
||||
|
||||
struct Reconciler *reconciler_create(void) {
|
||||
struct Reconciler *r = calloc(1, sizeof(struct Reconciler));
|
||||
return r;
|
||||
}
|
||||
|
||||
void reconciler_destroy(struct Reconciler *r) {
|
||||
free(r);
|
||||
}
|
||||
|
||||
void reconciler_set_log(struct Reconciler *r, Rec_Log_Fn fn, void *userdata) {
|
||||
r->log_fn = fn;
|
||||
r->log_userdata = userdata;
|
||||
}
|
||||
|
||||
struct Rec_Resource *reconciler_add_resource(
|
||||
struct Reconciler *r,
|
||||
const char *name,
|
||||
const struct Rec_Transition *transitions,
|
||||
int state_count,
|
||||
const char **state_names,
|
||||
int initial_state,
|
||||
void *userdata)
|
||||
{
|
||||
if (r->count >= REC_MAX_RESOURCES) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
struct Rec_Resource *res = &r->resources[r->count++];
|
||||
memset(res, 0, sizeof(*res));
|
||||
strncpy(res->name, name, sizeof(res->name) - 1);
|
||||
res->transitions = transitions;
|
||||
res->state_count = state_count;
|
||||
res->state_names = state_names;
|
||||
res->current_state = initial_state;
|
||||
res->wanted_state = initial_state;
|
||||
res->userdata = userdata;
|
||||
res->dep_count = 0;
|
||||
return res;
|
||||
}
|
||||
|
||||
void reconciler_add_dep(
|
||||
struct Rec_Resource *resource,
|
||||
int blocked_below,
|
||||
struct Rec_Resource *dep,
|
||||
int dep_min_state)
|
||||
{
|
||||
if (resource->dep_count >= REC_MAX_DEPS) {
|
||||
return;
|
||||
}
|
||||
|
||||
struct Rec_Dep *d = &resource->deps[resource->dep_count++];
|
||||
d->dep = dep;
|
||||
d->dep_min_state = dep_min_state;
|
||||
d->blocked_below = blocked_below;
|
||||
}
|
||||
|
||||
void reconciler_set_wanted(struct Rec_Resource *r, int wanted_state) {
|
||||
r->wanted_state = wanted_state;
|
||||
}
|
||||
|
||||
void reconciler_force_current(struct Rec_Resource *r, int state) {
|
||||
r->current_state = state;
|
||||
}
|
||||
|
||||
int reconciler_get_current(const struct Rec_Resource *r) {
|
||||
return r->current_state;
|
||||
}
|
||||
|
||||
int reconciler_get_wanted(const struct Rec_Resource *r) {
|
||||
return r->wanted_state;
|
||||
}
|
||||
|
||||
const char *reconciler_get_name(const struct Rec_Resource *r) {
|
||||
return r->name;
|
||||
}
|
||||
|
||||
const char *reconciler_state_name(const struct Rec_Resource *r, int state) {
|
||||
if (r->state_names != NULL && state >= 0 && state < r->state_count) {
|
||||
return r->state_names[state];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/*
|
||||
* BFS over the transition graph to find the shortest path from
|
||||
* current_state to wanted_state. Returns the first transition on
|
||||
* that path, or NULL if no path exists (or already stable).
|
||||
*/
|
||||
static const struct Rec_Transition *find_next_transition(const struct Rec_Resource *res) {
|
||||
if (res->current_state == res->wanted_state) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* prev[s] = index of transition in res->transitions that leads into state s,
|
||||
* or -1 if not yet visited. */
|
||||
int prev_trans[REC_MAX_STATES];
|
||||
int visited[REC_MAX_STATES];
|
||||
for (int i = 0; i < REC_MAX_STATES; i++) {
|
||||
prev_trans[i] = -1;
|
||||
visited[i] = 0;
|
||||
}
|
||||
|
||||
/* BFS queue — state indices. */
|
||||
int queue[REC_MAX_STATES];
|
||||
int head = 0;
|
||||
int tail = 0;
|
||||
|
||||
visited[res->current_state] = 1;
|
||||
queue[tail++] = res->current_state;
|
||||
|
||||
int found = 0;
|
||||
|
||||
while (head < tail && !found) {
|
||||
int cur = queue[head++];
|
||||
|
||||
for (int i = 0; ; i++) {
|
||||
const struct Rec_Transition *t = &res->transitions[i];
|
||||
if (t->from == -1 && t->to == -1 && t->action == NULL) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (t->from != cur) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int next = t->to;
|
||||
if (next < 0 || next >= REC_MAX_STATES) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (visited[next]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited[next] = 1;
|
||||
prev_trans[next] = i;
|
||||
queue[tail++] = next;
|
||||
|
||||
if (next == res->wanted_state) {
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Walk back from wanted_state to find the first step. */
|
||||
int state = res->wanted_state;
|
||||
int first_trans_idx = prev_trans[state];
|
||||
|
||||
while (1) {
|
||||
int ti = prev_trans[state];
|
||||
if (ti == -1) {
|
||||
break;
|
||||
}
|
||||
int from_state = res->transitions[ti].from;
|
||||
if (from_state == res->current_state) {
|
||||
first_trans_idx = ti;
|
||||
break;
|
||||
}
|
||||
first_trans_idx = ti;
|
||||
state = from_state;
|
||||
}
|
||||
|
||||
return &res->transitions[first_trans_idx];
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns 1 if all dependencies allow the resource to enter next_state.
|
||||
* Returns 0 if any dependency blocks it.
|
||||
*/
|
||||
static int deps_allow(const struct Rec_Resource *res, int next_state) {
|
||||
for (int i = 0; i < res->dep_count; i++) {
|
||||
const struct Rec_Dep *d = &res->deps[i];
|
||||
if (next_state >= d->blocked_below && d->dep->current_state < d->dep_min_state) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
Rec_Status reconciler_get_status(const struct Rec_Resource *r) {
|
||||
if (r->current_state == r->wanted_state) {
|
||||
return REC_STATUS_STABLE;
|
||||
}
|
||||
|
||||
const struct Rec_Transition *t = find_next_transition(r);
|
||||
if (t == NULL) {
|
||||
return REC_STATUS_NO_PATH;
|
||||
}
|
||||
|
||||
if (!deps_allow(r, t->to)) {
|
||||
return REC_STATUS_BLOCKED;
|
||||
}
|
||||
|
||||
return REC_STATUS_WORKING;
|
||||
}
|
||||
|
||||
int reconciler_tick(struct Reconciler *r) {
|
||||
int attempted = 0;
|
||||
|
||||
for (int i = 0; i < r->count; i++) {
|
||||
struct Rec_Resource *res = &r->resources[i];
|
||||
|
||||
if (res->current_state == res->wanted_state) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const struct Rec_Transition *t = find_next_transition(res);
|
||||
if (t == NULL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!deps_allow(res, t->to)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int from = res->current_state;
|
||||
int to = t->to;
|
||||
int success = t->action(res->userdata);
|
||||
attempted++;
|
||||
|
||||
if (success) {
|
||||
res->current_state = to;
|
||||
}
|
||||
|
||||
if (r->log_fn != NULL) {
|
||||
r->log_fn(res, from, to, success, r->log_userdata);
|
||||
}
|
||||
}
|
||||
|
||||
return attempted;
|
||||
}
|
||||
|
||||
int reconciler_is_stable(const struct Reconciler *r) {
|
||||
for (int i = 0; i < r->count; i++) {
|
||||
if (r->resources[i].current_state != r->resources[i].wanted_state) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/serial
|
||||
|
||||
all: $(MODULE_BUILD)/serial.o
|
||||
|
||||
$(MODULE_BUILD)/serial.o: serial.c $(ROOT)/include/serial.h | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) -c -o $@ $<
|
||||
$(MODULE_BUILD)/serial.o: serial.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/serial.o
|
||||
rm -f $(MODULE_BUILD)/serial.o $(MODULE_BUILD)/serial.d
|
||||
|
||||
-include $(MODULE_BUILD)/serial.d
|
||||
|
||||
19
src/modules/test_image/Makefile
Normal file
19
src/modules/test_image/Makefile
Normal file
@@ -0,0 +1,19 @@
|
||||
ROOT := $(abspath ../../..)
|
||||
include $(ROOT)/common.mk
|
||||
|
||||
MODULE_BUILD = $(BUILD)/test_image
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(MODULE_BUILD)/test_image.o
|
||||
|
||||
$(MODULE_BUILD)/test_image.o: test_image.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/test_image.o $(MODULE_BUILD)/test_image.d
|
||||
|
||||
-include $(MODULE_BUILD)/test_image.d
|
||||
179
src/modules/test_image/test_image.c
Normal file
179
src/modules/test_image/test_image.c
Normal file
@@ -0,0 +1,179 @@
|
||||
#include <stdlib.h>
|
||||
#include "test_image.h"
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* BT.601 limited-range RGB → YCbCr */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void rgb_to_ycbcr(int r, int g, int b,
|
||||
uint8_t *y, uint8_t *cb, uint8_t *cr)
|
||||
{
|
||||
*y = (uint8_t)((( 66*r + 129*g + 25*b + 128) >> 8) + 16);
|
||||
*cb = (uint8_t)((( -38*r - 74*g + 112*b + 128) >> 8) + 128);
|
||||
*cr = (uint8_t)((( 112*r - 94*g - 18*b + 128) >> 8) + 128);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Allocation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Test_Frame *test_image_alloc(int width, int height, Test_Fmt fmt)
|
||||
{
|
||||
Test_Frame *f = calloc(1, sizeof(*f));
|
||||
if (!f) { return NULL; }
|
||||
|
||||
f->width = width;
|
||||
f->height = height;
|
||||
f->fmt = fmt;
|
||||
|
||||
size_t buf_size;
|
||||
|
||||
switch (fmt) {
|
||||
case TEST_FMT_YUV420:
|
||||
f->stride[0] = width;
|
||||
f->stride[1] = width / 2;
|
||||
f->stride[2] = width / 2;
|
||||
buf_size = (size_t)width * height
|
||||
+ (size_t)(width / 2) * (height / 2) * 2;
|
||||
break;
|
||||
case TEST_FMT_YUV422:
|
||||
f->stride[0] = width;
|
||||
f->stride[1] = width / 2;
|
||||
f->stride[2] = width / 2;
|
||||
buf_size = (size_t)width * height
|
||||
+ (size_t)(width / 2) * height * 2;
|
||||
break;
|
||||
case TEST_FMT_BGRA:
|
||||
f->stride[0] = width * 4;
|
||||
f->stride[1] = 0;
|
||||
f->stride[2] = 0;
|
||||
buf_size = (size_t)width * height * 4;
|
||||
break;
|
||||
default:
|
||||
free(f);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
uint8_t *buf = malloc(buf_size);
|
||||
if (!buf) { free(f); return NULL; }
|
||||
|
||||
f->plane[0] = buf;
|
||||
f->plane[1] = NULL;
|
||||
f->plane[2] = NULL;
|
||||
|
||||
if (fmt == TEST_FMT_YUV420) {
|
||||
f->plane[1] = buf + (size_t)width * height;
|
||||
f->plane[2] = f->plane[1] + (size_t)(width / 2) * (height / 2);
|
||||
} else if (fmt == TEST_FMT_YUV422) {
|
||||
f->plane[1] = buf + (size_t)width * height;
|
||||
f->plane[2] = f->plane[1] + (size_t)(width / 2) * height;
|
||||
}
|
||||
|
||||
return f;
|
||||
}
|
||||
|
||||
void test_image_free(Test_Frame *f)
|
||||
{
|
||||
if (!f) { return; }
|
||||
free(f->plane[0]);
|
||||
free(f);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Per-pixel write */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void write_pixel(Test_Frame *f, int row, int col, int r, int g, int b)
|
||||
{
|
||||
if (f->fmt == TEST_FMT_BGRA) {
|
||||
uint8_t *p = f->plane[0] + row * f->stride[0] + col * 4;
|
||||
p[0] = (uint8_t)b;
|
||||
p[1] = (uint8_t)g;
|
||||
p[2] = (uint8_t)r;
|
||||
p[3] = 255;
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t y, cb, cr;
|
||||
rgb_to_ycbcr(r, g, b, &y, &cb, &cr);
|
||||
|
||||
f->plane[0][row * f->stride[0] + col] = y;
|
||||
|
||||
if (f->fmt == TEST_FMT_YUV420) {
|
||||
if ((row & 1) == 0 && (col & 1) == 0) {
|
||||
int ci = (row / 2) * f->stride[1] + col / 2;
|
||||
f->plane[1][ci] = cb;
|
||||
f->plane[2][ci] = cr;
|
||||
}
|
||||
} else { /* YUV422 */
|
||||
if ((col & 1) == 0) {
|
||||
int ci = row * f->stride[1] + col / 2;
|
||||
f->plane[1][ci] = cb;
|
||||
f->plane[2][ci] = cr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Patterns */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#define N_BARS 8
|
||||
|
||||
static const int BAR_RGB[N_BARS][3] = {
|
||||
{191, 191, 191}, /* white 75% */
|
||||
{191, 191, 0}, /* yellow */
|
||||
{ 0, 191, 191}, /* cyan */
|
||||
{ 0, 191, 0}, /* green */
|
||||
{191, 0, 191}, /* magenta */
|
||||
{191, 0, 0}, /* red */
|
||||
{ 0, 0, 191}, /* blue */
|
||||
{ 0, 0, 0}, /* black */
|
||||
};
|
||||
|
||||
static void gen_bars(Test_Frame *f)
|
||||
{
|
||||
for (int row = 0; row < f->height; row++) {
|
||||
for (int col = 0; col < f->width; col++) {
|
||||
int bar = col * N_BARS / f->width;
|
||||
write_pixel(f, row, col,
|
||||
BAR_RGB[bar][0], BAR_RGB[bar][1], BAR_RGB[bar][2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void gen_ramp(Test_Frame *f)
|
||||
{
|
||||
for (int row = 0; row < f->height; row++) {
|
||||
for (int col = 0; col < f->width; col++) {
|
||||
int v = (f->width > 1) ? col * 255 / (f->width - 1) : 128;
|
||||
write_pixel(f, row, col, v, v, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#define GRID_STEP 64
|
||||
|
||||
static void gen_grid(Test_Frame *f)
|
||||
{
|
||||
for (int row = 0; row < f->height; row++) {
|
||||
for (int col = 0; col < f->width; col++) {
|
||||
int on = ((row % GRID_STEP) == 0) || ((col % GRID_STEP) == 0);
|
||||
int v = on ? 255 : 0;
|
||||
write_pixel(f, row, col, v, v, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
void test_image_generate(Test_Frame *f, Test_Pattern pat)
|
||||
{
|
||||
switch (pat) {
|
||||
case TEST_PATTERN_BARS: gen_bars(f); break;
|
||||
case TEST_PATTERN_RAMP: gen_ramp(f); break;
|
||||
case TEST_PATTERN_GRID: gen_grid(f); break;
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/transport
|
||||
|
||||
all: $(MODULE_BUILD)/transport.o
|
||||
|
||||
$(MODULE_BUILD)/transport.o: transport.c $(ROOT)/include/transport.h | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) -c -o $@ $<
|
||||
$(MODULE_BUILD)/transport.o: transport.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/transport.o
|
||||
rm -f $(MODULE_BUILD)/transport.o $(MODULE_BUILD)/transport.d
|
||||
|
||||
-include $(MODULE_BUILD)/transport.d
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ MODULE_BUILD = $(BUILD)/v4l2_ctrl
|
||||
|
||||
all: $(MODULE_BUILD)/v4l2_ctrl.o
|
||||
|
||||
$(MODULE_BUILD)/v4l2_ctrl.o: v4l2_ctrl.c $(ROOT)/include/v4l2_ctrl.h $(ROOT)/include/error.h | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) -c -o $@ $<
|
||||
$(MODULE_BUILD)/v4l2_ctrl.o: v4l2_ctrl.c | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(MODULE_BUILD)/v4l2_ctrl.o
|
||||
rm -f $(MODULE_BUILD)/v4l2_ctrl.o $(MODULE_BUILD)/v4l2_ctrl.d
|
||||
|
||||
-include $(MODULE_BUILD)/v4l2_ctrl.d
|
||||
|
||||
42
src/modules/xorg/Makefile
Normal file
42
src/modules/xorg/Makefile
Normal file
@@ -0,0 +1,42 @@
|
||||
ROOT := $(abspath ../../..)
|
||||
include $(ROOT)/common.mk
|
||||
|
||||
MODULE_BUILD = $(BUILD)/xorg
|
||||
GEN_DIR = $(BUILD)/gen
|
||||
ATLAS_H = $(GEN_DIR)/font_atlas.h
|
||||
ATLAS_PNG = $(GEN_DIR)/font_atlas.png
|
||||
GEN_SCRIPT = $(ROOT)/tools/gen_font_atlas/gen_font_atlas.py
|
||||
|
||||
# Select real implementation when glfw feature is enabled, stub otherwise.
|
||||
ifeq ($(filter glfw,$(FEATURES)),glfw)
|
||||
SRC = xorg.c
|
||||
DEPS = $(ATLAS_H)
|
||||
EXTRA_CFLAGS = -I$(GEN_DIR)
|
||||
else
|
||||
SRC = xorg_stub.c
|
||||
DEPS =
|
||||
EXTRA_CFLAGS =
|
||||
endif
|
||||
|
||||
OBJ = $(MODULE_BUILD)/xorg.o
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(OBJ)
|
||||
|
||||
$(OBJ): $(SRC) $(DEPS) | $(MODULE_BUILD)
|
||||
$(CC) $(CFLAGS) $(EXTRA_CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
$(ATLAS_H) $(ATLAS_PNG): $(GEN_SCRIPT) | $(GEN_DIR)
|
||||
python3 $< --out-header $(ATLAS_H) --out-png $(ATLAS_PNG)
|
||||
|
||||
$(MODULE_BUILD):
|
||||
mkdir -p $@
|
||||
|
||||
$(GEN_DIR):
|
||||
mkdir -p $@
|
||||
|
||||
clean:
|
||||
rm -f $(OBJ) $(MODULE_BUILD)/xorg.d
|
||||
|
||||
-include $(MODULE_BUILD)/xorg.d
|
||||
954
src/modules/xorg/xorg.c
Normal file
954
src/modules/xorg/xorg.c
Normal file
@@ -0,0 +1,954 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <GL/glew.h>
|
||||
#include <GLFW/glfw3.h>
|
||||
|
||||
#ifdef HAVE_TURBOJPEG
|
||||
#include <turbojpeg.h>
|
||||
#endif
|
||||
|
||||
#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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Full-screen quad via gl_VertexID.
|
||||
* u_uv_scale / u_uv_offset let render() apply UV crop for FILL and 1:1 modes.
|
||||
* UV Y is flipped: image row 0 appears at the top of the window.
|
||||
*/
|
||||
static const char *VERT_SRC =
|
||||
"#version 330 core\n"
|
||||
"out vec2 v_uv;\n"
|
||||
"uniform vec2 u_uv_scale;\n"
|
||||
"uniform vec2 u_uv_offset;\n"
|
||||
"void main() {\n"
|
||||
" float x = float(gl_VertexID & 1) * 2.0 - 1.0;\n"
|
||||
" float y = float((gl_VertexID >> 1) & 1) * 2.0 - 1.0;\n"
|
||||
" vec2 uv = vec2((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n"
|
||||
" v_uv = uv * u_uv_scale + u_uv_offset;\n"
|
||||
" gl_Position = vec4(x, y, 0.0, 1.0);\n"
|
||||
"}\n";
|
||||
|
||||
/* BT.601 limited-range YCbCr → RGB. */
|
||||
static const char *FRAG_YUV_SRC =
|
||||
"#version 330 core\n"
|
||||
"in vec2 v_uv;\n"
|
||||
"out vec4 out_color;\n"
|
||||
"uniform sampler2D u_tex_y;\n"
|
||||
"uniform sampler2D u_tex_cb;\n"
|
||||
"uniform sampler2D u_tex_cr;\n"
|
||||
"void main() {\n"
|
||||
" float y = texture(u_tex_y, v_uv).r;\n"
|
||||
" float cb = texture(u_tex_cb, v_uv).r - 0.5;\n"
|
||||
" float cr = texture(u_tex_cr, v_uv).r - 0.5;\n"
|
||||
" float r = clamp(1.164*(y - 0.0627) + 1.596*cr, 0.0, 1.0);\n"
|
||||
" float g = clamp(1.164*(y - 0.0627) - 0.391*cb - 0.813*cr, 0.0, 1.0);\n"
|
||||
" float b = clamp(1.164*(y - 0.0627) + 2.018*cb, 0.0, 1.0);\n"
|
||||
" 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"
|
||||
"in vec2 v_uv;\n"
|
||||
"out vec4 out_color;\n"
|
||||
"uniform sampler2D u_tex_rgb;\n"
|
||||
"void main() {\n"
|
||||
" out_color = vec4(texture(u_tex_rgb, v_uv).rgb, 1.0);\n"
|
||||
"}\n";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shader sources — solid rect */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Draws a screen-space axis-aligned rect using gl_VertexID (no VBO).
|
||||
* u_rect = (x0, y0, x1, y1) in window pixels.
|
||||
*/
|
||||
static const char *VERT_RECT_SRC =
|
||||
"#version 330 core\n"
|
||||
"uniform vec4 u_rect;\n"
|
||||
"uniform vec2 u_fb_size;\n"
|
||||
"void main() {\n"
|
||||
" vec2 corners[4] = vec2[4](\n"
|
||||
" vec2(u_rect.x, u_rect.y),\n"
|
||||
" vec2(u_rect.x, u_rect.w),\n"
|
||||
" vec2(u_rect.z, u_rect.y),\n"
|
||||
" vec2(u_rect.z, u_rect.w)\n"
|
||||
" );\n"
|
||||
" vec2 pos = corners[gl_VertexID];\n"
|
||||
" vec2 ndc = (pos / u_fb_size) * 2.0 - 1.0;\n"
|
||||
" ndc.y = -ndc.y;\n"
|
||||
" gl_Position = vec4(ndc, 0.0, 1.0);\n"
|
||||
"}\n";
|
||||
|
||||
static const char *FRAG_RECT_SRC =
|
||||
"#version 330 core\n"
|
||||
"out vec4 out_color;\n"
|
||||
"uniform vec4 u_color;\n"
|
||||
"void main() {\n"
|
||||
" out_color = u_color;\n"
|
||||
"}\n";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shader sources — text overlay */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/*
|
||||
* Screen-space vertex shader: a_pos in window pixels (top-left = 0,0),
|
||||
* converted to NDC. a_uv is the atlas UV coordinate.
|
||||
*/
|
||||
static const char *VERT_TEXT_SRC =
|
||||
"#version 330 core\n"
|
||||
"layout(location = 0) in vec2 a_pos;\n"
|
||||
"layout(location = 1) in vec2 a_uv;\n"
|
||||
"out vec2 v_uv;\n"
|
||||
"uniform vec2 u_fb_size;\n"
|
||||
"void main() {\n"
|
||||
" vec2 ndc = (a_pos / u_fb_size) * 2.0 - 1.0;\n"
|
||||
" ndc.y = -ndc.y;\n"
|
||||
" gl_Position = vec4(ndc, 0.0, 1.0);\n"
|
||||
" v_uv = a_uv;\n"
|
||||
"}\n";
|
||||
|
||||
/*
|
||||
* Fragment shader: samples the atlas (GL_R8, grayscale) and uses the
|
||||
* texel value as alpha, blended with the per-overlay colour.
|
||||
*/
|
||||
static const char *FRAG_TEXT_SRC =
|
||||
"#version 330 core\n"
|
||||
"in vec2 v_uv;\n"
|
||||
"out vec4 out_color;\n"
|
||||
"uniform sampler2D u_text_atlas;\n"
|
||||
"uniform vec3 u_text_color;\n"
|
||||
"void main() {\n"
|
||||
" float a = texture(u_text_atlas, v_uv).r;\n"
|
||||
" out_color = vec4(u_text_color, a);\n"
|
||||
"}\n";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants and types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#define MAX_OVERLAYS 8
|
||||
#define MAX_OVERLAY_CHARS 256
|
||||
|
||||
/*
|
||||
* Each glyph quad = 6 vertices × 4 floats (x, y, u, v).
|
||||
* Maximum vertex buffer covers all overlays at full text length.
|
||||
*/
|
||||
#define MAX_TEXT_VERTS (MAX_OVERLAYS * MAX_OVERLAY_CHARS * 6 * 4)
|
||||
|
||||
typedef struct {
|
||||
char text[MAX_OVERLAY_CHARS];
|
||||
int x, y; /* top-left of text block in window pixels */
|
||||
float r, g, b;
|
||||
} Overlay;
|
||||
|
||||
typedef enum { MODE_NONE, MODE_YUV, MODE_RGB } Render_Mode;
|
||||
|
||||
struct Xorg_Viewer {
|
||||
GLFWwindow *window;
|
||||
|
||||
/* Video programs */
|
||||
GLuint prog_yuv;
|
||||
GLint u_tex_y, u_tex_cb, u_tex_cr;
|
||||
GLint u_uv_scale_yuv, u_uv_offset_yuv;
|
||||
|
||||
GLuint prog_rgb;
|
||||
GLint u_tex_rgb;
|
||||
GLint u_uv_scale_rgb, u_uv_offset_rgb;
|
||||
|
||||
GLuint vao;
|
||||
GLuint tex[4]; /* 0=Y 1=Cb 2=Cr 3=BGRA/RGB */
|
||||
Render_Mode mode;
|
||||
|
||||
Xorg_Scale scale;
|
||||
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;
|
||||
GLint u_rect_fb_size_loc;
|
||||
GLint u_rect_color_loc;
|
||||
|
||||
/* Text overlay */
|
||||
GLuint prog_text;
|
||||
GLint u_fb_size_loc;
|
||||
GLint u_text_atlas_loc;
|
||||
GLint u_text_color_loc;
|
||||
GLuint tex_atlas;
|
||||
GLuint vao_text;
|
||||
GLuint vbo_text;
|
||||
|
||||
Overlay overlays[MAX_OVERLAYS];
|
||||
int n_overlays;
|
||||
|
||||
#ifdef HAVE_TURBOJPEG
|
||||
tjhandle tj;
|
||||
uint8_t *yuv_buf;
|
||||
int tj_width, tj_height, tj_subsamp;
|
||||
#endif
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shader helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static GLuint compile_shader(GLenum type, const char *src)
|
||||
{
|
||||
GLuint s = glCreateShader(type);
|
||||
glShaderSource(s, 1, &src, NULL);
|
||||
glCompileShader(s);
|
||||
GLint ok;
|
||||
glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
|
||||
if (!ok) {
|
||||
char log[512];
|
||||
glGetShaderInfoLog(s, sizeof(log), NULL, log);
|
||||
fprintf(stderr, "xorg: shader compile error: %s\n", log);
|
||||
glDeleteShader(s);
|
||||
return 0;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static GLuint link_program(const char *vert_src, const char *frag_src)
|
||||
{
|
||||
GLuint vs = compile_shader(GL_VERTEX_SHADER, vert_src);
|
||||
GLuint fs = compile_shader(GL_FRAGMENT_SHADER, frag_src);
|
||||
if (!vs || !fs) {
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
return 0;
|
||||
}
|
||||
GLuint p = glCreateProgram();
|
||||
glAttachShader(p, vs);
|
||||
glAttachShader(p, fs);
|
||||
glLinkProgram(p);
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
GLint ok;
|
||||
glGetProgramiv(p, GL_LINK_STATUS, &ok);
|
||||
if (!ok) {
|
||||
char log[512];
|
||||
glGetProgramInfoLog(p, sizeof(log), NULL, log);
|
||||
fprintf(stderr, "xorg: program link error: %s\n", log);
|
||||
glDeleteProgram(p);
|
||||
return 0;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public API */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void render(Xorg_Viewer *v); /* forward declarations */
|
||||
static void draw_text_overlays(Xorg_Viewer *v, int fb_w, int fb_h);
|
||||
|
||||
bool xorg_available(void) { return true; }
|
||||
|
||||
static void key_callback(GLFWwindow *window, int key, int scancode,
|
||||
int action, int mods)
|
||||
{
|
||||
(void)scancode; (void)mods;
|
||||
if (action == GLFW_PRESS &&
|
||||
(key == GLFW_KEY_ESCAPE || key == GLFW_KEY_Q)) {
|
||||
glfwSetWindowShouldClose(window, GLFW_TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
static void framebuffer_size_callback(GLFWwindow *window, int width, int height)
|
||||
{
|
||||
(void)width; (void)height;
|
||||
Xorg_Viewer *v = glfwGetWindowUserPointer(window);
|
||||
if (v) { render(v); }
|
||||
}
|
||||
|
||||
/* Initialise text rendering resources — called from xorg_viewer_open. */
|
||||
static bool init_text_rendering(Xorg_Viewer *v)
|
||||
{
|
||||
v->prog_rect = link_program(VERT_RECT_SRC, FRAG_RECT_SRC);
|
||||
if (!v->prog_rect) { return false; }
|
||||
v->u_rect_loc = glGetUniformLocation(v->prog_rect, "u_rect");
|
||||
v->u_rect_fb_size_loc = glGetUniformLocation(v->prog_rect, "u_fb_size");
|
||||
v->u_rect_color_loc = glGetUniformLocation(v->prog_rect, "u_color");
|
||||
|
||||
v->prog_text = link_program(VERT_TEXT_SRC, FRAG_TEXT_SRC);
|
||||
if (!v->prog_text) { return false; }
|
||||
|
||||
v->u_fb_size_loc = glGetUniformLocation(v->prog_text, "u_fb_size");
|
||||
v->u_text_atlas_loc = glGetUniformLocation(v->prog_text, "u_text_atlas");
|
||||
v->u_text_color_loc = glGetUniformLocation(v->prog_text, "u_text_color");
|
||||
|
||||
/* Upload atlas texture (grayscale GL_R8). */
|
||||
glGenTextures(1, &v->tex_atlas);
|
||||
glBindTexture(GL_TEXTURE_2D, v->tex_atlas);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_R8,
|
||||
FONT_ATLAS_W, FONT_ATLAS_H, 0,
|
||||
GL_RED, GL_UNSIGNED_BYTE, font_atlas_pixels);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
|
||||
/* VAO + dynamic VBO for glyph quads. */
|
||||
glGenVertexArrays(1, &v->vao_text);
|
||||
glGenBuffers(1, &v->vbo_text);
|
||||
|
||||
glBindVertexArray(v->vao_text);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, v->vbo_text);
|
||||
glBufferData(GL_ARRAY_BUFFER,
|
||||
MAX_TEXT_VERTS * sizeof(float), NULL, GL_DYNAMIC_DRAW);
|
||||
|
||||
/* layout(location=0): vec2 a_pos, layout(location=1): vec2 a_uv */
|
||||
glEnableVertexAttribArray(0);
|
||||
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE,
|
||||
4 * sizeof(float), (void *)0);
|
||||
glEnableVertexAttribArray(1);
|
||||
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE,
|
||||
4 * sizeof(float), (void *)(2 * sizeof(float)));
|
||||
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
glBindVertexArray(0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
|
||||
const char *title)
|
||||
{
|
||||
glfw_acquire();
|
||||
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
|
||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
||||
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
|
||||
|
||||
GLFWwindow *win = glfwCreateWindow(width, height, title, NULL, NULL);
|
||||
if (!win) {
|
||||
fprintf(stderr, "xorg: glfwCreateWindow failed\n");
|
||||
glfw_release();
|
||||
return NULL;
|
||||
}
|
||||
glfwSetWindowPos(win, x, y);
|
||||
glfwSetKeyCallback(win, key_callback);
|
||||
glfwMakeContextCurrent(win);
|
||||
glfwSwapInterval(1);
|
||||
|
||||
glewExperimental = GL_TRUE;
|
||||
if (glewInit() != GLEW_OK) {
|
||||
fprintf(stderr, "xorg: glewInit failed\n");
|
||||
glfwDestroyWindow(win);
|
||||
glfw_release();
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Xorg_Viewer *v = calloc(1, sizeof(*v));
|
||||
if (!v) {
|
||||
glfwDestroyWindow(win);
|
||||
glfw_release();
|
||||
return NULL;
|
||||
}
|
||||
v->window = win;
|
||||
v->scale = XORG_SCALE_STRETCH;
|
||||
v->anchor = XORG_ANCHOR_CENTER;
|
||||
glfwSetWindowUserPointer(win, v);
|
||||
glfwSetFramebufferSizeCallback(win, framebuffer_size_callback);
|
||||
|
||||
v->prog_yuv = link_program(VERT_SRC, FRAG_YUV_SRC);
|
||||
v->prog_rgb = link_program(VERT_SRC, FRAG_RGB_SRC);
|
||||
if (!v->prog_yuv || !v->prog_rgb) {
|
||||
xorg_viewer_close(v);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
v->u_tex_y = glGetUniformLocation(v->prog_yuv, "u_tex_y");
|
||||
v->u_tex_cb = glGetUniformLocation(v->prog_yuv, "u_tex_cb");
|
||||
v->u_tex_cr = glGetUniformLocation(v->prog_yuv, "u_tex_cr");
|
||||
v->u_uv_scale_yuv = glGetUniformLocation(v->prog_yuv, "u_uv_scale");
|
||||
v->u_uv_offset_yuv= glGetUniformLocation(v->prog_yuv, "u_uv_offset");
|
||||
|
||||
v->u_tex_rgb = glGetUniformLocation(v->prog_rgb, "u_tex_rgb");
|
||||
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);
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
glBindTexture(GL_TEXTURE_2D, v->tex[i]);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
}
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
|
||||
if (!init_text_rendering(v)) {
|
||||
xorg_viewer_close(v);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
#ifdef HAVE_TURBOJPEG
|
||||
v->tj = tjInitDecompress();
|
||||
if (!v->tj) {
|
||||
fprintf(stderr, "xorg: tjInitDecompress failed\n");
|
||||
xorg_viewer_close(v);
|
||||
return NULL;
|
||||
}
|
||||
#endif
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
void xorg_viewer_set_scale(Xorg_Viewer *v, Xorg_Scale scale)
|
||||
{
|
||||
if (v) { v->scale = scale; }
|
||||
}
|
||||
|
||||
void xorg_viewer_set_anchor(Xorg_Viewer *v, Xorg_Anchor anchor)
|
||||
{
|
||||
if (v) { v->anchor = anchor; }
|
||||
}
|
||||
|
||||
void xorg_viewer_set_overlay_text(Xorg_Viewer *v, int idx, int x, int y,
|
||||
const char *text, float r, float g, float b)
|
||||
{
|
||||
if (!v || idx < 0 || idx >= MAX_OVERLAYS) { return; }
|
||||
Overlay *o = &v->overlays[idx];
|
||||
strncpy(o->text, text, MAX_OVERLAY_CHARS - 1);
|
||||
o->text[MAX_OVERLAY_CHARS - 1] = '\0';
|
||||
o->x = x;
|
||||
o->y = y;
|
||||
o->r = r;
|
||||
o->g = g;
|
||||
o->b = b;
|
||||
if (idx >= v->n_overlays) { v->n_overlays = idx + 1; }
|
||||
}
|
||||
|
||||
void xorg_viewer_clear_overlays(Xorg_Viewer *v)
|
||||
{
|
||||
if (v) { v->n_overlays = 0; }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Internal: build and draw text quads for all overlays */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void draw_text_overlays(Xorg_Viewer *v, int fb_w, int fb_h)
|
||||
{
|
||||
if (v->n_overlays == 0 || !v->prog_text) { return; }
|
||||
|
||||
/*
|
||||
* Vertex layout: (x, y, u, uv) — 4 floats per vertex, 6 verts per glyph.
|
||||
* Declared static to keep it off the stack (2MB+ otherwise).
|
||||
*/
|
||||
static float verts[MAX_TEXT_VERTS];
|
||||
|
||||
/* Reset to full-window viewport for overlay drawing. */
|
||||
glViewport(0, 0, fb_w, fb_h);
|
||||
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, v->tex_atlas);
|
||||
|
||||
#define OVERLAY_MARGIN 4
|
||||
|
||||
for (int oi = 0; oi < v->n_overlays; oi++) {
|
||||
const Overlay *o = &v->overlays[oi];
|
||||
if (o->text[0] == '\0') { continue; }
|
||||
|
||||
/* Measure text to compute bounding box for the background rect. */
|
||||
int text_w = 0, max_ascent = 0, max_descent = 0;
|
||||
for (const char *p = o->text; *p; p++) {
|
||||
unsigned char cp = (unsigned char)*p;
|
||||
if (cp < 32) { continue; }
|
||||
const Font_Glyph *g = &font_glyphs[cp];
|
||||
text_w += g->advance;
|
||||
if (g->bearing_y > max_ascent) { max_ascent = g->bearing_y; }
|
||||
if (g->h > 0) {
|
||||
int desc = g->h - g->bearing_y;
|
||||
if (desc > max_descent) { max_descent = desc; }
|
||||
}
|
||||
}
|
||||
int text_h = max_ascent + max_descent;
|
||||
int max_bearing_y = max_ascent;
|
||||
|
||||
/* Draw semi-transparent dark background rect. */
|
||||
float rx0 = (float)(o->x - OVERLAY_MARGIN);
|
||||
float ry0 = (float)(o->y - OVERLAY_MARGIN);
|
||||
float rx1 = (float)(o->x + text_w + OVERLAY_MARGIN);
|
||||
float ry1 = (float)(o->y + text_h + OVERLAY_MARGIN);
|
||||
glUseProgram(v->prog_rect);
|
||||
glUniform2f(v->u_rect_fb_size_loc, (float)fb_w, (float)fb_h);
|
||||
glUniform4f(v->u_rect_loc, rx0, ry0, rx1, ry1);
|
||||
glUniform4f(v->u_rect_color_loc, 0.0f, 0.0f, 0.0f, 0.55f);
|
||||
glBindVertexArray(v->vao);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
|
||||
int baseline_y = o->y + max_bearing_y;
|
||||
|
||||
/* Switch back to text program + VBO for glyph quads. */
|
||||
glUseProgram(v->prog_text);
|
||||
glUniform2f(v->u_fb_size_loc, (float)fb_w, (float)fb_h);
|
||||
glUniform1i(v->u_text_atlas_loc, 0);
|
||||
glBindVertexArray(v->vao_text);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, v->vbo_text);
|
||||
|
||||
/* Build quads for this overlay. */
|
||||
int n_verts = 0;
|
||||
int cursor_x = o->x;
|
||||
|
||||
for (const char *p = o->text; *p; p++) {
|
||||
unsigned char cp = (unsigned char)*p;
|
||||
if (cp < 32) { continue; }
|
||||
|
||||
const Font_Glyph *g = &font_glyphs[cp];
|
||||
|
||||
/* Advance even for non-printing glyphs (e.g. space). */
|
||||
if (g->w > 0 && g->h > 0) {
|
||||
/* Screen-space quad corners (top-left origin). */
|
||||
float sx0 = (float)(cursor_x + g->bearing_x);
|
||||
float sy0 = (float)(baseline_y - g->bearing_y);
|
||||
float sx1 = sx0 + (float)g->w;
|
||||
float sy1 = sy0 + (float)g->h;
|
||||
|
||||
/* Atlas UV corners. */
|
||||
float u0 = (float)g->x / FONT_ATLAS_W;
|
||||
float u1 = (float)(g->x + g->w) / FONT_ATLAS_W;
|
||||
float v0 = (float)g->y / FONT_ATLAS_H;
|
||||
float v1 = (float)(g->y + g->h) / FONT_ATLAS_H;
|
||||
|
||||
if (n_verts + 6 * 4 > MAX_TEXT_VERTS) { break; }
|
||||
|
||||
float *d = verts + n_verts;
|
||||
/* tri 0 */
|
||||
d[ 0]=sx0; d[ 1]=sy0; d[ 2]=u0; d[ 3]=v0;
|
||||
d[ 4]=sx0; d[ 5]=sy1; d[ 6]=u0; d[ 7]=v1;
|
||||
d[ 8]=sx1; d[ 9]=sy0; d[10]=u1; d[11]=v0;
|
||||
/* tri 1 */
|
||||
d[12]=sx1; d[13]=sy0; d[14]=u1; d[15]=v0;
|
||||
d[16]=sx0; d[17]=sy1; d[18]=u0; d[19]=v1;
|
||||
d[20]=sx1; d[21]=sy1; d[22]=u1; d[23]=v1;
|
||||
n_verts += 24;
|
||||
}
|
||||
cursor_x += g->advance;
|
||||
}
|
||||
|
||||
if (n_verts == 0) { continue; }
|
||||
|
||||
glBufferSubData(GL_ARRAY_BUFFER, 0, n_verts * sizeof(float), verts);
|
||||
glUniform3f(v->u_text_color_loc, o->r, o->g, o->b);
|
||||
glDrawArrays(GL_TRIANGLES, 0, n_verts / 4);
|
||||
}
|
||||
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
glBindVertexArray(0);
|
||||
glDisable(GL_BLEND);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Internal: compute layout and render from existing textures */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void render(Xorg_Viewer *v)
|
||||
{
|
||||
if (v->mode == MODE_NONE) { return; }
|
||||
|
||||
int fb_w, fb_h;
|
||||
glfwGetFramebufferSize(v->window, &fb_w, &fb_h);
|
||||
|
||||
int vp_x = 0, vp_y = 0, vp_w = fb_w, vp_h = fb_h;
|
||||
float uv_sx = 1.0f, uv_sy = 1.0f;
|
||||
float uv_ox = 0.0f, uv_oy = 0.0f;
|
||||
|
||||
int fw = v->frame_w, fh = v->frame_h;
|
||||
|
||||
if (fw > 0 && fh > 0 && v->scale != XORG_SCALE_STRETCH) {
|
||||
float fa = (float)fw / fh;
|
||||
float wa = (float)fb_w / fb_h;
|
||||
|
||||
switch (v->scale) {
|
||||
|
||||
case XORG_SCALE_FIT:
|
||||
/* Largest rect that fits; black bars fill the rest. */
|
||||
if (fa > wa) {
|
||||
vp_w = fb_w;
|
||||
vp_h = (int)(fb_w / fa);
|
||||
} else {
|
||||
vp_h = fb_h;
|
||||
vp_w = (int)(fb_h * fa);
|
||||
}
|
||||
vp_x = (v->anchor == XORG_ANCHOR_CENTER) ? (fb_w - vp_w) / 2 : 0;
|
||||
vp_y = (v->anchor == XORG_ANCHOR_CENTER) ? (fb_h - vp_h) / 2
|
||||
: fb_h - vp_h;
|
||||
break;
|
||||
|
||||
case XORG_SCALE_FILL:
|
||||
/*
|
||||
* Scale to cover the window; crop the overflowing axis.
|
||||
* UV range is narrowed on the cropped axis.
|
||||
*/
|
||||
if (fa > wa) {
|
||||
/* Frame wider than window: fit height, crop width. */
|
||||
uv_sx = wa / fa;
|
||||
uv_ox = (v->anchor == XORG_ANCHOR_CENTER)
|
||||
? (1.0f - uv_sx) * 0.5f : 0.0f;
|
||||
} else {
|
||||
/* Frame taller than window: fit width, crop height. */
|
||||
uv_sy = fa / wa;
|
||||
uv_oy = (v->anchor == XORG_ANCHOR_CENTER)
|
||||
? (1.0f - uv_sy) * 0.5f : 0.0f;
|
||||
}
|
||||
break;
|
||||
|
||||
case XORG_SCALE_1_1:
|
||||
/*
|
||||
* One frame pixel = one screen pixel.
|
||||
* If frame is larger than the window, the excess is cropped.
|
||||
*/
|
||||
vp_w = fw < fb_w ? fw : fb_w;
|
||||
vp_h = fh < fb_h ? fh : fb_h;
|
||||
|
||||
if (v->anchor == XORG_ANCHOR_CENTER) {
|
||||
vp_x = (fb_w - fw) / 2;
|
||||
vp_y = (fb_h - fh) / 2;
|
||||
/* If frame overflows, crop from centre via UV offset. */
|
||||
if (vp_x < 0) {
|
||||
uv_ox = (float)(-vp_x) / fw;
|
||||
uv_sx = (float)vp_w / fw;
|
||||
vp_x = 0;
|
||||
}
|
||||
if (vp_y < 0) {
|
||||
uv_oy = (float)(-vp_y) / fh;
|
||||
uv_sy = (float)vp_h / fh;
|
||||
vp_y = 0;
|
||||
}
|
||||
} else {
|
||||
/* Top-left anchor: show top-left portion of frame. */
|
||||
vp_x = 0;
|
||||
vp_y = fb_h - vp_h;
|
||||
uv_sx = (float)vp_w / fw;
|
||||
uv_sy = (float)vp_h / fh;
|
||||
}
|
||||
break;
|
||||
|
||||
case XORG_SCALE_STRETCH:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
glViewport(vp_x, vp_y, vp_w, vp_h);
|
||||
|
||||
GLuint prog = (v->mode == MODE_YUV) ? v->prog_yuv : v->prog_rgb;
|
||||
GLint u_uv_scale = (v->mode == MODE_YUV) ? v->u_uv_scale_yuv : v->u_uv_scale_rgb;
|
||||
GLint u_uv_off = (v->mode == MODE_YUV) ? v->u_uv_offset_yuv : v->u_uv_offset_rgb;
|
||||
|
||||
glUseProgram(prog);
|
||||
glUniform2f(u_uv_scale, uv_sx, uv_sy);
|
||||
glUniform2f(u_uv_off, uv_ox, uv_oy);
|
||||
|
||||
if (v->mode == MODE_YUV) {
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, v->tex[0]);
|
||||
glUniform1i(v->u_tex_y, 0);
|
||||
glActiveTexture(GL_TEXTURE1);
|
||||
glBindTexture(GL_TEXTURE_2D, v->tex[1]);
|
||||
glUniform1i(v->u_tex_cb, 1);
|
||||
glActiveTexture(GL_TEXTURE2);
|
||||
glBindTexture(GL_TEXTURE_2D, v->tex[2]);
|
||||
glUniform1i(v->u_tex_cr, 2);
|
||||
} else {
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, v->tex[3]);
|
||||
glUniform1i(v->u_tex_rgb, 0);
|
||||
}
|
||||
|
||||
glBindVertexArray(v->vao);
|
||||
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
||||
glBindVertexArray(0);
|
||||
|
||||
/* Draw text overlays on top, then present. */
|
||||
draw_text_overlays(v, fb_w, fb_h);
|
||||
|
||||
glfwSwapBuffers(v->window);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Internal: upload YUV planes */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void upload_yuv(Xorg_Viewer *v,
|
||||
const uint8_t *y, int y_w, int y_h,
|
||||
const uint8_t *cb, int c_w, int c_h,
|
||||
const uint8_t *cr)
|
||||
{
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
|
||||
glBindTexture(GL_TEXTURE_2D, v->tex[0]);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, y_w, y_h, 0,
|
||||
GL_RED, GL_UNSIGNED_BYTE, y);
|
||||
|
||||
glBindTexture(GL_TEXTURE_2D, v->tex[1]);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, c_w, c_h, 0,
|
||||
GL_RED, GL_UNSIGNED_BYTE, cb);
|
||||
|
||||
glBindTexture(GL_TEXTURE_2D, v->tex[2]);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, c_w, c_h, 0,
|
||||
GL_RED, GL_UNSIGNED_BYTE, cr);
|
||||
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
|
||||
v->mode = MODE_YUV;
|
||||
render(v);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Push functions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
bool xorg_viewer_push_yuv420(Xorg_Viewer *v,
|
||||
const uint8_t *y, const uint8_t *cb, const uint8_t *cr,
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
glBindTexture(GL_TEXTURE_2D, v->tex[3]);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0,
|
||||
GL_BGRA, GL_UNSIGNED_BYTE, data);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
|
||||
v->mode = MODE_RGB;
|
||||
render(v);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool xorg_viewer_push_mjpeg(Xorg_Viewer *v,
|
||||
const uint8_t *data, size_t size)
|
||||
{
|
||||
#ifndef HAVE_TURBOJPEG
|
||||
(void)v; (void)data; (void)size;
|
||||
return false;
|
||||
#else
|
||||
if (!v) { return false; }
|
||||
glfwMakeContextCurrent(v->window);
|
||||
|
||||
int w, h, subsamp, colorspace;
|
||||
if (tjDecompressHeader3(v->tj, data, (unsigned long)size,
|
||||
&w, &h, &subsamp, &colorspace) < 0) {
|
||||
fprintf(stderr, "xorg: tjDecompressHeader3: %s\n", tjGetErrorStr2(v->tj));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (w != v->tj_width || h != v->tj_height || subsamp != v->tj_subsamp) {
|
||||
free(v->yuv_buf);
|
||||
v->yuv_buf = malloc(tjBufSizeYUV2(w, 1, h, subsamp));
|
||||
if (!v->yuv_buf) { return false; }
|
||||
v->tj_width = w;
|
||||
v->tj_height = h;
|
||||
v->tj_subsamp = subsamp;
|
||||
}
|
||||
|
||||
int y_w = tjPlaneWidth(0, w, subsamp);
|
||||
int y_h = tjPlaneHeight(0, h, subsamp);
|
||||
int c_w = tjPlaneWidth(1, w, subsamp);
|
||||
int c_h = tjPlaneHeight(1, h, subsamp);
|
||||
|
||||
int strides[3] = { y_w, c_w, c_w };
|
||||
uint8_t *planes[3];
|
||||
planes[0] = v->yuv_buf;
|
||||
planes[1] = planes[0] + y_w * y_h;
|
||||
planes[2] = planes[1] + c_w * c_h;
|
||||
|
||||
if (tjDecompressToYUVPlanes(v->tj, data, (unsigned long)size,
|
||||
planes, w, strides, h, 0) < 0) {
|
||||
fprintf(stderr, "xorg: tjDecompressToYUVPlanes: %s\n", tjGetErrorStr2(v->tj));
|
||||
return false;
|
||||
}
|
||||
|
||||
v->frame_w = w;
|
||||
v->frame_h = h;
|
||||
upload_yuv(v, planes[0], y_w, y_h, planes[1], c_w, c_h, planes[2]);
|
||||
return true;
|
||||
#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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
bool xorg_viewer_handle_events(Xorg_Viewer *v)
|
||||
{
|
||||
if (!v || glfwWindowShouldClose(v->window)) { return false; }
|
||||
glfwPollEvents();
|
||||
return !glfwWindowShouldClose(v->window);
|
||||
}
|
||||
|
||||
void xorg_viewer_close(Xorg_Viewer *v)
|
||||
{
|
||||
if (!v) { return; }
|
||||
#ifdef HAVE_TURBOJPEG
|
||||
if (v->tj) { tjDestroy(v->tj); }
|
||||
free(v->yuv_buf);
|
||||
#endif
|
||||
if (v->vao_text) { glDeleteVertexArrays(1, &v->vao_text); }
|
||||
if (v->vbo_text) { glDeleteBuffers(1, &v->vbo_text); }
|
||||
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->prog_nosignal) { glDeleteProgram(v->prog_nosignal); }
|
||||
if (v->window) {
|
||||
glfwDestroyWindow(v->window);
|
||||
glfw_release();
|
||||
}
|
||||
free(v);
|
||||
}
|
||||
45
src/modules/xorg/xorg_stub.c
Normal file
45
src/modules/xorg/xorg_stub.c
Normal file
@@ -0,0 +1,45 @@
|
||||
#include <stddef.h>
|
||||
#include "xorg.h"
|
||||
|
||||
bool xorg_available(void) { return false; }
|
||||
|
||||
Xorg_Viewer *xorg_viewer_open(int x, int y, int width, int height,
|
||||
const char *title)
|
||||
{
|
||||
(void)x; (void)y; (void)width; (void)height; (void)title;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
bool xorg_viewer_push_yuv420(Xorg_Viewer *v,
|
||||
const uint8_t *y, const uint8_t *cb, const uint8_t *cr,
|
||||
int width, int height)
|
||||
{
|
||||
(void)v; (void)y; (void)cb; (void)cr; (void)width; (void)height;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool xorg_viewer_push_bgra(Xorg_Viewer *v,
|
||||
const uint8_t *data, int width, int height)
|
||||
{
|
||||
(void)v; (void)data; (void)width; (void)height;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool xorg_viewer_push_mjpeg(Xorg_Viewer *v,
|
||||
const uint8_t *data, size_t size)
|
||||
{
|
||||
(void)v; (void)data; (void)size;
|
||||
return false;
|
||||
}
|
||||
|
||||
void xorg_viewer_set_scale(Xorg_Viewer *v, Xorg_Scale scale) { (void)v; (void)scale; }
|
||||
void xorg_viewer_set_anchor(Xorg_Viewer *v, Xorg_Anchor anchor) { (void)v; (void)anchor; }
|
||||
void xorg_viewer_set_overlay_text(Xorg_Viewer *v, int idx, int x, int y,
|
||||
const char *text, float r, float g, float b)
|
||||
{
|
||||
(void)v; (void)idx; (void)x; (void)y; (void)text; (void)r; (void)g; (void)b;
|
||||
}
|
||||
void xorg_viewer_clear_overlays(Xorg_Viewer *v) { (void)v; }
|
||||
bool xorg_viewer_poll(Xorg_Viewer *v) { (void)v; return false; }
|
||||
bool xorg_viewer_handle_events(Xorg_Viewer *v) { (void)v; return false; }
|
||||
void xorg_viewer_close(Xorg_Viewer *v) { (void)v; }
|
||||
51
src/node/Makefile
Normal file
51
src/node/Makefile
Normal file
@@ -0,0 +1,51 @@
|
||||
ROOT := $(abspath ../..)
|
||||
include $(ROOT)/common.mk
|
||||
|
||||
NODE_BUILD = $(BUILD)/node
|
||||
MAIN_OBJ = $(NODE_BUILD)/main.o
|
||||
COMMON_OBJ = $(BUILD)/common/error.o
|
||||
MEDIA_OBJ = $(BUILD)/media_ctrl/media_ctrl.o
|
||||
V4L2_OBJ = $(BUILD)/v4l2_ctrl/v4l2_ctrl.o
|
||||
SERIAL_OBJ = $(BUILD)/serial/serial.o
|
||||
TRANSPORT_OBJ = $(BUILD)/transport/transport.o
|
||||
DISCOVERY_OBJ = $(BUILD)/discovery/discovery.o
|
||||
CONFIG_OBJ = $(BUILD)/config/config.o
|
||||
PROTOCOL_OBJ = $(BUILD)/protocol/protocol.o
|
||||
RECONCILER_OBJ = $(BUILD)/reconciler/reconciler.o
|
||||
INGEST_OBJ = $(BUILD)/ingest/ingest.o
|
||||
XORG_OBJ = $(BUILD)/xorg/xorg.o
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(NODE_BUILD)/video-node
|
||||
|
||||
$(NODE_BUILD)/video-node: $(MAIN_OBJ) \
|
||||
$(COMMON_OBJ) $(MEDIA_OBJ) $(V4L2_OBJ) $(SERIAL_OBJ) \
|
||||
$(TRANSPORT_OBJ) $(DISCOVERY_OBJ) $(CONFIG_OBJ) $(PROTOCOL_OBJ) \
|
||||
$(RECONCILER_OBJ) $(INGEST_OBJ) $(XORG_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^ -lpthread -lm $(PKG_LDFLAGS)
|
||||
|
||||
$(MAIN_OBJ): main.c | $(NODE_BUILD)
|
||||
$(CC) $(CFLAGS) $(DEPFLAGS) -c -o $@ $<
|
||||
|
||||
# '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 $@
|
||||
|
||||
clean:
|
||||
rm -f $(NODE_BUILD)/video-node $(MAIN_OBJ) $(NODE_BUILD)/main.d
|
||||
|
||||
-include $(NODE_BUILD)/main.d
|
||||
1537
src/node/main.c
Normal file
1537
src/node/main.c
Normal file
File diff suppressed because it is too large
Load Diff
229
tools/gen_font_atlas/gen_font_atlas.py
Normal file
229
tools/gen_font_atlas/gen_font_atlas.py
Normal file
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a bitmap font atlas from a TrueType font for use in the xorg viewer.
|
||||
|
||||
Output:
|
||||
--out-header C header with pixel data (uint8_t array) and glyph metrics
|
||||
--out-png Optional PNG for visual inspection
|
||||
|
||||
The atlas texture is grayscale (1 byte/pixel, GL_R8). Each glyph's alpha
|
||||
channel is stored directly — the renderer blends using this value.
|
||||
|
||||
Usage:
|
||||
gen_font_atlas.py [--font PATH] [--size PT]
|
||||
[--out-header PATH] [--out-png PATH]
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
from PIL import Image, ImageFont, ImageDraw
|
||||
|
||||
GLYPH_FIRST = 32
|
||||
GLYPH_LAST = 255
|
||||
|
||||
FONT_SEARCH_PATHS = [
|
||||
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
||||
'/usr/share/fonts/dejavu/DejaVuSans.ttf',
|
||||
'/usr/share/fonts/TTF/DejaVuSans.ttf',
|
||||
'/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf',
|
||||
'/usr/local/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
||||
]
|
||||
|
||||
def find_font():
|
||||
for p in FONT_SEARCH_PATHS:
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple per-column skyline bin packer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Skyline:
|
||||
"""
|
||||
Skyline bin packer using a per-column height array.
|
||||
For each candidate x position, the placement y is max(sky[x:x+w]).
|
||||
O(atlas_width * n_glyphs) — fast enough for small atlases.
|
||||
"""
|
||||
def __init__(self, width, height):
|
||||
self.w = width
|
||||
self.h = height
|
||||
self.sky = [0] * width
|
||||
|
||||
def place(self, gw, gh):
|
||||
"""Find best-fit position for a glyph of size (gw, gh). Returns (x, y) or None."""
|
||||
best_y = None
|
||||
best_x = None
|
||||
for x in range(self.w - gw + 1):
|
||||
y = max(self.sky[x:x + gw])
|
||||
if y + gh > self.h:
|
||||
continue
|
||||
if best_y is None or y < best_y:
|
||||
best_y = y
|
||||
best_x = x
|
||||
if best_x is None:
|
||||
return None
|
||||
for i in range(best_x, best_x + gw):
|
||||
self.sky[i] = best_y + gh
|
||||
return (best_x, best_y)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('--font', default=None,
|
||||
help='Path to TrueType font file (default: search for DejaVu Sans)')
|
||||
ap.add_argument('--size', type=int, default=16,
|
||||
help='Font size in points (default: 16)')
|
||||
ap.add_argument('--out-header', default='font_atlas.h',
|
||||
help='Output C header path (default: font_atlas.h)')
|
||||
ap.add_argument('--out-png', default=None,
|
||||
help='Optional output PNG for visual inspection')
|
||||
args = ap.parse_args()
|
||||
|
||||
font_path = args.font or find_font()
|
||||
if not font_path:
|
||||
sys.exit('error: DejaVu Sans not found; pass --font PATH')
|
||||
print(f'font: {font_path}', file=sys.stderr)
|
||||
print(f'size: {args.size}pt', file=sys.stderr)
|
||||
|
||||
font = ImageFont.truetype(font_path, args.size)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Collect glyph metrics and bitmaps
|
||||
# ------------------------------------------------------------------
|
||||
glyphs = {}
|
||||
for cp in range(GLYPH_FIRST, GLYPH_LAST + 1):
|
||||
ch = chr(cp)
|
||||
|
||||
# getbbox returns (left, top, right, bottom) in local glyph coords.
|
||||
# top is negative for glyphs that extend above the origin.
|
||||
bbox = font.getbbox(ch)
|
||||
adv = int(font.getlength(ch) + 0.5)
|
||||
|
||||
if bbox is None:
|
||||
glyphs[cp] = {'bitmap': None, 'w': 0, 'h': 0,
|
||||
'advance': adv, 'bearing_x': 0, 'bearing_y': 0}
|
||||
continue
|
||||
|
||||
l, t, r, b = bbox
|
||||
gw = r - l
|
||||
gh = b - t
|
||||
|
||||
if gw <= 0 or gh <= 0:
|
||||
# Non-printing (e.g. space) — store advance only
|
||||
glyphs[cp] = {'bitmap': None, 'w': 0, 'h': 0,
|
||||
'advance': adv, 'bearing_x': int(l), 'bearing_y': int(-t)}
|
||||
continue
|
||||
|
||||
img = Image.new('L', (gw, gh), 0)
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.text((-l, -t), ch, font=font, fill=255)
|
||||
|
||||
glyphs[cp] = {
|
||||
'bitmap': img, 'w': gw, 'h': gh,
|
||||
'advance': adv,
|
||||
'bearing_x': int(l), # horizontal distance from pen to left edge
|
||||
'bearing_y': int(-t), # ascent: pixels above baseline (positive)
|
||||
}
|
||||
|
||||
renderable = [cp for cp, g in glyphs.items() if g['bitmap'] is not None]
|
||||
print(f'glyphs: {len(renderable)} renderable, '
|
||||
f'{len(glyphs) - len(renderable)} non-printing', file=sys.stderr)
|
||||
|
||||
total_area = sum(glyphs[cp]['w'] * glyphs[cp]['h'] for cp in renderable)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pack into atlas — try increasing sizes until everything fits
|
||||
# ------------------------------------------------------------------
|
||||
# Sort by descending height for better skyline packing
|
||||
sorted_cps = sorted(renderable,
|
||||
key=lambda cp: (-glyphs[cp]['h'], -glyphs[cp]['w']))
|
||||
|
||||
PAD = 1 # 1-pixel gap between glyphs to avoid bilinear bleed
|
||||
|
||||
placements = {}
|
||||
atlas_w = atlas_h = 0
|
||||
|
||||
for aw, ah in [(256, 256), (512, 256), (512, 512), (1024, 512)]:
|
||||
packer = Skyline(aw, ah)
|
||||
places = {}
|
||||
failed = False
|
||||
for cp in sorted_cps:
|
||||
g = glyphs[cp]
|
||||
pos = packer.place(g['w'] + PAD, g['h'] + PAD)
|
||||
if pos is None:
|
||||
failed = True
|
||||
break
|
||||
places[cp] = pos
|
||||
if not failed:
|
||||
placements = places
|
||||
atlas_w, atlas_h = aw, ah
|
||||
util = 100 * total_area // (aw * ah)
|
||||
print(f'atlas: {aw}x{ah} ({util}% utilisation)', file=sys.stderr)
|
||||
break
|
||||
else:
|
||||
sys.exit('error: could not fit all glyphs into any supported atlas size')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render atlas image
|
||||
# ------------------------------------------------------------------
|
||||
atlas = Image.new('L', (atlas_w, atlas_h), 0)
|
||||
for cp, (px, py) in placements.items():
|
||||
atlas.paste(glyphs[cp]['bitmap'], (px, py))
|
||||
|
||||
if args.out_png:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.out_png)), exist_ok=True)
|
||||
atlas.save(args.out_png)
|
||||
print(f'png: {args.out_png}', file=sys.stderr)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Write C header
|
||||
# ------------------------------------------------------------------
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.out_header)), exist_ok=True)
|
||||
pixels = list(atlas.tobytes())
|
||||
|
||||
with open(args.out_header, 'w') as f:
|
||||
f.write('/* Auto-generated by gen_font_atlas.py — do not edit. */\n')
|
||||
f.write('#pragma once\n\n')
|
||||
f.write('#include <stdint.h>\n\n')
|
||||
f.write(f'#define FONT_ATLAS_W {atlas_w}\n')
|
||||
f.write(f'#define FONT_ATLAS_H {atlas_h}\n\n')
|
||||
f.write('typedef struct {\n')
|
||||
f.write('\tint x, y; /* top-left in atlas */\n')
|
||||
f.write('\tint w, h; /* bitmap size in pixels */\n')
|
||||
f.write('\tint advance; /* horizontal pen advance */\n')
|
||||
f.write('\tint bearing_x; /* left-side bearing */\n')
|
||||
f.write('\tint bearing_y; /* ascent above baseline */\n')
|
||||
f.write('} Font_Glyph;\n\n')
|
||||
|
||||
# Glyph table — indexed directly by codepoint 0..255
|
||||
f.write('static const Font_Glyph font_glyphs[256] = {\n')
|
||||
for cp in range(256):
|
||||
if cp in glyphs:
|
||||
g = glyphs[cp]
|
||||
px, py = placements.get(cp, (0, 0))
|
||||
f.write(f'\t[{cp}] = {{ {px}, {py}, {g["w"]}, {g["h"]}, '
|
||||
f'{g["advance"]}, {g["bearing_x"]}, {g["bearing_y"]} }},\n')
|
||||
else:
|
||||
f.write(f'\t[{cp}] = {{ 0, 0, 0, 0, 0, 0, 0 }},\n')
|
||||
f.write('};\n\n')
|
||||
|
||||
# Pixel data — grayscale, row-major
|
||||
f.write(f'static const uint8_t font_atlas_pixels[{atlas_w * atlas_h}] = {{\n')
|
||||
for i in range(0, len(pixels), 16):
|
||||
chunk = pixels[i:i + 16]
|
||||
f.write('\t' + ', '.join(str(b) for b in chunk) + ',\n')
|
||||
f.write('};\n')
|
||||
|
||||
print(f'header: {args.out_header}', file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user