From b3a8924c915ba4607a05d213a9f437886c808cbe Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Mon, 3 Aug 2026 12:26:23 -0400 Subject: [PATCH] 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. --- docs/BRINGUP.md | 61 +- .../recipes-forgefirm/forgectrl/forgectrl.bb | 22 +- .../forgectrl/forgectrl/CMakeLists.txt | 6 +- .../forgectrl/forgectrl/cam.c | 847 ++++++++++++++++++ .../forgectrl/forgectrl/cam.h | 61 ++ .../forgectrl/forgectrl/debayer.c | 82 ++ .../forgectrl/forgectrl/debayer.h | 24 + .../forgectrl/forgectrl/forgectrl.init | 36 + .../forgectrl/forgectrl/hello.c | 6 - .../forgectrl/forgectrl/main.c | 299 +++++++ .../images/forgefirm-image.bb | 17 +- 11 files changed, 1435 insertions(+), 26 deletions(-) create mode 100644 meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.c create mode 100644 meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.h create mode 100644 meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/debayer.c create mode 100644 meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/debayer.h create mode 100644 meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/forgectrl.init delete mode 100644 meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/hello.c create mode 100644 meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/main.c diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 90f3a60..1fa8761 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -1,8 +1,9 @@ # ForgeFIRM bring-up status & cold-start runbook -Last updated: **2026-08-02** — milestone 2 (factory-true motion tuning) -bench-verified, and the controller promoted to a canonical grblHAL driver -repo (**grblHAL-glowforge**) with bench parity re-proven on hardware. +Last updated: **2026-08-03** — camera service (forgectrl MJPEG on :8080) +implemented and bench-verified, including motion-coexistence (clamped 0 +while streaming). Previous milestone: factory-true motion tuning + +promotion to the canonical grblHAL driver repo (**grblHAL-glowforge**). Read together with `AUDIT_ACTION_PLAN.md` in the project root (sibling of this repo; per-finding status of the 2026-07-03 audit) and `kernel-module-glowforge/UAPI.md` (the pulse-stream feeder contract). @@ -121,6 +122,47 @@ hardware I/O — host testing). (controlled decel) and raises an alarm; TCP disconnects never kill the process (the deadman fd stays held). +## The camera service (forgectrl, port 8080) + +Source: `meta-forgefirm/recipes-forgefirm/forgectrl/` (recipe-local C, +MIT; built by the `forgectrl` recipe, installed in both images with a +sysvinit script). One ulfius daemon exposes both OV5648 cameras as MJPEG +over the mainline imx-media pipeline: + +- `GET /` — index page with a live view; `/?action=stream|snapshot` are + the mjpg-streamer-compatible aliases (lid camera). +- `GET /cam/stream?cam=lid|head` — multipart MJPEG at 1296×972 (2×2 + Bayer-superpixel demosaic, JPEG q75; `FORGECTRL_STREAM_Q` overrides). +- `GET /cam/snapshot?cam=lid|head&res=full|half&q=1..100` — single JPEG, + default full 2592×1944 (own MIT bilinear demosaic, output verified + against the gfhardware reference grab). +- `GET /cam/status` — JSON (running/cam/clients/frames/fps). + +Engine model: one worker owns the V4L2 node persistently (media-ctl / +v4l2-ctl configure sequences identical to gfhardware/cam.py, factory +exposure/gain/WB, software hflip in the demosaic); starts on demand, +full teardown after 10 s idle so gfhardware one-shot grabs still work. +The cameras share the hardware video-mux, so stream clients pin the +selection — requests for the other camera return 409 until they leave. +The per-camera lamp (`pic/lid_led` / `head/white_led`) is raised to +`FORGECTRL_LAMP` (default 132) while capturing and restored on idle. + +Bench (2026-08-03, on the board): stream 3.2 fps sustained at 1296×972; +full-res snapshot 2.4 s warm / 2.7 s cold (cold includes the pipeline +bring-up); camera switch works both ways; two parallel clients share the +frame rate; idle teardown observed. **Motion coexistence proven**: X +round-trip jogs at F1200 with an active stream — producer stats +`clamped 0`, max behind 4.5 ms (the daemon runs at nice +5, single +core). Run by hand: `/usr/bin/forgectrl >> /data/forgectrl.log 2>&1 &` +(kill before scp when redeploying, text-file-busy). + +Not yet done: LightBurn-side camera consumption (needs an operator +session — LightBurn desktop takes USB webcams; the mjpg-streamer alias +is the standard bridge-style interface, so test whether the overlay can +use it directly or needs a relay), lens calibration / bed alignment, and +the deferred 5.6 emulator homing-image smoke (the cloud emulator can now +be pointed at live snapshots). + ## Hardware facts bank (measured) - SDMA pulse engine: ring free = 128 MiB − 32 KiB gap; script effective @@ -293,6 +335,13 @@ hardware I/O — host testing). the dependence is weak — with forced flow ΔT = P/(ṁ·c), which carries no absolute-temperature term — but that is reasoning, not measurement. + - **OBSERVED 2026-08-03 (needs triage):** `/data/glowforge.log` + carries, from a prior controller run, a passing check (rise + 11.4 °C) followed by TWO `COOLANT FLOW FAULT` lines (rise + 16.5 / 15.9 °C vs the 14.4 limit, dT 11.6). Undated (raw + stderr log). Either the pump genuinely faltered or this is the + warm-baseline false-positive mode above — check the pump and + re-run a supervised verification before trusting the loop. *(Superseded earlier text kept below for context.)* **Coolant flow verification (first attempt, live-verified both ways).** @@ -347,8 +396,10 @@ hardware I/O — host testing). hall-supervised only. 4. **6.5 safety mapping**: door/estop evdev → feed-hold/halt in the backend; underrun → grblHAL alarm; interlock-trip recovery check. -5. **6.6 camera service**: persistent MJPEG (ulfius, forgectrl) — also the - natural time for the deferred 5.6 emulator smoke (homing images). +5. **6.6 camera service: DONE 2026-08-03, bench-verified** (see "The + camera service" section above). Remaining camera work: LightBurn + consumption test (operator), lens calibration / bed alignment, the + deferred 5.6 emulator homing-image smoke. 6. **Housekeeping**: ~~pick the controller's remote home~~ **DONE 2026-08-02** — the controller is now the canonical driver repo `github.com/ScottW514/grblHAL-glowforge` (+ `ScottW514/core` fork; diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl.bb b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl.bb index 6e73ebf..28bd850 100644 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl.bb +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl.bb @@ -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 +} diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/CMakeLists.txt b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/CMakeLists.txt index e6b23be..2f8fd75 100644 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/CMakeLists.txt +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/CMakeLists.txt @@ -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) diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.c b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.c new file mode 100644 index 0000000..26dd4b2 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.c @@ -0,0 +1,847 @@ +/* + * cam.c - persistent Glowforge camera capture engine + * Copyright (c) 2026 Scott Wiederhold + * 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 +#include +#include +#include +#include +#include +#include +#include /* requires stdio.h first (FILE) */ +#include +#include +#include +#include +#include +#include +#include +#include + +#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 -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); +} diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.h b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.h new file mode 100644 index 0000000..95797fc --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/cam.h @@ -0,0 +1,61 @@ +/* + * cam.h - persistent Glowforge camera capture engine + * Copyright (c) 2026 Scott Wiederhold + * 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 +#include + +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 diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/debayer.c b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/debayer.c new file mode 100644 index 0000000..e54e464 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/debayer.c @@ -0,0 +1,82 @@ +/* + * debayer.c - BGGR raw-Bayer to RGB conversion for the Glowforge cameras + * Copyright (c) 2026 Scott Wiederhold + * 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 */ + } + } +} diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/debayer.h b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/debayer.h new file mode 100644 index 0000000..b2e13b2 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/debayer.h @@ -0,0 +1,24 @@ +/* + * debayer.h - BGGR raw-Bayer to RGB conversion for the Glowforge cameras + * Copyright (c) 2026 Scott Wiederhold + * SPDX-License-Identifier: MIT + */ +#ifndef FORGECTRL_DEBAYER_H +#define FORGECTRL_DEBAYER_H + +#include + +/* 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 diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/forgectrl.init b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/forgectrl.init new file mode 100644 index 0000000..870e6b8 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/forgectrl.init @@ -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 diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/hello.c b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/hello.c deleted file mode 100644 index 34d86c4..0000000 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/hello.c +++ /dev/null @@ -1,6 +0,0 @@ -#include -int main() { - // printf() displays the string inside quotation - printf("Hello, World!"); - return 0; -} diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/main.c b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/main.c new file mode 100644 index 0000000..5be3393 --- /dev/null +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl/main.c @@ -0,0 +1,299 @@ +/* + * main.c - forgectrl: ForgeFIRM system control daemon + * Copyright (c) 2026 Scott Wiederhold + * 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 +#include +#include +#include +#include +#include + +#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[] = + "ForgeFIRM camera" + "" + "

