forgectrl: implement the camera MJPEG service

Persistent imx-media capture engine (lid/head via the shared video-mux, 409 arbitration, 10 s idle teardown), 1296x972 MJPEG stream via 2x2 Bayer superpixel, full-resolution bilinear snapshots, mjpg-streamer-compatible aliases, sysvinit script; installed in both images. Bench-verified: 3.2 fps stream, clamped 0 during simultaneous motion.
This commit is contained in:
ScottW514
2026-08-03 12:26:23 -04:00
parent f396dbde34
commit b3a8924c91
11 changed files with 1435 additions and 26 deletions
@@ -6,14 +6,26 @@ LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda
SRC_URI = "\
file://CMakeLists.txt \
file://hello.c \
file://main.c \
file://cam.c \
file://cam.h \
file://debayer.c \
file://debayer.h \
file://forgectrl.init \
"
S = "${WORKDIR}"
inherit cmake
inherit cmake update-rc.d
DEPENDS += "libconfig ulfius"
RDEPENDS:${PN} = "libconfig ulfius"
DEPENDS += "ulfius jpeg"
# media-ctl / v4l2-ctl configure the imx-media pipeline at runtime
RDEPENDS:${PN} = "v4l-utils"
EXTRA_OECMAKE = ""
INITSCRIPT_NAME = "forgectrl"
INITSCRIPT_PARAMS = "defaults 90"
do_install:append() {
install -d ${D}${sysconfdir}/init.d
install -m 0755 ${WORKDIR}/forgectrl.init ${D}${sysconfdir}/init.d/forgectrl
}
@@ -2,7 +2,9 @@ cmake_minimum_required(VERSION 3.10)
project(forgectrl C)
set(CMAKE_C_STANDARD 11)
file(GLOB sources "${PROJECT_SOURCE_DIR}/*.c")
add_executable(forgectrl ${sources})
add_executable(forgectrl main.c cam.c debayer.c)
target_compile_options(forgectrl PRIVATE -Wall -Wextra -O2)
target_link_libraries(forgectrl ulfius jpeg pthread m)
install(TARGETS forgectrl RUNTIME DESTINATION bin)
@@ -0,0 +1,847 @@
/*
* cam.c - persistent Glowforge camera capture engine
* Copyright (c) 2026 Scott Wiederhold <s.e.wiederhold@gmail.com>
* SPDX-License-Identifier: MIT
*
* Pipeline model (mainline imx-media): both OV5648 sensors feed one
* video-mux -> MIPI CSI-2 -> IPU CSI path to the 'ipu1_csi0 capture' video
* node. Camera selection is which mux sink link is enabled; sensor controls
* live on the sensor subdev; illumination is the per-camera LED driven via
* sysfs. Links and pad formats are configured with media-ctl / v4l2-ctl
* (the same sequences python3-gfhardware uses for one-shot grabs), then the
* capture node is held open and streamed continuously.
*
* Threading: a control mutex serializes engine start/stop/switch; the
* engine lock covers frame data and counters. The worker thread only ever
* takes the engine lock, so control paths may join it while holding the
* control mutex.
*/
#define _GNU_SOURCE
#include "cam.h"
#include "debayer.h"
#include <errno.h>
#include <fcntl.h>
#include <linux/videodev2.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <jpeglib.h> /* requires stdio.h first (FILE) */
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/select.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <unistd.h>
#define CAM_W 2592
#define CAM_H 1944
#define HALF_W (CAM_W / 2)
#define HALF_H (CAM_H / 2)
#define HFLIP 1 /* factory image orientation (see debayer.h) */
#define N_BUFS 4
#define DQ_TIMEOUT_S 2 /* select() timeout per frame; also the idle tick */
#define MAX_DQ_TIMEOUTS 3 /* consecutive timeouts -> engine gives up */
#define IDLE_STOP_S 10 /* no clients/snapshots for this long -> teardown */
#define SNAP_TIMEOUT_S 15
#define CLIENT_WAIT_S 5
#define CAPTURE_ENTITY "ipu1_csi0 capture"
#define MBUS_FMT "SBGGR8_1X8/2592x1944 field:none"
struct camdef {
const char *name;
int bus; /* I2C bus: sensor entity resolved by <bus>-0036 */
int muxpad; /* video-mux sink pad */
const char *lamp; /* sysfs illumination attribute */
int exposure; /* 1/16-line units, frame-length ceiling ~31600 */
int gain;
};
static const struct camdef camdefs[2] = {
[CAM_LID] = { "lid", 0, 0, "/sys/glowforge/pic/lid_led", 24000, 50 },
[CAM_HEAD] = { "head", 3, 1, "/sys/glowforge/head/white_led", 24000, 200 },
};
struct buffer {
void *start;
size_t length;
};
static struct {
/* control path (start/stop/switch) - taken before lock, never by the
* worker thread */
pthread_mutex_t ctl;
/* engine state */
pthread_mutex_t lock;
pthread_cond_t frame_cv; /* new stream frame published */
pthread_cond_t snap_cv; /* snapshot request completed */
pthread_t tid;
int tid_valid;
int running; /* worker alive and capturing */
int stop_flag;
cam_id_t cam;
int clients;
struct timespec last_activity;
/* published stream frame (half-res JPEG) */
uint8_t *stream_jpg;
size_t stream_len;
size_t stream_cap;
uint64_t seq;
double fps;
/* one pending snapshot request at a time (control mutex serializes) */
int snap_pending; /* 1 = requested, 2 = done, 3 = failed */
int snap_full;
int snap_quality;
uint8_t *snap_jpg; /* malloc'd result, taken by requester */
size_t snap_len;
/* capture resources (worker/start/teardown only) */
int fd;
struct buffer bufs[N_BUFS];
int n_bufs;
int streaming;
int lamp_prev; /* -1 = unknown, restore to 0 */
/* config */
int stream_quality;
int lamp_level;
} eng = {
.ctl = PTHREAD_MUTEX_INITIALIZER,
.lock = PTHREAD_MUTEX_INITIALIZER,
.frame_cv = PTHREAD_COND_INITIALIZER,
.snap_cv = PTHREAD_COND_INITIALIZER,
.fd = -1,
.lamp_prev = -1,
.stream_quality = 75,
.lamp_level = 132,
};
const char *cam_name(cam_id_t cam)
{
return camdefs[cam].name;
}
/* ------------------------------------------------------------------ util */
static void now_ts(struct timespec *ts)
{
clock_gettime(CLOCK_MONOTONIC, ts);
}
static double ts_diff(const struct timespec *a, const struct timespec *b)
{
return (double)(a->tv_sec - b->tv_sec) +
(double)(a->tv_nsec - b->tv_nsec) / 1e9;
}
static int xioctl(int fd, unsigned long req, void *arg)
{
int r;
do {
r = ioctl(fd, req, arg);
} while (r == -1 && errno == EINTR);
return r;
}
/* Run a shell command, logging and returning nonzero on failure. */
static int run(const char *fmt, ...)
{
char cmd[512];
va_list ap;
va_start(ap, fmt);
vsnprintf(cmd, sizeof(cmd), fmt, ap);
va_end(ap);
int rc = system(cmd);
if (rc == -1 || !WIFEXITED(rc) || WEXITSTATUS(rc) != 0) {
fprintf(stderr, "cam: command failed (%d): %s\n", rc, cmd);
return -1;
}
return 0;
}
/* Run a command and capture its first line of output. */
static int run_read(char *out, size_t outlen, const char *fmt, ...)
{
char cmd[512];
va_list ap;
va_start(ap, fmt);
vsnprintf(cmd, sizeof(cmd), fmt, ap);
va_end(ap);
FILE *p = popen(cmd, "r");
if (!p)
return -1;
out[0] = '\0';
if (!fgets(out, (int)outlen, p)) {
pclose(p);
return -1;
}
int rc = pclose(p);
out[strcspn(out, "\r\n")] = '\0';
if (rc == -1 || !WIFEXITED(rc) || WEXITSTATUS(rc) != 0 || !out[0]) {
fprintf(stderr, "cam: command failed: %s\n", cmd);
return -1;
}
return 0;
}
static int sysfs_read_int(const char *path, int *val)
{
FILE *f = fopen(path, "r");
if (!f)
return -1;
int ok = fscanf(f, "%d", val) == 1;
fclose(f);
return ok ? 0 : -1;
}
static int sysfs_write_int(const char *path, int val)
{
FILE *f = fopen(path, "w");
if (!f)
return -1;
int ok = fprintf(f, "%d", val) > 0;
fclose(f);
return ok ? 0 : -1;
}
/* --------------------------------------------------- pipeline configure */
/* Resolve the sensor media entity on an I2C bus (e.g. "ov5648 0-0036") by
* its address suffix, so OV5648 and OV8856 (HD model) both match. */
static int sensor_entity(int bus, char *out, size_t outlen)
{
char needle[16];
snprintf(needle, sizeof(needle), " %d-0036", bus);
FILE *p = popen("media-ctl -p", "r");
if (!p)
return -1;
char line[512];
int found = 0;
while (fgets(line, sizeof(line), p)) {
char *e = strstr(line, "entity ");
if (!e)
continue;
char *colon = strchr(e, ':');
char *hit = strstr(line, needle);
if (!colon || !hit || hit < colon)
continue;
char *start = colon + 2;
char *end = hit + strlen(needle);
if (end <= start || (size_t)(end - start) >= outlen)
continue;
memcpy(out, start, (size_t)(end - start));
out[end - start] = '\0';
found = 1;
break;
}
pclose(p);
return found ? 0 : -1;
}
/* Route the selected sensor through the video-mux to the capture node and
* set the raw-Bayer format on every pad of the active path. Exactly one
* mux sink link may be enabled, so the other camera's link (if that sensor
* exists) is disabled first. */
static int configure_pipeline(cam_id_t cam, const char *sensor,
const char *other_sensor)
{
const struct camdef *c = &camdefs[cam];
const struct camdef *o = &camdefs[cam == CAM_LID ? CAM_HEAD : CAM_LID];
if (other_sensor[0] &&
run("media-ctl -l '\"%s\":0 -> \"video-mux\":%d [0]'",
other_sensor, o->muxpad))
return -1;
if (run("media-ctl -l '\"%s\":0 -> \"video-mux\":%d [1]'",
sensor, c->muxpad) ||
run("media-ctl -l '\"video-mux\":2 -> \"imx6-mipi-csi2\":0 [1]'") ||
run("media-ctl -l '\"imx6-mipi-csi2\":1 -> \"ipu1_csi0_mux\":0 [1]'") ||
run("media-ctl -l '\"ipu1_csi0_mux\":5 -> \"ipu1_csi0\":0 [1]'") ||
run("media-ctl -l '\"ipu1_csi0\":2 -> \"" CAPTURE_ENTITY "\":0 [1]'"))
return -1;
if (run("media-ctl -V '\"%s\":0 [fmt:" MBUS_FMT "]'", sensor) ||
run("media-ctl -V '\"video-mux\":%d [fmt:" MBUS_FMT "]'", c->muxpad) ||
run("media-ctl -V '\"video-mux\":2 [fmt:" MBUS_FMT "]'") ||
run("media-ctl -V '\"imx6-mipi-csi2\":0 [fmt:" MBUS_FMT "]'") ||
run("media-ctl -V '\"imx6-mipi-csi2\":1 [fmt:" MBUS_FMT "]'") ||
run("media-ctl -V '\"ipu1_csi0_mux\":0 [fmt:" MBUS_FMT "]'") ||
run("media-ctl -V '\"ipu1_csi0_mux\":5 [fmt:" MBUS_FMT "]'") ||
run("media-ctl -V '\"ipu1_csi0\":0 [fmt:" MBUS_FMT "]'") ||
run("media-ctl -V '\"ipu1_csi0\":2 [fmt:" MBUS_FMT "]'"))
return -1;
return 0;
}
/* Manual exposure/gain/white-balance on the sensor subdev (factory values).
* The auto-clusters must go manual before the manual values take effect.
* The sensor flips stay off: HFLIP breaks imx-media CSI capture, so the
* factory mirror is applied in software (debayer). */
static int configure_sensor(const char *sensor, const struct camdef *c)
{
char subdev[64];
if (run_read(subdev, sizeof(subdev), "media-ctl -e '%s'", sensor))
return -1;
if (run("v4l2-ctl -d %s -c auto_exposure=1 -c gain_automatic=0"
" -c white_balance_automatic=0", subdev))
return -1;
if (run("v4l2-ctl -d %s -c exposure=%d -c gain=%d -c red_balance=1100"
" -c blue_balance=1400 -c horizontal_flip=0 -c vertical_flip=0",
subdev, c->exposure, c->gain))
return -1;
return 0;
}
/* ------------------------------------------------------- jpeg encoding */
static int jpeg_encode_rgb(const uint8_t *rgb, int w, int h, int quality,
int fast, uint8_t **out, size_t *outlen)
{
struct jpeg_compress_struct ci;
struct jpeg_error_mgr jerr;
unsigned char *buf = NULL;
unsigned long buflen = 0;
ci.err = jpeg_std_error(&jerr);
jpeg_create_compress(&ci);
jpeg_mem_dest(&ci, &buf, &buflen);
ci.image_width = (JDIMENSION)w;
ci.image_height = (JDIMENSION)h;
ci.input_components = 3;
ci.in_color_space = JCS_RGB;
jpeg_set_defaults(&ci);
jpeg_set_quality(&ci, quality, TRUE);
if (fast)
ci.dct_method = JDCT_FASTEST;
jpeg_start_compress(&ci, TRUE);
while (ci.next_scanline < ci.image_height) {
JSAMPROW row = (JSAMPROW)(rgb + (long)ci.next_scanline * w * 3);
jpeg_write_scanlines(&ci, &row, 1);
}
jpeg_finish_compress(&ci);
jpeg_destroy_compress(&ci);
*out = buf;
*outlen = (size_t)buflen;
return 0;
}
/* --------------------------------------------------- capture start/stop */
/* Release every capture resource and restore the lamp. Safe to call from
* any state; called by the worker on exit and by a failed start. */
static void release_capture(void)
{
if (eng.streaming) {
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
xioctl(eng.fd, VIDIOC_STREAMOFF, &type);
eng.streaming = 0;
}
for (int i = 0; i < eng.n_bufs; i++) {
if (eng.bufs[i].start) {
munmap(eng.bufs[i].start, eng.bufs[i].length);
eng.bufs[i].start = NULL;
}
}
eng.n_bufs = 0;
if (eng.fd >= 0) {
close(eng.fd);
eng.fd = -1;
}
if (eng.lamp_prev >= 0) {
sysfs_write_int(camdefs[eng.cam].lamp, eng.lamp_prev);
eng.lamp_prev = -1;
}
}
/* Configure the media graph and sensor, light the lamp, and bring up the
* V4L2 capture node streaming. Called with ctl held, engine not running. */
static int start_capture(cam_id_t cam, char *err, size_t errlen)
{
const struct camdef *c = &camdefs[cam];
char sensor[64], other[64] = "";
if (sensor_entity(c->bus, sensor, sizeof(sensor))) {
snprintf(err, errlen, "no camera sensor on i2c-%d", c->bus);
return -1;
}
(void)sensor_entity(camdefs[cam == CAM_LID ? CAM_HEAD : CAM_LID].bus,
other, sizeof(other));
if (configure_pipeline(cam, sensor, other)) {
snprintf(err, errlen, "media pipeline configuration failed");
return -1;
}
if (configure_sensor(sensor, c)) {
snprintf(err, errlen, "sensor configuration failed");
return -1;
}
char dev[64];
if (run_read(dev, sizeof(dev), "media-ctl -e '" CAPTURE_ENTITY "'")) {
snprintf(err, errlen, "cannot resolve capture video node");
return -1;
}
eng.cam = cam;
/* Scene lighting for the duration; the previous level is restored at
* teardown (raw register write - instant, no fade). */
if (sysfs_read_int(c->lamp, &eng.lamp_prev))
eng.lamp_prev = 0;
sysfs_write_int(c->lamp, eng.lamp_level);
eng.fd = open(dev, O_RDWR | O_NONBLOCK, 0);
if (eng.fd < 0) {
snprintf(err, errlen, "open %s: %s", dev, strerror(errno));
release_capture();
return -1;
}
struct v4l2_format fmt = {0};
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
fmt.fmt.pix.width = CAM_W;
fmt.fmt.pix.height = CAM_H;
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_SBGGR8;
fmt.fmt.pix.field = V4L2_FIELD_NONE;
if (xioctl(eng.fd, VIDIOC_S_FMT, &fmt) < 0) {
snprintf(err, errlen, "S_FMT: %s", strerror(errno));
release_capture();
return -1;
}
struct v4l2_requestbuffers req = {0};
req.count = N_BUFS;
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_MMAP;
if (xioctl(eng.fd, VIDIOC_REQBUFS, &req) < 0 || req.count < 2) {
snprintf(err, errlen, "REQBUFS: %s (device busy?)", strerror(errno));
release_capture();
return -1;
}
for (eng.n_bufs = 0; eng.n_bufs < (int)req.count; eng.n_bufs++) {
struct v4l2_buffer buf = {0};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
buf.index = (unsigned)eng.n_bufs;
if (xioctl(eng.fd, VIDIOC_QUERYBUF, &buf) < 0) {
snprintf(err, errlen, "QUERYBUF: %s", strerror(errno));
release_capture();
return -1;
}
eng.bufs[eng.n_bufs].length = buf.length;
eng.bufs[eng.n_bufs].start = mmap(NULL, buf.length,
PROT_READ | PROT_WRITE, MAP_SHARED,
eng.fd, buf.m.offset);
if (eng.bufs[eng.n_bufs].start == MAP_FAILED) {
eng.bufs[eng.n_bufs].start = NULL;
snprintf(err, errlen, "mmap: %s", strerror(errno));
release_capture();
return -1;
}
}
for (int i = 0; i < eng.n_bufs; i++) {
struct v4l2_buffer buf = {0};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
buf.index = (unsigned)i;
if (xioctl(eng.fd, VIDIOC_QBUF, &buf) < 0) {
snprintf(err, errlen, "QBUF: %s", strerror(errno));
release_capture();
return -1;
}
}
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (xioctl(eng.fd, VIDIOC_STREAMON, &type) < 0) {
snprintf(err, errlen, "STREAMON: %s", strerror(errno));
release_capture();
return -1;
}
eng.streaming = 1;
return 0;
}
/* ---------------------------------------------------------- worker loop */
static void *worker(void *arg)
{
(void)arg;
uint8_t *rgb_half = malloc((size_t)HALF_W * HALF_H * 3);
uint8_t *rgb_full = NULL; /* allocated on first full-res snapshot */
int dq_timeouts = 0;
struct timespec fps_t0;
now_ts(&fps_t0);
uint64_t fps_frames = 0;
if (!rgb_half) {
fprintf(stderr, "cam: worker OOM\n");
goto out;
}
for (;;) {
struct timespec now;
now_ts(&now);
pthread_mutex_lock(&eng.lock);
int stop = eng.stop_flag;
int clients = eng.clients;
int snap = eng.snap_pending == 1;
int idle = clients == 0 && !snap &&
ts_diff(&now, &eng.last_activity) > IDLE_STOP_S;
pthread_mutex_unlock(&eng.lock);
if (stop || idle)
break;
/* Wait for a frame */
fd_set fds;
FD_ZERO(&fds);
FD_SET(eng.fd, &fds);
struct timeval tv = { .tv_sec = DQ_TIMEOUT_S };
int r = select(eng.fd + 1, &fds, NULL, NULL, &tv);
if (r == -1 && errno == EINTR)
continue;
if (r <= 0) {
if (r == 0 && ++dq_timeouts >= MAX_DQ_TIMEOUTS) {
fprintf(stderr, "cam: %d consecutive frame timeouts, "
"stopping engine\n", dq_timeouts);
break;
}
if (r == -1) {
fprintf(stderr, "cam: select: %s\n", strerror(errno));
break;
}
continue;
}
struct v4l2_buffer buf = {0};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
if (xioctl(eng.fd, VIDIOC_DQBUF, &buf) < 0) {
if (errno == EAGAIN || errno == EIO)
continue;
fprintf(stderr, "cam: DQBUF: %s\n", strerror(errno));
break;
}
dq_timeouts = 0;
const uint8_t *raw = eng.bufs[buf.index].start;
/* Snapshot request rides on the same raw frame */
if (snap) {
uint8_t *jpg = NULL;
size_t len = 0;
int ok;
pthread_mutex_lock(&eng.lock);
int full = eng.snap_full;
int q = eng.snap_quality;
pthread_mutex_unlock(&eng.lock);
if (full) {
if (!rgb_full)
rgb_full = malloc((size_t)CAM_W * CAM_H * 3);
ok = rgb_full != NULL;
if (ok) {
debayer_bggr_bilinear(raw, rgb_full, CAM_W, CAM_H, HFLIP);
ok = jpeg_encode_rgb(rgb_full, CAM_W, CAM_H, q, 0,
&jpg, &len) == 0;
}
} else {
debayer_bggr_half(raw, rgb_half, CAM_W, CAM_H, HFLIP);
ok = jpeg_encode_rgb(rgb_half, HALF_W, HALF_H, q, 0,
&jpg, &len) == 0;
}
pthread_mutex_lock(&eng.lock);
free(eng.snap_jpg);
eng.snap_jpg = ok ? jpg : NULL;
eng.snap_len = ok ? len : 0;
eng.snap_pending = ok ? 2 : 3;
now_ts(&eng.last_activity);
pthread_cond_broadcast(&eng.snap_cv);
pthread_mutex_unlock(&eng.lock);
}
/* Stream frame */
if (clients > 0) {
uint8_t *jpg = NULL;
size_t len = 0;
debayer_bggr_half(raw, rgb_half, CAM_W, CAM_H, HFLIP);
if (jpeg_encode_rgb(rgb_half, HALF_W, HALF_H, eng.stream_quality,
1, &jpg, &len) == 0) {
pthread_mutex_lock(&eng.lock);
free(eng.stream_jpg);
eng.stream_jpg = jpg;
eng.stream_len = len;
eng.seq++;
fps_frames++;
struct timespec t;
now_ts(&t);
double dt = ts_diff(&t, &fps_t0);
if (dt >= 2.0) {
eng.fps = (double)fps_frames / dt;
fps_frames = 0;
fps_t0 = t;
}
pthread_cond_broadcast(&eng.frame_cv);
pthread_mutex_unlock(&eng.lock);
}
}
if (xioctl(eng.fd, VIDIOC_QBUF, &buf) < 0) {
fprintf(stderr, "cam: QBUF: %s\n", strerror(errno));
break;
}
}
out:
release_capture();
free(rgb_half);
free(rgb_full);
pthread_mutex_lock(&eng.lock);
eng.running = 0;
/* fail any waiter: stream clients see running==0, a pending snapshot
* is marked failed */
if (eng.snap_pending == 1)
eng.snap_pending = 3;
pthread_cond_broadcast(&eng.frame_cv);
pthread_cond_broadcast(&eng.snap_cv);
pthread_mutex_unlock(&eng.lock);
return NULL;
}
/* ------------------------------------------------------- engine control */
/* With ctl held: make the engine run on `cam`. Fails if clients hold the
* other camera. */
static int ensure_engine(cam_id_t cam, char *err, size_t errlen)
{
for (;;) {
pthread_mutex_lock(&eng.lock);
int running = eng.running;
cam_id_t cur = eng.cam;
int clients = eng.clients;
int tid_valid = eng.tid_valid;
pthread_mutex_unlock(&eng.lock);
if (running && cur == cam)
return 0;
if (running && cur != cam) {
if (clients > 0) {
snprintf(err, errlen,
"camera busy: %d client(s) streaming %s",
clients, camdefs[cur].name);
return -1;
}
pthread_mutex_lock(&eng.lock);
eng.stop_flag = 1;
pthread_mutex_unlock(&eng.lock);
/* worker notices at the next tick (<= DQ_TIMEOUT_S) */
}
if (tid_valid) {
pthread_join(eng.tid, NULL);
pthread_mutex_lock(&eng.lock);
eng.tid_valid = 0;
eng.stop_flag = 0;
pthread_mutex_unlock(&eng.lock);
continue; /* re-evaluate from a clean state */
}
/* cold start */
if (start_capture(cam, err, errlen))
return -1;
pthread_mutex_lock(&eng.lock);
eng.running = 1;
eng.stop_flag = 0;
eng.seq = 0;
eng.fps = 0;
now_ts(&eng.last_activity);
if (pthread_create(&eng.tid, NULL, worker, NULL)) {
eng.running = 0;
pthread_mutex_unlock(&eng.lock);
release_capture();
snprintf(err, errlen, "worker thread creation failed");
return -1;
}
eng.tid_valid = 1;
pthread_mutex_unlock(&eng.lock);
return 0;
}
}
void cam_engine_init(void)
{
const char *v;
if ((v = getenv("FORGECTRL_STREAM_Q")) != NULL) {
int q = atoi(v);
if (q >= 1 && q <= 100)
eng.stream_quality = q;
}
if ((v = getenv("FORGECTRL_LAMP")) != NULL) {
int l = atoi(v);
if (l >= 0 && l <= 1023)
eng.lamp_level = l;
}
}
void cam_engine_shutdown(void)
{
pthread_mutex_lock(&eng.ctl);
pthread_mutex_lock(&eng.lock);
int tid_valid = eng.tid_valid;
eng.stop_flag = 1;
pthread_mutex_unlock(&eng.lock);
if (tid_valid) {
pthread_join(eng.tid, NULL);
pthread_mutex_lock(&eng.lock);
eng.tid_valid = 0;
pthread_mutex_unlock(&eng.lock);
}
pthread_mutex_unlock(&eng.ctl);
}
/* ------------------------------------------------------------ snapshots */
int cam_snapshot(cam_id_t cam, int full, int quality,
uint8_t **jpeg, size_t *len, char *err, size_t errlen)
{
pthread_mutex_lock(&eng.ctl);
if (ensure_engine(cam, err, errlen)) {
pthread_mutex_unlock(&eng.ctl);
return -1;
}
pthread_mutex_lock(&eng.lock);
eng.snap_pending = 1;
eng.snap_full = full;
eng.snap_quality = quality;
now_ts(&eng.last_activity);
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += SNAP_TIMEOUT_S;
int rc = 0;
while (eng.snap_pending == 1) {
if (pthread_cond_timedwait(&eng.snap_cv, &eng.lock, &deadline)
== ETIMEDOUT) {
rc = ETIMEDOUT;
break;
}
}
if (rc == 0 && eng.snap_pending == 2) {
*jpeg = eng.snap_jpg;
*len = eng.snap_len;
eng.snap_jpg = NULL;
eng.snap_len = 0;
eng.snap_pending = 0;
} else {
if (eng.snap_pending == 1 || eng.snap_pending == 3)
snprintf(err, errlen, rc == ETIMEDOUT ?
"snapshot timed out" : "snapshot capture failed");
eng.snap_pending = 0;
rc = -1;
}
now_ts(&eng.last_activity);
pthread_mutex_unlock(&eng.lock);
pthread_mutex_unlock(&eng.ctl);
return rc == 0 ? 0 : -1;
}
/* --------------------------------------------------------- stream client */
struct cam_client {
uint64_t last_seq;
uint8_t *buf;
size_t cap;
};
cam_client_t *cam_client_open(cam_id_t cam, char *err, size_t errlen)
{
pthread_mutex_lock(&eng.ctl);
if (ensure_engine(cam, err, errlen)) {
pthread_mutex_unlock(&eng.ctl);
return NULL;
}
cam_client_t *c = calloc(1, sizeof(*c));
if (!c) {
pthread_mutex_unlock(&eng.ctl);
snprintf(err, errlen, "out of memory");
return NULL;
}
pthread_mutex_lock(&eng.lock);
eng.clients++;
now_ts(&eng.last_activity);
pthread_mutex_unlock(&eng.lock);
pthread_mutex_unlock(&eng.ctl);
return c;
}
long cam_client_next(cam_client_t *c, const uint8_t **jpeg)
{
pthread_mutex_lock(&eng.lock);
int timeouts = 0;
while (eng.running && eng.seq <= c->last_seq) {
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += CLIENT_WAIT_S;
if (pthread_cond_timedwait(&eng.frame_cv, &eng.lock, &deadline)
== ETIMEDOUT && ++timeouts >= 2)
break;
}
if (!eng.running || eng.seq <= c->last_seq) {
pthread_mutex_unlock(&eng.lock);
return -1;
}
if (c->cap < eng.stream_len) {
uint8_t *nb = realloc(c->buf, eng.stream_len);
if (!nb) {
pthread_mutex_unlock(&eng.lock);
return -1;
}
c->buf = nb;
c->cap = eng.stream_len;
}
memcpy(c->buf, eng.stream_jpg, eng.stream_len);
long len = (long)eng.stream_len;
c->last_seq = eng.seq;
now_ts(&eng.last_activity);
pthread_mutex_unlock(&eng.lock);
*jpeg = c->buf;
return len;
}
void cam_client_close(cam_client_t *c)
{
if (!c)
return;
pthread_mutex_lock(&eng.lock);
if (eng.clients > 0)
eng.clients--;
now_ts(&eng.last_activity);
pthread_mutex_unlock(&eng.lock);
free(c->buf);
free(c);
}
/* --------------------------------------------------------------- status */
void cam_get_status(struct cam_status *st)
{
pthread_mutex_lock(&eng.lock);
st->running = eng.running;
st->cam = eng.cam;
st->clients = eng.clients;
st->seq = eng.seq;
st->fps = eng.fps;
pthread_mutex_unlock(&eng.lock);
}
@@ -0,0 +1,61 @@
/*
* cam.h - persistent Glowforge camera capture engine
* Copyright (c) 2026 Scott Wiederhold <s.e.wiederhold@gmail.com>
* SPDX-License-Identifier: MIT
*
* One worker thread owns the imx-media pipeline and V4L2 capture node for
* the selected camera (lid or head - they share the video-mux, so exactly
* one can stream at a time). The engine starts on demand, publishes the
* latest half-resolution JPEG for stream clients, serves full-resolution
* snapshot requests from the same raw frames, and tears the pipeline down
* after an idle period so one-shot users (gfhardware) can still grab.
*/
#ifndef FORGECTRL_CAM_H
#define FORGECTRL_CAM_H
#include <stddef.h>
#include <stdint.h>
typedef enum {
CAM_LID = 0,
CAM_HEAD = 1,
} cam_id_t;
/* Read once at startup (env overrides): stream JPEG quality and lamp level. */
void cam_engine_init(void);
/* Stop the engine (if running) and release everything. */
void cam_engine_shutdown(void);
/* Blocking snapshot from the live engine. full=1 -> 2592x1944 bilinear,
* full=0 -> 1296x972. quality 1..100. On success *jpeg is malloc'd (caller
* frees). Returns 0, or -1 with a message in err (engine busy on the other
* camera, pipeline failure, timeout). */
int cam_snapshot(cam_id_t cam, int full, int quality,
uint8_t **jpeg, size_t *len, char *err, size_t errlen);
/* Stream client: open pins the engine to a camera (starting or switching it
* if needed), next blocks for a frame newer than the last one returned and
* copies it into a client-owned buffer, close releases the pin. */
typedef struct cam_client cam_client_t;
cam_client_t *cam_client_open(cam_id_t cam, char *err, size_t errlen);
/* Returns frame length (>0), or -1 when the engine stopped / timed out and
* the stream should end. The returned pointer stays valid until the next
* cam_client_next() or cam_client_close(). */
long cam_client_next(cam_client_t *c, const uint8_t **jpeg);
void cam_client_close(cam_client_t *c);
/* Status snapshot for /cam/status. */
struct cam_status {
int running;
cam_id_t cam;
int clients;
uint64_t seq;
double fps;
};
void cam_get_status(struct cam_status *st);
const char *cam_name(cam_id_t cam);
#endif
@@ -0,0 +1,82 @@
/*
* debayer.c - BGGR raw-Bayer to RGB conversion for the Glowforge cameras
* Copyright (c) 2026 Scott Wiederhold <s.e.wiederhold@gmail.com>
* SPDX-License-Identifier: MIT
*
* BGGR tile layout (row 0 topmost):
* even rows: B G B G ...
* odd rows: G R G R ...
*/
#include "debayer.h"
static inline int clampi(int v, int lo, int hi)
{
return v < lo ? lo : (v > hi ? hi : v);
}
void debayer_bggr_bilinear(const uint8_t *raw, uint8_t *rgb,
int w, int h, int hflip)
{
/* Border pixels use clamped neighbor coordinates; the interior uses the
* same expressions with the clamps folding to identity. Per-pixel clamp
* cost is acceptable: full resolution is only used for snapshots. */
for (int y = 0; y < h; y++) {
const int yn = clampi(y - 1, 0, h - 1) * w;
const int yc = y * w;
const int ys = clampi(y + 1, 0, h - 1) * w;
uint8_t *out_row = rgb + (long)y * w * 3;
for (int x = 0; x < w; x++) {
const int xw = clampi(x - 1, 0, w - 1);
const int xe = clampi(x + 1, 0, w - 1);
unsigned r, g, b;
if ((y & 1) == 0) {
if ((x & 1) == 0) { /* B site */
b = raw[yc + x];
g = (raw[yc + xw] + raw[yc + xe] +
raw[yn + x] + raw[ys + x] + 2) >> 2;
r = (raw[yn + xw] + raw[yn + xe] +
raw[ys + xw] + raw[ys + xe] + 2) >> 2;
} else { /* G site on a B row */
g = raw[yc + x];
b = (raw[yc + xw] + raw[yc + xe] + 1) >> 1;
r = (raw[yn + x] + raw[ys + x] + 1) >> 1;
}
} else {
if ((x & 1) == 0) { /* G site on an R row */
g = raw[yc + x];
r = (raw[yc + xw] + raw[yc + xe] + 1) >> 1;
b = (raw[yn + x] + raw[ys + x] + 1) >> 1;
} else { /* R site */
r = raw[yc + x];
g = (raw[yc + xw] + raw[yc + xe] +
raw[yn + x] + raw[ys + x] + 2) >> 2;
b = (raw[yn + xw] + raw[yn + xe] +
raw[ys + xw] + raw[ys + xe] + 2) >> 2;
}
}
uint8_t *px = out_row + (long)(hflip ? w - 1 - x : x) * 3;
px[0] = (uint8_t)r;
px[1] = (uint8_t)g;
px[2] = (uint8_t)b;
}
}
}
void debayer_bggr_half(const uint8_t *raw, uint8_t *rgb,
int w, int h, int hflip)
{
const int ow = w / 2;
const int oh = h / 2;
for (int y = 0; y < oh; y++) {
const uint8_t *row_b = raw + (long)(2 * y) * w; /* B G ... */
const uint8_t *row_r = row_b + w; /* G R ... */
uint8_t *out_row = rgb + (long)y * ow * 3;
for (int x = 0; x < ow; x++) {
const int xi = 2 * x;
uint8_t *px = out_row + (long)(hflip ? ow - 1 - x : x) * 3;
px[0] = row_r[xi + 1]; /* R */
px[1] = (uint8_t)((row_b[xi + 1] + row_r[xi] + 1) >> 1); /* G */
px[2] = row_b[xi]; /* B */
}
}
}
@@ -0,0 +1,24 @@
/*
* debayer.h - BGGR raw-Bayer to RGB conversion for the Glowforge cameras
* Copyright (c) 2026 Scott Wiederhold <s.e.wiederhold@gmail.com>
* SPDX-License-Identifier: MIT
*/
#ifndef FORGECTRL_DEBAYER_H
#define FORGECTRL_DEBAYER_H
#include <stdint.h>
/* Full-resolution bilinear demosaic of a BGGR frame. rgb must hold w*h*3
* bytes. hflip mirrors the output horizontally (the factory image
* orientation: the sensor HFLIP register breaks imx-media CSI capture, so
* the mirror is applied in software). */
void debayer_bggr_bilinear(const uint8_t *raw, uint8_t *rgb,
int w, int h, int hflip);
/* Half-resolution demosaic: each 2x2 BGGR quad becomes one RGB pixel
* (greens averaged) - no interpolation. Output is (w/2)x(h/2); rgb must
* hold (w/2)*(h/2)*3 bytes. */
void debayer_bggr_half(const uint8_t *raw, uint8_t *rgb,
int w, int h, int hflip);
#endif
@@ -0,0 +1,36 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: forgectrl
# Required-Start: $network
# Required-Stop:
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: ForgeFIRM control daemon (camera MJPEG service)
### END INIT INFO
DAEMON=/usr/bin/forgectrl
PIDFILE=/var/run/forgectrl.pid
LOG=/data/forgectrl.log
case "$1" in
start)
echo "Starting forgectrl"
start-stop-daemon -S -q -p $PIDFILE -m -b -x /bin/sh -- \
-c "exec $DAEMON >> $LOG 2>&1"
;;
stop)
echo "Stopping forgectrl"
start-stop-daemon -K -q -p $PIDFILE
rm -f $PIDFILE
;;
restart)
$0 stop
sleep 1
$0 start
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
;;
esac
exit 0
@@ -1,6 +0,0 @@
#include <stdio.h>
int main() {
// printf() displays the string inside quotation
printf("Hello, World!");
return 0;
}
@@ -0,0 +1,299 @@
/*
* main.c - forgectrl: ForgeFIRM system control daemon
* Copyright (c) 2026 Scott Wiederhold <s.e.wiederhold@gmail.com>
* SPDX-License-Identifier: MIT
*
* HTTP service (ulfius) exposing the Glowforge cameras as MJPEG:
*
* GET / index page with a live view
* GET /?action=stream mjpg-streamer-compatible stream (lid)
* GET /?action=snapshot mjpg-streamer-compatible snapshot (lid)
* GET /cam/stream?cam=lid|head multipart MJPEG, 1296x972
* GET /cam/snapshot?cam=&res=full|half&q= single JPEG (default full res)
* GET /cam/status JSON engine status
*
* The two cameras share the hardware mux, so streaming clients pin the
* selection; requests for the other camera return 409 until they leave.
* Environment: FORGECTRL_PORT (8080), FORGECTRL_STREAM_Q (75),
* FORGECTRL_LAMP (132).
*
* ulfius runs libmicrohttpd in thread-per-connection mode, so each stream
* callback may block waiting for the next frame.
*/
#define _GNU_SOURCE
#include "cam.h"
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ulfius.h>
#include <unistd.h>
#define DEFAULT_PORT 8080
#define BOUNDARY "forgectrl-frame"
#define SNAP_Q_DEF 75
static volatile sig_atomic_t quit = 0;
static void on_signal(int sig)
{
(void)sig;
quit = 1;
}
/* ------------------------------------------------------------- helpers */
static cam_id_t parse_cam(const struct _u_request *req, int *ok)
{
const char *v = u_map_get(req->map_url, "cam");
*ok = 1;
if (!v || !strcmp(v, "lid"))
return CAM_LID;
if (!strcmp(v, "head"))
return CAM_HEAD;
*ok = 0;
return CAM_LID;
}
static int reply_error(struct _u_response *res, unsigned status,
const char *msg)
{
ulfius_set_string_body_response(res, status, msg);
ulfius_add_header_to_response(res, "Content-Type", "text/plain");
return U_CALLBACK_CONTINUE;
}
/* ------------------------------------------------------------ streaming */
struct stream_ctx {
cam_client_t *cl;
uint8_t *chunk; /* current multipart chunk being drained */
size_t chunk_cap;
size_t chunk_len;
size_t off;
};
static ssize_t stream_cb(void *cls, uint64_t pos, char *buf, size_t max)
{
(void)pos;
struct stream_ctx *sc = cls;
if (sc->off >= sc->chunk_len) {
const uint8_t *jpg;
long len = cam_client_next(sc->cl, &jpg);
if (len < 0)
return U_STREAM_END;
char head[128];
int headlen = snprintf(head, sizeof(head),
"--" BOUNDARY "\r\n"
"Content-Type: image/jpeg\r\n"
"Content-Length: %ld\r\n\r\n", len);
size_t need = (size_t)headlen + (size_t)len + 2;
if (sc->chunk_cap < need) {
uint8_t *nb = realloc(sc->chunk, need);
if (!nb)
return U_STREAM_ERROR;
sc->chunk = nb;
sc->chunk_cap = need;
}
memcpy(sc->chunk, head, (size_t)headlen);
memcpy(sc->chunk + headlen, jpg, (size_t)len);
memcpy(sc->chunk + headlen + len, "\r\n", 2);
sc->chunk_len = need;
sc->off = 0;
}
size_t n = sc->chunk_len - sc->off;
if (n > max)
n = max;
memcpy(buf, sc->chunk + sc->off, n);
sc->off += n;
return (ssize_t)n;
}
static void stream_free_cb(void *cls)
{
struct stream_ctx *sc = cls;
cam_client_close(sc->cl);
free(sc->chunk);
free(sc);
}
static int do_stream(cam_id_t cam, struct _u_response *res)
{
char err[256];
cam_client_t *cl = cam_client_open(cam, err, sizeof(err));
if (!cl)
return reply_error(res, strstr(err, "busy") ? 409 : 503, err);
struct stream_ctx *sc = calloc(1, sizeof(*sc));
if (!sc) {
cam_client_close(cl);
return reply_error(res, 500, "out of memory");
}
sc->cl = cl;
ulfius_add_header_to_response(res, "Content-Type",
"multipart/x-mixed-replace; boundary=" BOUNDARY);
ulfius_add_header_to_response(res, "Cache-Control", "no-store");
ulfius_set_stream_response(res, 200, stream_cb, stream_free_cb,
U_STREAM_SIZE_UNKNOWN, 64 * 1024, sc);
return U_CALLBACK_CONTINUE;
}
static int do_snapshot(cam_id_t cam, int full, int quality,
struct _u_response *res)
{
uint8_t *jpg = NULL;
size_t len = 0;
char err[256];
if (cam_snapshot(cam, full, quality, &jpg, &len, err, sizeof(err)))
return reply_error(res, strstr(err, "busy") ? 409 : 503, err);
ulfius_set_binary_body_response(res, 200, (const char *)jpg, len);
ulfius_add_header_to_response(res, "Content-Type", "image/jpeg");
ulfius_add_header_to_response(res, "Cache-Control", "no-store");
free(jpg);
return U_CALLBACK_CONTINUE;
}
/* ------------------------------------------------------------ callbacks */
static int cb_stream(const struct _u_request *req, struct _u_response *res,
void *user_data)
{
(void)user_data;
int ok;
cam_id_t cam = parse_cam(req, &ok);
if (!ok)
return reply_error(res, 400, "cam must be 'lid' or 'head'");
return do_stream(cam, res);
}
static int cb_snapshot(const struct _u_request *req, struct _u_response *res,
void *user_data)
{
(void)user_data;
int ok;
cam_id_t cam = parse_cam(req, &ok);
if (!ok)
return reply_error(res, 400, "cam must be 'lid' or 'head'");
int full = 1;
const char *v = u_map_get(req->map_url, "res");
if (v) {
if (!strcmp(v, "half"))
full = 0;
else if (strcmp(v, "full"))
return reply_error(res, 400, "res must be 'full' or 'half'");
}
int quality = SNAP_Q_DEF;
if ((v = u_map_get(req->map_url, "q")) != NULL) {
quality = atoi(v);
if (quality < 1 || quality > 100)
return reply_error(res, 400, "q must be 1..100");
}
return do_snapshot(cam, full, quality, res);
}
static int cb_status(const struct _u_request *req, struct _u_response *res,
void *user_data)
{
(void)req;
(void)user_data;
struct cam_status st;
cam_get_status(&st);
char body[256];
snprintf(body, sizeof(body),
"{\"running\":%s,\"cam\":\"%s\",\"clients\":%d,"
"\"frames\":%llu,\"fps\":%.1f,"
"\"stream\":{\"width\":1296,\"height\":972},"
"\"snapshot\":{\"width\":2592,\"height\":1944}}",
st.running ? "true" : "false", cam_name(st.cam), st.clients,
(unsigned long long)st.seq, st.fps);
ulfius_set_string_body_response(res, 200, body);
ulfius_add_header_to_response(res, "Content-Type", "application/json");
return U_CALLBACK_CONTINUE;
}
static const char index_html[] =
"<!DOCTYPE html><html><head><title>ForgeFIRM camera</title>"
"<style>body{font-family:sans-serif;background:#111;color:#ddd;"
"text-align:center}img{max-width:95%;border:1px solid #444}"
"a{color:#8cf}</style></head><body>"
"<h2>ForgeFIRM camera</h2>"
"<p><a href=\"/cam/stream?cam=lid\">lid stream</a> | "
"<a href=\"/cam/snapshot?cam=lid\">lid snapshot</a> | "
"<a href=\"/cam/stream?cam=head\">head stream</a> | "
"<a href=\"/cam/snapshot?cam=head\">head snapshot</a> | "
"<a href=\"/cam/status\">status</a></p>"
"<img src=\"/cam/stream?cam=lid\" alt=\"lid camera stream\">"
"</body></html>";
/* "/" serves the index, plus the mjpg-streamer-compatible
* ?action=stream / ?action=snapshot aliases many clients expect. */
static int cb_root(const struct _u_request *req, struct _u_response *res,
void *user_data)
{
(void)user_data;
const char *action = u_map_get(req->map_url, "action");
if (action) {
if (!strcmp(action, "stream"))
return do_stream(CAM_LID, res);
if (!strcmp(action, "snapshot"))
return do_snapshot(CAM_LID, 1, SNAP_Q_DEF, res);
return reply_error(res, 400, "unknown action");
}
ulfius_set_string_body_response(res, 200, index_html);
ulfius_add_header_to_response(res, "Content-Type", "text/html");
return U_CALLBACK_CONTINUE;
}
/* ------------------------------------------------------------------ main */
int main(void)
{
unsigned port = DEFAULT_PORT;
const char *v = getenv("FORGECTRL_PORT");
if (v && atoi(v) > 0 && atoi(v) < 65536)
port = (unsigned)atoi(v);
/* Stay well below the motion feeder (SCHED_FIFO) and the controller;
* best effort. */
(void)nice(5);
cam_engine_init();
struct _u_instance inst;
if (ulfius_init_instance(&inst, port, NULL, NULL) != U_OK) {
fprintf(stderr, "forgectrl: ulfius init failed\n");
return 1;
}
ulfius_add_endpoint_by_val(&inst, "GET", "/", NULL, 0, &cb_root, NULL);
ulfius_add_endpoint_by_val(&inst, "GET", "/cam/stream", NULL, 0,
&cb_stream, NULL);
ulfius_add_endpoint_by_val(&inst, "GET", "/cam/snapshot", NULL, 0,
&cb_snapshot, NULL);
ulfius_add_endpoint_by_val(&inst, "GET", "/cam/status", NULL, 0,
&cb_status, NULL);
if (ulfius_start_framework(&inst) != U_OK) {
fprintf(stderr, "forgectrl: cannot start HTTP on port %u\n", port);
ulfius_clean_instance(&inst);
return 1;
}
fprintf(stderr, "forgectrl: listening on port %u\n", port);
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
signal(SIGPIPE, SIG_IGN);
while (!quit)
pause();
fprintf(stderr, "forgectrl: shutting down\n");
ulfius_stop_framework(&inst);
ulfius_clean_instance(&inst);
cam_engine_shutdown();
return 0;
}