ForgeFIRM camera

" + "

lid stream | " + "lid snapshot | " + "head stream | " + "head snapshot | " + "status

" + "\"lid" + ""; + +/* "/" 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; +} diff --git a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb index f5f970b..8f68a8b 100644 --- a/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb +++ b/meta-forgefirm/recipes-forgefirm/images/forgefirm-image.bb @@ -3,13 +3,14 @@ require recipes-glowforge/images/glowforge-image.bb DESCRIPTION = "OpenGlow/ForgeFIRM image for Glowforge" # ForgeFIRM cuts the cloud dependency: drop the Glowforge cloud client -# (gfui-client connects to Glowforge's servers) — exactly what ForgeFIRM -# replaces with a local grblHAL controller (forgectrl, currently a placeholder; -# see kas/README.md backlog #5). Removing it here (override only; the shared -# glowforge-image base is untouched) also sidesteps its do_package failure. +# (gfui-client connects to Glowforge's servers). Removing it here (override +# only; the shared glowforge-image base is untouched) also sidesteps its +# do_package failure. Its slot is filled locally by forgectrl (camera MJPEG +# service; the grblHAL controller recipe is tracked in kas/README.md +# backlog #5). IMAGE_INSTALL:remove = "gfui-client" -# Camera bring-up tooling: v4l-utils provides v4l2-ctl (the media-ctl binary is -# already pulled in) for configuring the imx-media pipeline and grabbing frames -# while the gfhardware capture path is ported off the factory NXP V4L2 model. -IMAGE_INSTALL:append = " v4l-utils" +# forgectrl: the ForgeFIRM control daemon (camera MJPEG service on :8080). +# v4l-utils provides media-ctl / v4l2-ctl for the imx-media pipeline (also a +# forgectrl runtime dependency, kept explicit here for bring-up use). +IMAGE_INSTALL:append = " forgectrl v4l-utils"