mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
kinect stuff v1
This commit is contained in:
@@ -98,6 +98,13 @@ roomCameras:
|
||||
url: "http://192.168.0.51/snapshot.jpg"
|
||||
streamUrl: "http://192.168.0.51/stream.mjpg"
|
||||
|
||||
kinect:
|
||||
enabled: false
|
||||
# Capture requests are global across 3d/color so one person cannot spam room
|
||||
# uploads for everyone else. This does not affect the native worker's local
|
||||
# camera cache; it only gates browser-requested broadcasts.
|
||||
captureCooldownMs: 10000
|
||||
|
||||
discord:
|
||||
token: "DISCORD_BOT_TOKEN"
|
||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||
|
||||
@@ -40,6 +40,7 @@ require('./src/services/liftService');
|
||||
require('./src/services/audioLevelsService');
|
||||
require('./src/services/audioForwardService');
|
||||
require('./src/services/buttonBoxService');
|
||||
require('./src/services/kinectService');
|
||||
require('./src/services/sessionService');
|
||||
require('./src/services/batteryManager');
|
||||
require('./src/services/replayEngineV2');
|
||||
|
||||
@@ -11,6 +11,7 @@ MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
||||
KINECT_UDEV_RULE="/etc/udev/rules.d/99-kinect-world.rules"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This installer must be run with sudo/root." >&2
|
||||
@@ -30,12 +31,45 @@ MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
||||
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
||||
|
||||
echo "[1/6] Installing dependencies..."
|
||||
dnf install -y nodejs npm curl tar >/dev/null
|
||||
# The Kinect tooling uses a native libfreenect worker/probe rather than a
|
||||
# Python wrapper. Install both runtime and development headers here so a fresh
|
||||
# Fedora server can build the worker locally and then run it under the same
|
||||
# normal user that owns the rover service.
|
||||
dnf install -y \
|
||||
nodejs \
|
||||
npm \
|
||||
curl \
|
||||
tar \
|
||||
gcc-c++ \
|
||||
make \
|
||||
pkgconf-pkg-config \
|
||||
libfreenect \
|
||||
libfreenect-devel \
|
||||
libusb1-devel >/dev/null
|
||||
NODE_BIN="$(command -v node)"
|
||||
|
||||
echo " Installing Kinect udev rule -> $KINECT_UDEV_RULE"
|
||||
cat > "$KINECT_UDEV_RULE" <<'EOF'
|
||||
# Xbox 360 / Kinect v1 exposes motor, audio, and camera as separate Microsoft
|
||||
# USB devices. Fedora/OpenNI PrimeSense rules can leave the camera node as
|
||||
# root:primesense 0660, which makes libfreenect fail with LIBUSB_ERROR_ACCESS
|
||||
# when the rover server runs as the normal service user. This late 99-* rule is
|
||||
# intentionally broad for local rover hardware: every Microsoft Kinect sibling
|
||||
# gets world read/write access so the native libfreenect worker can open the
|
||||
# camera without running the whole server as root.
|
||||
SUBSYSTEM=="usb", ATTR{idVendor}=="045e", MODE="0666", GROUP="root", TAG+="uaccess"
|
||||
EOF
|
||||
chmod 644 "$KINECT_UDEV_RULE"
|
||||
udevadm control --reload-rules
|
||||
|
||||
echo "[2/6] Installing Node production deps..."
|
||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production"
|
||||
|
||||
if [[ -f "$SERVER_DIR/src/services/kinectService/native/Makefile" ]]; then
|
||||
echo " Building native Kinect worker..."
|
||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR/src/services/kinectService/native' && make"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
||||
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
||||
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
||||
@@ -145,3 +179,5 @@ echo " mediamtx.service (WebRTC fan-out)"
|
||||
echo " multirover.service (Node.js control server)"
|
||||
echo
|
||||
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
||||
echo "Kinect/libfreenect packages and udev permissions were installed."
|
||||
echo "If a Kinect is already plugged in, unplug/replug its USB/power before testing so the new udev rule applies."
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-8c6_50As.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CzvRO_Gd.css">
|
||||
<script type="module" crossorigin src="/assets/index-CYxH68Qf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dzjs7qyo.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Kinect Hardware Bridge
|
||||
// Purpose: Owns the persistent native Kinect worker process and converts worker output into Socket.IO-ready payloads.
|
||||
// Scope: Keeps libfreenect process management isolated from auth, cooldown, and browser delivery.
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const WORKER_PATH = process.env.KINECT_WORKER || path.join(__dirname, 'native', 'kinect_worker');
|
||||
const CAPTURE_TIMEOUT_MS = 12000;
|
||||
const WORKER_STDERR_LOG_INTERVAL_MS = 5000;
|
||||
|
||||
let worker = null;
|
||||
let stdoutBuffer = Buffer.alloc(0);
|
||||
let pending = null;
|
||||
let commandChain = Promise.resolve();
|
||||
let nextCommandId = 1;
|
||||
let lastWorkerStderr = '';
|
||||
let lastWorkerStderrLogAt = 0;
|
||||
let suppressedWorkerStderr = 0;
|
||||
|
||||
function resetWorker(child = worker) {
|
||||
// Child process events can arrive after a replacement worker has already
|
||||
// started. Only clear module state when the event belongs to the current
|
||||
// process so a stale close event cannot tear down the live stream.
|
||||
if (child && worker && child !== worker) return;
|
||||
worker = null;
|
||||
stdoutBuffer = Buffer.alloc(0);
|
||||
pending = null;
|
||||
}
|
||||
|
||||
function rejectPending(err) {
|
||||
if (!pending) return;
|
||||
const current = pending;
|
||||
pending = null;
|
||||
clearTimeout(current.timeout);
|
||||
current.reject(err);
|
||||
}
|
||||
|
||||
function stopWorker() {
|
||||
if (!worker) return;
|
||||
const child = worker;
|
||||
resetWorker();
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
|
||||
function parseWorkerStdout() {
|
||||
if (!pending) return;
|
||||
|
||||
while (pending) {
|
||||
if (!pending.meta) {
|
||||
const newline = stdoutBuffer.indexOf(0x0a);
|
||||
if (newline === -1) return;
|
||||
try {
|
||||
pending.meta = JSON.parse(stdoutBuffer.slice(0, newline).toString('utf8'));
|
||||
} catch (err) {
|
||||
rejectPending(new Error(`kinect worker returned invalid metadata: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
stdoutBuffer = stdoutBuffer.slice(newline + 1);
|
||||
}
|
||||
|
||||
const payloadBytes = Number(pending.meta.payloadBytes) || 0;
|
||||
if (stdoutBuffer.length < payloadBytes) return;
|
||||
const payload = stdoutBuffer.slice(0, payloadBytes);
|
||||
stdoutBuffer = stdoutBuffer.slice(payloadBytes);
|
||||
|
||||
const current = pending;
|
||||
pending = null;
|
||||
clearTimeout(current.timeout);
|
||||
if (current.meta.id !== current.id) {
|
||||
current.reject(new Error('kinect worker response id mismatch'));
|
||||
} else if (!current.meta.ok) {
|
||||
current.reject(new Error(current.meta.error || 'kinect worker failed'));
|
||||
} else {
|
||||
current.resolve({ meta: current.meta, payload });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (worker && !worker.killed) {
|
||||
return worker;
|
||||
}
|
||||
|
||||
lastWorkerStderr = '';
|
||||
const child = spawn(WORKER_PATH, [], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
worker = child;
|
||||
stdoutBuffer = Buffer.alloc(0);
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk]);
|
||||
parseWorkerStdout();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
// libfreenect logs diagnostic USB details to stderr. Keep those details
|
||||
// available for the eventual close error, but throttle console output
|
||||
// because packet-loss messages can become repetitive on Kinect v1 hardware.
|
||||
const text = chunk.toString('utf8').trim();
|
||||
if (!text) return;
|
||||
lastWorkerStderr = text.split('\n').filter(Boolean).slice(-1)[0] || text;
|
||||
const now = Date.now();
|
||||
if (now - lastWorkerStderrLogAt >= WORKER_STDERR_LOG_INTERVAL_MS) {
|
||||
const suffix = suppressedWorkerStderr
|
||||
? ` (${suppressedWorkerStderr} similar worker stderr messages suppressed)`
|
||||
: '';
|
||||
console.warn('[kinect worker]', `${text}${suffix}`);
|
||||
lastWorkerStderrLogAt = now;
|
||||
suppressedWorkerStderr = 0;
|
||||
} else {
|
||||
suppressedWorkerStderr += 1;
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
rejectPending(err);
|
||||
resetWorker(child);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
const reason = signal || code;
|
||||
const detail = lastWorkerStderr ? `: ${lastWorkerStderr}` : '';
|
||||
rejectPending(new Error(`kinect worker exited (${reason})${detail}`));
|
||||
resetWorker(child);
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
function startWorker() {
|
||||
ensureWorker();
|
||||
}
|
||||
|
||||
function sendWorkerCommand(command, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = ensureWorker();
|
||||
const id = nextCommandId;
|
||||
nextCommandId += 1;
|
||||
|
||||
// The native worker frames stdout as "one response for one command", so
|
||||
// keeping only one in-flight command prevents interleaved binary payloads
|
||||
// and also avoids overlapping expensive point-cloud serialization.
|
||||
if (pending) {
|
||||
reject(new Error('kinect worker command already running'));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
rejectPending(new Error('kinect worker timed out'));
|
||||
stopWorker();
|
||||
}, timeoutMs);
|
||||
|
||||
pending = {
|
||||
id,
|
||||
meta: null,
|
||||
timeout,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
|
||||
try {
|
||||
child.stdin.write(`${JSON.stringify({ id, ...command })}\n`);
|
||||
} catch (err) {
|
||||
rejectPending(err);
|
||||
stopWorker();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function queueWorkerCommand(command, timeoutMs) {
|
||||
commandChain = commandChain
|
||||
.catch(() => {})
|
||||
.then(() => sendWorkerCommand(command, timeoutMs));
|
||||
return commandChain;
|
||||
}
|
||||
|
||||
async function captureColorImage() {
|
||||
const { meta, payload } = await queueWorkerCommand({ mode: 'color' }, CAPTURE_TIMEOUT_MS);
|
||||
const jpeg = await sharp(payload, {
|
||||
raw: {
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
channels: 3,
|
||||
},
|
||||
})
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
return {
|
||||
meta: {
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
format: 'jpeg',
|
||||
frameAgeMs: meta.frameAgeMs,
|
||||
},
|
||||
buffer: jpeg,
|
||||
};
|
||||
}
|
||||
|
||||
async function capturePointCloud() {
|
||||
const { meta, payload } = await queueWorkerCommand({ mode: 'pointcloud' }, CAPTURE_TIMEOUT_MS);
|
||||
return {
|
||||
meta: {
|
||||
width: meta.width,
|
||||
height: meta.height,
|
||||
pointCount: meta.pointCount,
|
||||
format: meta.format,
|
||||
strideBytes: 16,
|
||||
rgbFrameAgeMs: meta.rgbFrameAgeMs,
|
||||
depthFrameAgeMs: meta.depthFrameAgeMs,
|
||||
},
|
||||
buffer: payload,
|
||||
};
|
||||
}
|
||||
|
||||
async function getWorkerStatus() {
|
||||
const { meta } = await queueWorkerCommand({ mode: 'status' }, CAPTURE_TIMEOUT_MS);
|
||||
return {
|
||||
hasRgb: Boolean(meta.hasRgb),
|
||||
hasDepth: Boolean(meta.hasDepth),
|
||||
rgbFrames: Number(meta.rgbFrames) || 0,
|
||||
depthFrames: Number(meta.depthFrames) || 0,
|
||||
validDepthPixels: Number(meta.validDepthPixels) || 0,
|
||||
rgbFrameAgeMs: meta.rgbFrameAgeMs ?? null,
|
||||
depthFrameAgeMs: meta.depthFrameAgeMs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function installShutdownHooks() {
|
||||
const shutdown = () => stopWorker();
|
||||
process.once('exit', shutdown);
|
||||
process.once('SIGINT', () => {
|
||||
shutdown();
|
||||
process.exit(130);
|
||||
});
|
||||
process.once('SIGTERM', () => {
|
||||
shutdown();
|
||||
process.exit(143);
|
||||
});
|
||||
}
|
||||
|
||||
installShutdownHooks();
|
||||
|
||||
module.exports = {
|
||||
startWorker,
|
||||
stopWorker,
|
||||
captureColorImage,
|
||||
capturePointCloud,
|
||||
getWorkerStatus,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// Kinect Service
|
||||
// Purpose: Composes Kinect hardware capture and browser socket delivery.
|
||||
// Scope: Exposes session-readable state while keeping startup side effects in this service folder.
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const hardware = require('./hardware');
|
||||
const { registerKinectSocketGateway, kinectEvents } = require('./socketGateway');
|
||||
|
||||
const config = loadConfig();
|
||||
const gateway = registerKinectSocketGateway({
|
||||
config,
|
||||
hardware,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getState: gateway.getState,
|
||||
kinectEvents,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
# The native Kinect worker is built on the target Fedora server because it links
|
||||
# against the locally installed libfreenect/libusb packages.
|
||||
/kinect_worker
|
||||
@@ -0,0 +1,30 @@
|
||||
CXX ?= g++
|
||||
PKG_CONFIG ?= pkg-config
|
||||
|
||||
# Build the production Kinect worker from native C++ so it uses the same
|
||||
# libfreenect callback/event path that the standalone probe proved reliable.
|
||||
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
|
||||
CPPFLAGS += $(shell $(PKG_CONFIG) --cflags libfreenect 2>/dev/null)
|
||||
LDLIBS += $(shell $(PKG_CONFIG) --libs libfreenect 2>/dev/null) -pthread
|
||||
|
||||
# Fedora installations do not always provide libfreenect.pc, so keep explicit
|
||||
# fallback paths for the package layout installed by install_server.sh.
|
||||
ifeq ($(strip $(CPPFLAGS)),)
|
||||
CPPFLAGS += -I/usr/include/libfreenect -I/usr/include/libusb-1.0
|
||||
endif
|
||||
ifeq ($(filter -lfreenect,$(LDLIBS)),)
|
||||
LDLIBS += -lfreenect
|
||||
endif
|
||||
|
||||
TARGET := kinect_worker
|
||||
SRC := kinect_worker.cpp
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): $(SRC)
|
||||
$(CXX) $(CXXFLAGS) $(CPPFLAGS) -o $@ $< $(LDLIBS)
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
@@ -0,0 +1,441 @@
|
||||
// Kinect native worker.
|
||||
//
|
||||
// Purpose:
|
||||
// Keep the proven libfreenect camera/depth callback path running in one native
|
||||
// process and answer snapshot commands from Node. Node owns auth, cooldowns,
|
||||
// JPEG encoding, and Socket.IO fan-out; this worker owns only USB streaming and
|
||||
// binary frame extraction.
|
||||
//
|
||||
// Protocol:
|
||||
// stdin receives one JSON command per line, for example {"id":1,"mode":"color"}.
|
||||
// stdout returns one JSON metadata line followed by payloadBytes raw bytes. All
|
||||
// diagnostics go to stderr so libfreenect logs can never corrupt binary frames.
|
||||
|
||||
#include <libfreenect.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <sys/time.h>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kWidth = 640;
|
||||
constexpr int kHeight = 480;
|
||||
constexpr int kRgbBytes = kWidth * kHeight * 3;
|
||||
constexpr int kDepthPixels = kWidth * kHeight;
|
||||
constexpr int kFrameStaleMs = 5000;
|
||||
constexpr int kCommandFrameWaitMs = 3000;
|
||||
|
||||
struct FrameCache {
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::vector<uint8_t> rgb = std::vector<uint8_t>(kRgbBytes);
|
||||
std::vector<uint16_t> depth = std::vector<uint16_t>(kDepthPixels);
|
||||
bool has_rgb = false;
|
||||
bool has_depth = false;
|
||||
uint32_t valid_depth_pixels = 0;
|
||||
uint64_t rgb_at_ms = 0;
|
||||
uint64_t depth_at_ms = 0;
|
||||
uint64_t rgb_frames = 0;
|
||||
uint64_t depth_frames = 0;
|
||||
};
|
||||
|
||||
FrameCache cache;
|
||||
std::atomic<bool> running{true};
|
||||
freenect_context* freenect_ctx = nullptr;
|
||||
freenect_device* freenect_dev = nullptr;
|
||||
|
||||
// libfreenect's video callback expects us to hand back a replacement buffer.
|
||||
// The cache receives its own copy, so this buffer can be reused solely for USB
|
||||
// streaming without Node ever reading from memory libfreenect still owns.
|
||||
std::vector<uint8_t> video_back_buffer(kRgbBytes);
|
||||
|
||||
uint64_t now_ms() {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
void log_step(const std::string& message) {
|
||||
std::cerr << "[kinect-worker] " << message << "\n";
|
||||
}
|
||||
|
||||
std::string json_escape(const std::string& value) {
|
||||
std::ostringstream out;
|
||||
for (char ch : value) {
|
||||
switch (ch) {
|
||||
case '\\':
|
||||
out << "\\\\";
|
||||
break;
|
||||
case '"':
|
||||
out << "\\\"";
|
||||
break;
|
||||
case '\n':
|
||||
out << "\\n";
|
||||
break;
|
||||
case '\r':
|
||||
out << "\\r";
|
||||
break;
|
||||
case '\t':
|
||||
out << "\\t";
|
||||
break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(ch) < 0x20) {
|
||||
out << "\\u" << std::hex << std::setw(4) << std::setfill('0')
|
||||
<< static_cast<int>(static_cast<unsigned char>(ch));
|
||||
} else {
|
||||
out << ch;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
int parse_id(const std::string& line) {
|
||||
const std::string key = "\"id\"";
|
||||
const auto key_pos = line.find(key);
|
||||
if (key_pos == std::string::npos) return 0;
|
||||
const auto colon = line.find(':', key_pos + key.size());
|
||||
if (colon == std::string::npos) return 0;
|
||||
std::size_t pos = colon + 1;
|
||||
while (pos < line.size() && (line[pos] == ' ' || line[pos] == '\t')) pos += 1;
|
||||
return std::atoi(line.c_str() + pos);
|
||||
}
|
||||
|
||||
std::string parse_mode(const std::string& line) {
|
||||
const std::string key = "\"mode\"";
|
||||
const auto key_pos = line.find(key);
|
||||
if (key_pos == std::string::npos) return "";
|
||||
const auto colon = line.find(':', key_pos + key.size());
|
||||
if (colon == std::string::npos) return "";
|
||||
const auto first_quote = line.find('"', colon + 1);
|
||||
if (first_quote == std::string::npos) return "";
|
||||
const auto second_quote = line.find('"', first_quote + 1);
|
||||
if (second_quote == std::string::npos) return "";
|
||||
return line.substr(first_quote + 1, second_quote - first_quote - 1);
|
||||
}
|
||||
|
||||
void write_packet(int id, const std::string& meta_fields, const std::vector<uint8_t>& payload) {
|
||||
std::cout << "{\"id\":" << id << ",\"ok\":true" << meta_fields
|
||||
<< ",\"payloadBytes\":" << payload.size() << "}\n";
|
||||
std::cout.flush();
|
||||
if (!payload.empty()) {
|
||||
std::cout.write(reinterpret_cast<const char*>(payload.data()), static_cast<std::streamsize>(payload.size()));
|
||||
std::cout.flush();
|
||||
}
|
||||
}
|
||||
|
||||
void write_error(int id, const std::string& message) {
|
||||
std::cout << "{\"id\":" << id << ",\"ok\":false,\"error\":\""
|
||||
<< json_escape(message) << "\",\"payloadBytes\":0}\n";
|
||||
std::cout.flush();
|
||||
}
|
||||
|
||||
void depth_callback(freenect_device*, void* depth_data, uint32_t) {
|
||||
const auto* depth = static_cast<const uint16_t*>(depth_data);
|
||||
std::lock_guard<std::mutex> lock(cache.mutex);
|
||||
std::memcpy(cache.depth.data(), depth, kDepthPixels * sizeof(uint16_t));
|
||||
uint32_t valid_depth_pixels = 0;
|
||||
for (int index = 0; index < kDepthPixels; index += 1) {
|
||||
if (depth[index] != 0) valid_depth_pixels += 1;
|
||||
}
|
||||
cache.has_depth = true;
|
||||
cache.valid_depth_pixels = valid_depth_pixels;
|
||||
cache.depth_at_ms = now_ms();
|
||||
cache.depth_frames += 1;
|
||||
cache.cv.notify_all();
|
||||
}
|
||||
|
||||
void video_callback(freenect_device* device, void* rgb_data, uint32_t) {
|
||||
const auto* rgb = static_cast<const uint8_t*>(rgb_data);
|
||||
std::lock_guard<std::mutex> lock(cache.mutex);
|
||||
std::memcpy(cache.rgb.data(), rgb, kRgbBytes);
|
||||
cache.has_rgb = true;
|
||||
cache.rgb_at_ms = now_ms();
|
||||
cache.rgb_frames += 1;
|
||||
|
||||
// Hand libfreenect a replacement immediately. Node reads only from the
|
||||
// independent cache copy, which avoids a use-after-callback race.
|
||||
freenect_set_video_buffer(device, video_back_buffer.data());
|
||||
cache.cv.notify_all();
|
||||
}
|
||||
|
||||
bool wait_for_frames(bool need_rgb, bool need_depth, std::string* error) {
|
||||
std::unique_lock<std::mutex> lock(cache.mutex);
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kCommandFrameWaitMs);
|
||||
const auto ready = [&]() {
|
||||
const uint64_t now = now_ms();
|
||||
const bool rgb_ok = !need_rgb || (cache.has_rgb && now - cache.rgb_at_ms <= kFrameStaleMs);
|
||||
const bool depth_ok =
|
||||
!need_depth ||
|
||||
(cache.has_depth && cache.valid_depth_pixels > 0 && now - cache.depth_at_ms <= kFrameStaleMs);
|
||||
return rgb_ok && depth_ok;
|
||||
};
|
||||
|
||||
while (!ready()) {
|
||||
if (cache.cv.wait_until(lock, deadline) == std::cv_status::timeout) break;
|
||||
}
|
||||
if (ready()) return true;
|
||||
|
||||
const uint64_t now = now_ms();
|
||||
std::ostringstream msg;
|
||||
msg << "kinect frames unavailable";
|
||||
if (need_rgb) {
|
||||
msg << " rgb=" << (cache.has_rgb ? std::to_string(now - cache.rgb_at_ms) + "ms old" : "missing");
|
||||
}
|
||||
if (need_depth) {
|
||||
msg << " depth="
|
||||
<< (cache.has_depth
|
||||
? std::to_string(now - cache.depth_at_ms) + "ms old valid=" +
|
||||
std::to_string(cache.valid_depth_pixels)
|
||||
: "missing");
|
||||
}
|
||||
*error = msg.str();
|
||||
return false;
|
||||
}
|
||||
|
||||
void handle_color(int id) {
|
||||
std::string error;
|
||||
if (!wait_for_frames(true, false, &error)) {
|
||||
write_error(id, error);
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> payload;
|
||||
uint64_t age = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(cache.mutex);
|
||||
payload = cache.rgb;
|
||||
age = now_ms() - cache.rgb_at_ms;
|
||||
}
|
||||
|
||||
std::ostringstream meta;
|
||||
meta << ",\"kind\":\"color\",\"format\":\"rgb24\",\"width\":" << kWidth
|
||||
<< ",\"height\":" << kHeight << ",\"frameAgeMs\":" << age;
|
||||
write_packet(id, meta.str(), payload);
|
||||
}
|
||||
|
||||
void handle_pointcloud(int id) {
|
||||
std::string error;
|
||||
if (!wait_for_frames(true, true, &error)) {
|
||||
write_error(id, error);
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> rgb;
|
||||
std::vector<uint16_t> depth;
|
||||
uint64_t rgb_age = 0;
|
||||
uint64_t depth_age = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(cache.mutex);
|
||||
rgb = cache.rgb;
|
||||
depth = cache.depth;
|
||||
const uint64_t now = now_ms();
|
||||
rgb_age = now - cache.rgb_at_ms;
|
||||
depth_age = now - cache.depth_at_ms;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> payload;
|
||||
payload.reserve(kDepthPixels * 16);
|
||||
uint32_t point_count = 0;
|
||||
const float focal_x = 525.0f;
|
||||
const float focal_y = 525.0f;
|
||||
const float center_x = static_cast<float>(kWidth - 1) / 2.0f;
|
||||
const float center_y = static_cast<float>(kHeight - 1) / 2.0f;
|
||||
|
||||
auto append_float = [&](float value) {
|
||||
uint8_t bytes[sizeof(float)];
|
||||
std::memcpy(bytes, &value, sizeof(float));
|
||||
payload.insert(payload.end(), bytes, bytes + sizeof(float));
|
||||
};
|
||||
|
||||
// Registered depth aligns with RGB, so each valid depth pixel can become a
|
||||
// colored point without an additional calibration lookup. Invalid zero-depth
|
||||
// pixels are skipped to keep the payload and browser point count smaller.
|
||||
for (int y = 0; y < kHeight; y += 1) {
|
||||
for (int x = 0; x < kWidth; x += 1) {
|
||||
const int idx = y * kWidth + x;
|
||||
const uint16_t z_mm = depth[idx];
|
||||
if (z_mm == 0) continue;
|
||||
const float z = static_cast<float>(z_mm) / 1000.0f;
|
||||
const float world_x = (static_cast<float>(x) - center_x) * z / focal_x;
|
||||
const float world_y = -(static_cast<float>(y) - center_y) * z / focal_y;
|
||||
append_float(world_x);
|
||||
append_float(world_y);
|
||||
append_float(z);
|
||||
payload.push_back(rgb[idx * 3 + 0]);
|
||||
payload.push_back(rgb[idx * 3 + 1]);
|
||||
payload.push_back(rgb[idx * 3 + 2]);
|
||||
payload.push_back(255);
|
||||
point_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::ostringstream meta;
|
||||
meta << ",\"kind\":\"pointCloud\",\"format\":\"xyzrgb-f32-u8\",\"width\":" << kWidth
|
||||
<< ",\"height\":" << kHeight << ",\"pointCount\":" << point_count
|
||||
<< ",\"rgbFrameAgeMs\":" << rgb_age << ",\"depthFrameAgeMs\":" << depth_age;
|
||||
write_packet(id, meta.str(), payload);
|
||||
}
|
||||
|
||||
void handle_status(int id) {
|
||||
bool has_rgb = false;
|
||||
bool has_depth = false;
|
||||
uint64_t rgb_age = 0;
|
||||
uint64_t depth_age = 0;
|
||||
uint64_t rgb_frames = 0;
|
||||
uint64_t depth_frames = 0;
|
||||
uint32_t valid_depth_pixels = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(cache.mutex);
|
||||
const uint64_t now = now_ms();
|
||||
has_rgb = cache.has_rgb;
|
||||
has_depth = cache.has_depth;
|
||||
rgb_age = has_rgb ? now - cache.rgb_at_ms : 0;
|
||||
depth_age = has_depth ? now - cache.depth_at_ms : 0;
|
||||
rgb_frames = cache.rgb_frames;
|
||||
depth_frames = cache.depth_frames;
|
||||
valid_depth_pixels = cache.valid_depth_pixels;
|
||||
}
|
||||
|
||||
std::ostringstream meta;
|
||||
meta << ",\"kind\":\"status\",\"hasRgb\":" << (has_rgb ? "true" : "false")
|
||||
<< ",\"hasDepth\":" << (has_depth ? "true" : "false")
|
||||
<< ",\"rgbFrameAgeMs\":" << (has_rgb ? std::to_string(rgb_age) : "null")
|
||||
<< ",\"depthFrameAgeMs\":" << (has_depth ? std::to_string(depth_age) : "null")
|
||||
<< ",\"rgbFrames\":" << rgb_frames << ",\"depthFrames\":" << depth_frames
|
||||
<< ",\"validDepthPixels\":" << valid_depth_pixels;
|
||||
write_packet(id, meta.str(), {});
|
||||
}
|
||||
|
||||
bool init_freenect() {
|
||||
log_step("initializing libfreenect");
|
||||
const int init_result = freenect_init(&freenect_ctx, nullptr);
|
||||
if (init_result < 0) {
|
||||
log_step("freenect_init failed with result " + std::to_string(init_result));
|
||||
return false;
|
||||
}
|
||||
freenect_set_log_level(freenect_ctx, FREENECT_LOG_WARNING);
|
||||
|
||||
// Use only the camera subdevice for this first app integration. The probe
|
||||
// showed the LED/motor sibling can error while camera/depth still works, so
|
||||
// startup should not depend on motor access.
|
||||
freenect_select_subdevices(
|
||||
freenect_ctx,
|
||||
static_cast<freenect_device_flags>(FREENECT_DEVICE_CAMERA));
|
||||
|
||||
const int device_count = freenect_num_devices(freenect_ctx);
|
||||
log_step("device count: " + std::to_string(device_count));
|
||||
if (device_count < 1) {
|
||||
log_step("no kinect devices found");
|
||||
return false;
|
||||
}
|
||||
|
||||
const int open_result = freenect_open_device(freenect_ctx, &freenect_dev, 0);
|
||||
if (open_result < 0) {
|
||||
log_step("freenect_open_device failed with result " + std::to_string(open_result));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool start_streams() {
|
||||
log_step("starting camera/depth streams");
|
||||
freenect_set_depth_callback(freenect_dev, depth_callback);
|
||||
freenect_set_video_callback(freenect_dev, video_callback);
|
||||
freenect_set_video_buffer(freenect_dev, video_back_buffer.data());
|
||||
|
||||
if (freenect_set_video_mode(
|
||||
freenect_dev,
|
||||
freenect_find_video_mode(FREENECT_RESOLUTION_MEDIUM, FREENECT_VIDEO_RGB)) < 0) {
|
||||
log_step("freenect_set_video_mode failed");
|
||||
return false;
|
||||
}
|
||||
if (freenect_set_depth_mode(
|
||||
freenect_dev,
|
||||
freenect_find_depth_mode(FREENECT_RESOLUTION_MEDIUM, FREENECT_DEPTH_REGISTERED)) < 0) {
|
||||
log_step("freenect_set_depth_mode failed");
|
||||
return false;
|
||||
}
|
||||
if (freenect_start_depth(freenect_dev) < 0) {
|
||||
log_step("freenect_start_depth failed");
|
||||
return false;
|
||||
}
|
||||
if (freenect_start_video(freenect_dev) < 0) {
|
||||
log_step("freenect_start_video failed");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void event_loop() {
|
||||
while (running) {
|
||||
timeval timeout;
|
||||
timeout.tv_sec = 0;
|
||||
timeout.tv_usec = 100000;
|
||||
const int result = freenect_process_events_timeout(freenect_ctx, &timeout);
|
||||
if (result < 0) {
|
||||
log_step("libfreenect event loop failed with result " + std::to_string(result));
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void shutdown_freenect() {
|
||||
running = false;
|
||||
if (freenect_dev) {
|
||||
freenect_stop_depth(freenect_dev);
|
||||
freenect_stop_video(freenect_dev);
|
||||
freenect_close_device(freenect_dev);
|
||||
freenect_dev = nullptr;
|
||||
}
|
||||
if (freenect_ctx) {
|
||||
freenect_shutdown(freenect_ctx);
|
||||
freenect_ctx = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
if (!init_freenect() || !start_streams()) {
|
||||
shutdown_freenect();
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::thread worker(event_loop);
|
||||
std::string line;
|
||||
while (running && std::getline(std::cin, line)) {
|
||||
const int id = parse_id(line);
|
||||
const std::string mode = parse_mode(line);
|
||||
if (mode == "color") {
|
||||
handle_color(id);
|
||||
} else if (mode == "pointcloud") {
|
||||
handle_pointcloud(id);
|
||||
} else if (mode == "status") {
|
||||
handle_status(id);
|
||||
} else {
|
||||
write_error(id, "unknown kinect command");
|
||||
}
|
||||
}
|
||||
|
||||
running = false;
|
||||
if (worker.joinable()) {
|
||||
worker.join();
|
||||
}
|
||||
shutdown_freenect();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Kinect Socket Gateway
|
||||
// Purpose: Registers browser-facing Kinect snapshot controls and broadcasts shared Kinect frames over Socket.IO.
|
||||
// Scope: Owns authorization, global capture cooldown, request serialization, cached-frame replay, and session-status events.
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('kinectService');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
||||
|
||||
const DEFAULT_CAPTURE_COOLDOWN_MS = 10000;
|
||||
|
||||
const kinectEvents = new EventEmitter();
|
||||
|
||||
function passesMode(socket) {
|
||||
const mode = getMode();
|
||||
if (mode === MODES.LOCKDOWN) return isLockdownAdmin(socket);
|
||||
if (mode === MODES.ADMIN) {
|
||||
const role = getRole(socket);
|
||||
return role === 'spectator' || isAdmin(socket);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeKinectConfig(config = {}) {
|
||||
const raw = config.kinect || {};
|
||||
return {
|
||||
enabled: Boolean(raw.enabled),
|
||||
captureCooldownMs:
|
||||
Number.isFinite(Number(raw.captureCooldownMs)) && Number(raw.captureCooldownMs) >= 0
|
||||
? Number(raw.captureCooldownMs)
|
||||
: DEFAULT_CAPTURE_COOLDOWN_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function registerKinectSocketGateway({ config, hardware }) {
|
||||
const settings = normalizeKinectConfig(config);
|
||||
let captureCooldownUntil = 0;
|
||||
let busy = false;
|
||||
let lastAction = null;
|
||||
let lastError = null;
|
||||
let lastPointCloud = null;
|
||||
let lastColorImage = null;
|
||||
|
||||
function buildStatus(extra = {}) {
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
// Availability starts optimistic when enabled. A real worker/capture
|
||||
// failure changes lastError, and session sync then makes the UI show that
|
||||
// the camera path needs attention.
|
||||
available: settings.enabled && !lastError,
|
||||
busy,
|
||||
captureCooldownUntil,
|
||||
lastAction,
|
||||
lastError,
|
||||
hasPointCloud: Boolean(lastPointCloud?.buffer),
|
||||
hasColorImage: Boolean(lastColorImage?.buffer),
|
||||
lastPointCloudTs: lastPointCloud?.meta?.ts || null,
|
||||
lastColorImageTs: lastColorImage?.meta?.ts || null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function emitStatusChange(extra = {}) {
|
||||
// The browser already receives session-wide status through sessionService.
|
||||
// Emitting a local service event keeps Kinect-specific code from importing
|
||||
// sessionService directly and creating a require cycle.
|
||||
kinectEvents.emit('change', buildStatus(extra));
|
||||
}
|
||||
|
||||
function sendCachedFrames(socket) {
|
||||
// Cached-frame replay gives newly opened tabs the latest room snapshot
|
||||
// without starting a new Kinect capture or spending upload continuously.
|
||||
if (lastPointCloud?.buffer) {
|
||||
socket.emit('kinect:pointCloudFrame', lastPointCloud.meta, lastPointCloud.buffer);
|
||||
}
|
||||
if (lastColorImage?.buffer) {
|
||||
socket.emit('kinect:colorFrame', lastColorImage.meta, lastColorImage.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectDisabled() {
|
||||
if (!settings.enabled) {
|
||||
return { error: 'kinect service is disabled' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rejectUnauthorized(socket) {
|
||||
if (!passesMode(socket)) {
|
||||
return { error: 'not authorized for kinect controls' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rejectCaptureCooldown() {
|
||||
const now = Date.now();
|
||||
if (captureCooldownUntil > now) {
|
||||
return {
|
||||
error: 'kinect capture cooldown active',
|
||||
retryAfterMs: captureCooldownUntil - now,
|
||||
captureCooldownUntil,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleCapture(socket, kind, cb) {
|
||||
const disabled = rejectDisabled();
|
||||
const unauthorized = rejectUnauthorized(socket);
|
||||
const cooldown = rejectCaptureCooldown();
|
||||
if (disabled || unauthorized || cooldown) {
|
||||
const response = disabled || unauthorized || cooldown;
|
||||
cb(response);
|
||||
emitStatusChange();
|
||||
return;
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
cb({ error: 'kinect capture already running' });
|
||||
emitStatusChange();
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
lastAction = kind;
|
||||
lastError = null;
|
||||
// The cooldown starts when the server accepts the request. That makes every
|
||||
// connected browser disable capture controls immediately, instead of waiting
|
||||
// for the worker to finish serializing a multi-megabyte point cloud.
|
||||
captureCooldownUntil = Date.now() + settings.captureCooldownMs;
|
||||
cb({ ok: true, captureCooldownUntil });
|
||||
emitStatusChange();
|
||||
|
||||
try {
|
||||
const capture =
|
||||
kind === 'pointCloud'
|
||||
? await hardware.capturePointCloud()
|
||||
: await hardware.captureColorImage();
|
||||
const meta = {
|
||||
...capture.meta,
|
||||
ts: Date.now(),
|
||||
requestedBy: socket.id,
|
||||
};
|
||||
if (kind === 'pointCloud') {
|
||||
lastPointCloud = { meta, buffer: capture.buffer };
|
||||
io.emit('kinect:pointCloudFrame', meta, capture.buffer);
|
||||
} else {
|
||||
lastColorImage = { meta, buffer: capture.buffer };
|
||||
io.emit('kinect:colorFrame', meta, capture.buffer);
|
||||
}
|
||||
logger.info('Kinect capture broadcast', {
|
||||
kind,
|
||||
bytes: capture.buffer?.length || capture.buffer?.byteLength || 0,
|
||||
socketId: socket.id,
|
||||
});
|
||||
} catch (err) {
|
||||
lastError = err.message || 'kinect capture failed';
|
||||
logger.warn('Kinect capture failed', { kind, socketId: socket.id, err: lastError });
|
||||
} finally {
|
||||
busy = false;
|
||||
emitStatusChange();
|
||||
}
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
sendCachedFrames(socket);
|
||||
|
||||
socket.on('kinect:requestCachedFrames', (_payload = {}, cb = () => {}) => {
|
||||
sendCachedFrames(socket);
|
||||
cb({ ok: true });
|
||||
});
|
||||
|
||||
socket.on('kinect:requestPointCloud', (_payload = {}, cb = () => {}) => {
|
||||
handleCapture(socket, 'pointCloud', cb);
|
||||
});
|
||||
|
||||
socket.on('kinect:requestColorImage', (_payload = {}, cb = () => {}) => {
|
||||
handleCapture(socket, 'colorImage', cb);
|
||||
});
|
||||
});
|
||||
|
||||
if (settings.enabled) {
|
||||
try {
|
||||
// Starting the worker at service startup gives the callback path time to
|
||||
// warm up, while clients still control when bytes are uploaded to them.
|
||||
hardware.startWorker();
|
||||
} catch (err) {
|
||||
lastError = err.message || 'kinect worker failed to start';
|
||||
logger.warn('Kinect worker startup failed', { err: lastError });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getState: buildStatus,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerKinectSocketGateway,
|
||||
kinectEvents,
|
||||
};
|
||||
@@ -13,6 +13,7 @@ const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
||||
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
|
||||
const { getState: getLiftState, liftEvents } = require('../liftService');
|
||||
const { getState: getKinectState, kinectEvents } = require('../kinectService');
|
||||
const { getVoteStatus: getOverseerVoteStatus } = require('../overseerControlService');
|
||||
const { getNickname, nicknameEvents } = require('../nicknameService');
|
||||
const {
|
||||
@@ -104,6 +105,7 @@ function buildSession(socket) {
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
neato: getNeatoState(),
|
||||
lift: getLiftState(),
|
||||
kinect: getKinectState(),
|
||||
replay: getReplayState(),
|
||||
replaySources: getReplaySources(socket),
|
||||
health: getHealthSnapshot(),
|
||||
@@ -288,6 +290,11 @@ liftEvents.on('update', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
kinectEvents.on('change', () => {
|
||||
logger.info('Kinect state change; syncing all clients');
|
||||
syncAll();
|
||||
});
|
||||
|
||||
replayEvents.on('update', () => {
|
||||
logger.info('Replay cooldown updated; syncing all clients');
|
||||
syncAll();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Keep probe build products out of source control. The probe is meant to be
|
||||
# rebuilt locally after libfreenect packages are installed, and captured frames
|
||||
# are machine-specific diagnostic artifacts rather than repo assets.
|
||||
/kinect_probe
|
||||
/kinect-probe-output/
|
||||
@@ -0,0 +1,34 @@
|
||||
CXX ?= g++
|
||||
PKG_CONFIG ?= pkg-config
|
||||
|
||||
# This probe is intentionally tiny and native. It links straight against
|
||||
# libfreenect so it can be compared with freenect-regview without any Node,
|
||||
# Python, Socket.IO, or browser code in the way.
|
||||
CXXFLAGS ?= -O2 -std=c++17 -Wall -Wextra -pedantic
|
||||
CPPFLAGS += $(shell $(PKG_CONFIG) --cflags libfreenect 2>/dev/null)
|
||||
LDLIBS += $(shell $(PKG_CONFIG) --libs libfreenect 2>/dev/null) -pthread
|
||||
|
||||
# Fedora's libfreenect package may not ship a pkg-config file in every install
|
||||
# shape, so keep the common include/library fallback explicit and boring.
|
||||
ifeq ($(strip $(CPPFLAGS)),)
|
||||
CPPFLAGS += -I/usr/include/libfreenect -I/usr/include/libusb-1.0
|
||||
endif
|
||||
ifeq ($(filter -lfreenect,$(LDLIBS)),)
|
||||
LDLIBS += -lfreenect
|
||||
endif
|
||||
|
||||
TARGET := kinect_probe
|
||||
SRC := kinect_probe.cpp
|
||||
|
||||
.PHONY: all clean run
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): $(SRC)
|
||||
$(CXX) $(CXXFLAGS) $(CPPFLAGS) -o $@ $< $(LDLIBS)
|
||||
|
||||
run: $(TARGET)
|
||||
./$(TARGET)
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
@@ -0,0 +1,33 @@
|
||||
# kinect probe
|
||||
|
||||
This is a standalone Kinect v1/libfreenect probe. It does not load the rover
|
||||
server, does not open sockets, and does not keep running after the capture
|
||||
attempt finishes.
|
||||
|
||||
Build it:
|
||||
|
||||
```bash
|
||||
cd server/tools/kinect-probe
|
||||
make
|
||||
```
|
||||
|
||||
Run it:
|
||||
|
||||
```bash
|
||||
./kinect_probe
|
||||
```
|
||||
|
||||
By default it writes into `./kinect-probe-output`:
|
||||
|
||||
- `kinect-color.ppm`: raw RGB color frame
|
||||
- `kinect-depth.pgm`: registered 16-bit depth frame
|
||||
- `kinect-status.json`: startup/capture timings and frame counters
|
||||
|
||||
You can choose a different output directory:
|
||||
|
||||
```bash
|
||||
./kinect_probe /tmp/kinect-probe
|
||||
```
|
||||
|
||||
The probe prints every important libfreenect startup step to stderr so failures
|
||||
show the exact point where the native path diverges from `freenect-regview`.
|
||||
@@ -0,0 +1,450 @@
|
||||
// Standalone Kinect v1 probe.
|
||||
//
|
||||
// Why this exists:
|
||||
// The rover app previously mixed several concerns at once: Kinect startup,
|
||||
// frame capture, JPEG/point-cloud conversion, Socket.IO fan-out, and React UI.
|
||||
// This probe deliberately removes everything except "can libfreenect produce
|
||||
// one RGB frame and one registered depth frame on this machine?". If this
|
||||
// succeeds, the app integration can reuse the proven native path. If it fails,
|
||||
// the stderr step log tells us exactly which libfreenect call failed.
|
||||
|
||||
#include <libfreenect.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <sys/time.h>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kWidth = 640;
|
||||
constexpr int kHeight = 480;
|
||||
constexpr int kRgbBytes = kWidth * kHeight * 3;
|
||||
constexpr int kDepthPixels = kWidth * kHeight;
|
||||
constexpr int kCaptureTimeoutMs = 12000;
|
||||
|
||||
struct CaptureState {
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
|
||||
// Each callback copies into these vectors immediately. Keeping our own copy
|
||||
// matters because libfreenect reuses its callback buffers after the callback
|
||||
// returns, so writing files directly from callback memory would be racy.
|
||||
std::vector<uint8_t> rgb = std::vector<uint8_t>(kRgbBytes);
|
||||
std::vector<uint16_t> depth = std::vector<uint16_t>(kDepthPixels);
|
||||
|
||||
bool has_rgb = false;
|
||||
bool has_depth = false;
|
||||
uint64_t rgb_frames = 0;
|
||||
uint64_t depth_frames = 0;
|
||||
uint64_t first_rgb_ms = 0;
|
||||
uint64_t first_depth_ms = 0;
|
||||
};
|
||||
|
||||
struct ProbeStats {
|
||||
uint64_t start_ms = 0;
|
||||
uint64_t init_ms = 0;
|
||||
int device_count = 0;
|
||||
bool opened = false;
|
||||
bool depth_started = false;
|
||||
bool video_started = false;
|
||||
bool wrote_color = false;
|
||||
bool wrote_depth = false;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
CaptureState state;
|
||||
ProbeStats stats;
|
||||
std::atomic<bool> running{true};
|
||||
freenect_context* freenect_ctx = nullptr;
|
||||
freenect_device* freenect_dev = nullptr;
|
||||
|
||||
// libfreenect video streaming uses caller-provided buffers. The callback hands
|
||||
// one replacement buffer back to libfreenect after copying the just-received
|
||||
// frame into CaptureState. This mirrors the simple double-buffer style used by
|
||||
// libfreenect examples, but only one replacement is enough for this one-shot
|
||||
// diagnostic tool because we are not trying to render every frame.
|
||||
std::vector<uint8_t> video_back_buffer(kRgbBytes);
|
||||
|
||||
uint64_t now_ms() {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
void log_step(const std::string& message) {
|
||||
std::cerr << "[kinect-probe] " << message << "\n";
|
||||
}
|
||||
|
||||
std::string escape_json(const std::string& value) {
|
||||
std::ostringstream out;
|
||||
for (const char ch : value) {
|
||||
switch (ch) {
|
||||
case '\\':
|
||||
out << "\\\\";
|
||||
break;
|
||||
case '"':
|
||||
out << "\\\"";
|
||||
break;
|
||||
case '\n':
|
||||
out << "\\n";
|
||||
break;
|
||||
case '\r':
|
||||
out << "\\r";
|
||||
break;
|
||||
case '\t':
|
||||
out << "\\t";
|
||||
break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(ch) < 0x20) {
|
||||
out << "\\u" << std::hex << std::setw(4) << std::setfill('0')
|
||||
<< static_cast<int>(static_cast<unsigned char>(ch));
|
||||
} else {
|
||||
out << ch;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
void depth_callback(freenect_device*, void* depth_data, uint32_t) {
|
||||
const auto* depth = static_cast<const uint16_t*>(depth_data);
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
|
||||
// Registered depth lines up with the RGB image, so preserving the full 16-bit
|
||||
// millimeter-ish values gives us a useful artifact for later point-cloud work.
|
||||
std::memcpy(state.depth.data(), depth, kDepthPixels * sizeof(uint16_t));
|
||||
state.depth_frames += 1;
|
||||
if (!state.has_depth) {
|
||||
state.has_depth = true;
|
||||
state.first_depth_ms = now_ms();
|
||||
log_step("first registered depth frame received");
|
||||
}
|
||||
state.cv.notify_all();
|
||||
}
|
||||
|
||||
void video_callback(freenect_device* device, void* rgb_data, uint32_t) {
|
||||
const auto* rgb = static_cast<const uint8_t*>(rgb_data);
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
|
||||
// The probe writes a PPM so there is no encoder dependency and no chance that
|
||||
// a JPEG/PNG library hides whether raw Kinect RGB data was actually received.
|
||||
std::memcpy(state.rgb.data(), rgb, kRgbBytes);
|
||||
state.rgb_frames += 1;
|
||||
if (!state.has_rgb) {
|
||||
state.has_rgb = true;
|
||||
state.first_rgb_ms = now_ms();
|
||||
log_step("first rgb frame received");
|
||||
}
|
||||
|
||||
freenect_set_video_buffer(device, video_back_buffer.data());
|
||||
state.cv.notify_all();
|
||||
}
|
||||
|
||||
bool write_color_ppm(const std::filesystem::path& path) {
|
||||
std::vector<uint8_t> rgb;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
rgb = state.rgb;
|
||||
}
|
||||
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
if (!out) {
|
||||
stats.error = "could not open color output file";
|
||||
return false;
|
||||
}
|
||||
|
||||
// P6 is the simplest standard RGB image format: ASCII header, then packed
|
||||
// 8-bit RGB bytes. It is intentionally chosen here to avoid adding image
|
||||
// library dependencies to a hardware probe.
|
||||
out << "P6\n" << kWidth << " " << kHeight << "\n255\n";
|
||||
out.write(reinterpret_cast<const char*>(rgb.data()), static_cast<std::streamsize>(rgb.size()));
|
||||
return static_cast<bool>(out);
|
||||
}
|
||||
|
||||
bool write_depth_pgm(const std::filesystem::path& path) {
|
||||
std::vector<uint16_t> depth;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
depth = state.depth;
|
||||
}
|
||||
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
if (!out) {
|
||||
stats.error = "could not open depth output file";
|
||||
return false;
|
||||
}
|
||||
|
||||
// PGM with max value above 255 stores two bytes per pixel. The Netpbm spec
|
||||
// expects big-endian byte order, so write the high byte first even though the
|
||||
// host machine is probably little-endian. Keeping 16-bit depth avoids losing
|
||||
// range information before we know the camera path is stable.
|
||||
out << "P5\n" << kWidth << " " << kHeight << "\n10000\n";
|
||||
for (const uint16_t value : depth) {
|
||||
const uint16_t clamped = value > 10000 ? 10000 : value;
|
||||
const char high = static_cast<char>((clamped >> 8) & 0xff);
|
||||
const char low = static_cast<char>(clamped & 0xff);
|
||||
out.write(&high, 1);
|
||||
out.write(&low, 1);
|
||||
}
|
||||
return static_cast<bool>(out);
|
||||
}
|
||||
|
||||
bool write_status_json(const std::filesystem::path& path) {
|
||||
uint64_t rgb_frames = 0;
|
||||
uint64_t depth_frames = 0;
|
||||
uint64_t first_rgb_delta_ms = 0;
|
||||
uint64_t first_depth_delta_ms = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
rgb_frames = state.rgb_frames;
|
||||
depth_frames = state.depth_frames;
|
||||
first_rgb_delta_ms = state.first_rgb_ms ? state.first_rgb_ms - stats.start_ms : 0;
|
||||
first_depth_delta_ms = state.first_depth_ms ? state.first_depth_ms - stats.start_ms : 0;
|
||||
}
|
||||
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
if (!out) {
|
||||
std::cerr << "[kinect-probe] could not open status output file: " << path << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
// The status file is meant to make terminal logs less fragile. If the user
|
||||
// pastes only the JSON later, it still carries the important timing and frame
|
||||
// count facts from the run.
|
||||
out << "{\n"
|
||||
<< " \"deviceCount\": " << stats.device_count << ",\n"
|
||||
<< " \"opened\": " << (stats.opened ? "true" : "false") << ",\n"
|
||||
<< " \"depthStarted\": " << (stats.depth_started ? "true" : "false") << ",\n"
|
||||
<< " \"videoStarted\": " << (stats.video_started ? "true" : "false") << ",\n"
|
||||
<< " \"rgbFrames\": " << rgb_frames << ",\n"
|
||||
<< " \"depthFrames\": " << depth_frames << ",\n"
|
||||
<< " \"firstRgbMs\": " << first_rgb_delta_ms << ",\n"
|
||||
<< " \"firstDepthMs\": " << first_depth_delta_ms << ",\n"
|
||||
<< " \"wroteColor\": " << (stats.wrote_color ? "true" : "false") << ",\n"
|
||||
<< " \"wroteDepth\": " << (stats.wrote_depth ? "true" : "false") << ",\n"
|
||||
<< " \"error\": \"" << escape_json(stats.error) << "\"\n"
|
||||
<< "}\n";
|
||||
return static_cast<bool>(out);
|
||||
}
|
||||
|
||||
void cleanup_freenect() {
|
||||
running = false;
|
||||
if (freenect_dev) {
|
||||
log_step("closing kinect device");
|
||||
freenect_stop_depth(freenect_dev);
|
||||
freenect_stop_video(freenect_dev);
|
||||
freenect_close_device(freenect_dev);
|
||||
freenect_dev = nullptr;
|
||||
}
|
||||
if (freenect_ctx) {
|
||||
log_step("shutting down libfreenect");
|
||||
freenect_shutdown(freenect_ctx);
|
||||
freenect_ctx = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool init_freenect() {
|
||||
log_step("initializing libfreenect");
|
||||
const int init_result = freenect_init(&freenect_ctx, nullptr);
|
||||
if (init_result < 0) {
|
||||
stats.error = "freenect_init failed with result " + std::to_string(init_result);
|
||||
return false;
|
||||
}
|
||||
stats.init_ms = now_ms() - stats.start_ms;
|
||||
|
||||
// Use DEBUG while probing so libfreenect prints the low-level USB reason near
|
||||
// the high-level step log. This is intentionally noisy because the probe is
|
||||
// not a production service.
|
||||
freenect_set_log_level(freenect_ctx, FREENECT_LOG_DEBUG);
|
||||
|
||||
// The first app integration should not touch the motor/LED/audio siblings.
|
||||
// Your working freenect-regview run proves the camera/depth path can work
|
||||
// even while LED/motor operations are unhappy, so this probe selects only the
|
||||
// camera subdevice and leaves tilt for a later isolated test.
|
||||
log_step("selecting camera subdevice only");
|
||||
freenect_select_subdevices(
|
||||
freenect_ctx,
|
||||
static_cast<freenect_device_flags>(FREENECT_DEVICE_CAMERA));
|
||||
|
||||
stats.device_count = freenect_num_devices(freenect_ctx);
|
||||
log_step("device count: " + std::to_string(stats.device_count));
|
||||
if (stats.device_count < 1) {
|
||||
stats.error = "no kinect devices found";
|
||||
return false;
|
||||
}
|
||||
|
||||
log_step("opening kinect device 0");
|
||||
const int open_result = freenect_open_device(freenect_ctx, &freenect_dev, 0);
|
||||
if (open_result < 0) {
|
||||
stats.error = "freenect_open_device failed with result " + std::to_string(open_result);
|
||||
return false;
|
||||
}
|
||||
stats.opened = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool start_streams() {
|
||||
log_step("configuring callbacks and stream modes");
|
||||
freenect_set_depth_callback(freenect_dev, depth_callback);
|
||||
freenect_set_video_callback(freenect_dev, video_callback);
|
||||
freenect_set_video_buffer(freenect_dev, video_back_buffer.data());
|
||||
|
||||
const freenect_frame_mode video_mode =
|
||||
freenect_find_video_mode(FREENECT_RESOLUTION_MEDIUM, FREENECT_VIDEO_RGB);
|
||||
const freenect_frame_mode depth_mode =
|
||||
freenect_find_depth_mode(FREENECT_RESOLUTION_MEDIUM, FREENECT_DEPTH_REGISTERED);
|
||||
|
||||
log_step("applying rgb video mode");
|
||||
if (freenect_set_video_mode(freenect_dev, video_mode) < 0) {
|
||||
stats.error = "freenect_set_video_mode failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
log_step("applying registered depth mode");
|
||||
if (freenect_set_depth_mode(freenect_dev, depth_mode) < 0) {
|
||||
stats.error = "freenect_set_depth_mode failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start depth before video to match the previous worker and common
|
||||
// libfreenect examples. The step log makes it easy to reverse this later if
|
||||
// freenect-regview's exact order turns out to matter on this machine.
|
||||
log_step("starting depth stream");
|
||||
const int depth_result = freenect_start_depth(freenect_dev);
|
||||
if (depth_result < 0) {
|
||||
stats.error = "freenect_start_depth failed with result " + std::to_string(depth_result);
|
||||
return false;
|
||||
}
|
||||
stats.depth_started = true;
|
||||
|
||||
log_step("starting video stream");
|
||||
const int video_result = freenect_start_video(freenect_dev);
|
||||
if (video_result < 0) {
|
||||
stats.error = "freenect_start_video failed with result " + std::to_string(video_result);
|
||||
return false;
|
||||
}
|
||||
stats.video_started = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void event_loop_until_ready() {
|
||||
log_step("processing libfreenect events until both frame types arrive");
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kCaptureTimeoutMs);
|
||||
|
||||
while (running && std::chrono::steady_clock::now() < deadline) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
if (state.has_rgb && state.has_depth) {
|
||||
log_step("both rgb and depth frames are available");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A short timeout keeps the one-shot probe responsive when the camera stops
|
||||
// talking. It also prevents a failed USB state from hanging the terminal.
|
||||
timeval timeout;
|
||||
timeout.tv_sec = 0;
|
||||
timeout.tv_usec = 100000;
|
||||
const int event_result = freenect_process_events_timeout(freenect_ctx, &timeout);
|
||||
if (event_result < 0) {
|
||||
stats.error = "freenect_process_events_timeout failed with result " + std::to_string(event_result);
|
||||
log_step(stats.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uint64_t rgb_frames = 0;
|
||||
uint64_t depth_frames = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
rgb_frames = state.rgb_frames;
|
||||
depth_frames = state.depth_frames;
|
||||
}
|
||||
stats.error = "timed out waiting for frames; rgbFrames=" + std::to_string(rgb_frames) +
|
||||
" depthFrames=" + std::to_string(depth_frames);
|
||||
log_step(stats.error);
|
||||
}
|
||||
|
||||
bool ensure_output_dir(const std::filesystem::path& output_dir) {
|
||||
std::error_code err;
|
||||
std::filesystem::create_directories(output_dir, err);
|
||||
if (err) {
|
||||
stats.error = "could not create output directory: " + err.message();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool write_outputs(const std::filesystem::path& output_dir) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
if (!state.has_rgb || !state.has_depth) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const auto color_path = output_dir / "kinect-color.ppm";
|
||||
const auto depth_path = output_dir / "kinect-depth.pgm";
|
||||
|
||||
log_step("writing color image: " + color_path.string());
|
||||
stats.wrote_color = write_color_ppm(color_path);
|
||||
if (!stats.wrote_color) {
|
||||
return false;
|
||||
}
|
||||
|
||||
log_step("writing depth image: " + depth_path.string());
|
||||
stats.wrote_depth = write_depth_pgm(depth_path);
|
||||
return stats.wrote_depth;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
stats.start_ms = now_ms();
|
||||
const std::filesystem::path output_dir =
|
||||
argc > 1 ? std::filesystem::path(argv[1]) : std::filesystem::path("kinect-probe-output");
|
||||
|
||||
log_step("output directory: " + output_dir.string());
|
||||
if (!ensure_output_dir(output_dir)) {
|
||||
write_status_json(output_dir / "kinect-status.json");
|
||||
return 1;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
if (init_freenect() && start_streams()) {
|
||||
event_loop_until_ready();
|
||||
ok = write_outputs(output_dir);
|
||||
} else {
|
||||
log_step(stats.error);
|
||||
}
|
||||
|
||||
// Write status before shutdown so the status file records the frame counters
|
||||
// from the active device state, then close streams/devices so the terminal
|
||||
// returns with no background Kinect ownership left behind.
|
||||
write_status_json(output_dir / "kinect-status.json");
|
||||
cleanup_freenect();
|
||||
|
||||
if (ok) {
|
||||
log_step("probe completed successfully");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (stats.error.empty()) {
|
||||
stats.error = "probe failed before writing both output files";
|
||||
}
|
||||
log_step("probe failed: " + stats.error);
|
||||
return 1;
|
||||
}
|
||||
Generated
+8
-1
@@ -12,7 +12,8 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-joystick-component": "^6.2.1",
|
||||
"react-router-dom": "^7.9.6"
|
||||
"react-router-dom": "^7.9.6",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -3960,6 +3961,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.184.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
|
||||
+4
-3
@@ -14,7 +14,8 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-joystick-component": "^6.2.1",
|
||||
"react-router-dom": "^7.9.6"
|
||||
"react-router-dom": "^7.9.6",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -27,8 +28,8 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"socket.io-client": "4.8.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"vite": "^7.2.2",
|
||||
"socket.io-client": "4.8.3"
|
||||
"vite": "^7.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useControlSystem,
|
||||
} from './controls/index.js';
|
||||
import RoomCameraPanel from './components/RoomCameraPanel/index.jsx';
|
||||
import KinectPanel from './components/KinectPanel/index.jsx';
|
||||
import LogPanel from './components/LogPanel/index.jsx';
|
||||
import DriverVideo from './components/DriverVideo/index.jsx';
|
||||
import RightPaneTabs from './components/RightPaneTabs/index.jsx';
|
||||
@@ -167,6 +168,7 @@ function MobileFeatureTabs({
|
||||
<HomeAssistantControls />
|
||||
<ButtonBoxPanel />
|
||||
<RoomCameraPanel panelId={roomPanelId} />
|
||||
<KinectPanel />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="help">
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Kinect Point Cloud Viewer
|
||||
// Purpose: Renders a requested Kinect point-cloud frame as an interactive local Three.js scene.
|
||||
// Scope: Owns lazy-loading Three.js, converting binary point data into geometry, and pausing work off-screen.
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export default function PointCloudViewer({ frame }) {
|
||||
const hostRef = useRef(null);
|
||||
const rendererRef = useRef(null);
|
||||
const cameraRef = useRef(null);
|
||||
const sceneRef = useRef(null);
|
||||
const pointsRef = useRef(null);
|
||||
const controlsRef = useRef(null);
|
||||
const threeRef = useRef(null);
|
||||
const visibleRef = useRef(false);
|
||||
const frameRef = useRef(frame);
|
||||
|
||||
const renderOnce = useCallback(() => {
|
||||
const renderer = rendererRef.current;
|
||||
const scene = sceneRef.current;
|
||||
const camera = cameraRef.current;
|
||||
if (!renderer || !scene || !camera || !visibleRef.current) return;
|
||||
renderer.render(scene, camera);
|
||||
}, []);
|
||||
|
||||
const rebuildGeometry = useCallback(() => {
|
||||
const scene = sceneRef.current;
|
||||
const currentFrame = frameRef.current;
|
||||
const THREE = threeRef.current;
|
||||
if (!scene || !THREE || !currentFrame?.buffer || !visibleRef.current) return;
|
||||
|
||||
const pointCount = Number(currentFrame.meta?.pointCount) || 0;
|
||||
const strideBytes = Number(currentFrame.meta?.strideBytes) || 16;
|
||||
if (!pointCount || strideBytes < 16) return;
|
||||
|
||||
const view = new DataView(currentFrame.buffer);
|
||||
const positions = new Float32Array(pointCount * 3);
|
||||
const colors = new Float32Array(pointCount * 3);
|
||||
|
||||
// The server sends x/y/z as little-endian floats followed by rgba bytes.
|
||||
// Building typed arrays only when the canvas is visible keeps expensive
|
||||
// browser-side point conversion from happening while the card is off-screen.
|
||||
for (let index = 0; index < pointCount; index += 1) {
|
||||
const source = index * strideBytes;
|
||||
const target = index * 3;
|
||||
positions[target + 0] = view.getFloat32(source + 0, true);
|
||||
positions[target + 1] = view.getFloat32(source + 4, true);
|
||||
positions[target + 2] = -view.getFloat32(source + 8, true);
|
||||
colors[target + 0] = view.getUint8(source + 12) / 255;
|
||||
colors[target + 1] = view.getUint8(source + 13) / 255;
|
||||
colors[target + 2] = view.getUint8(source + 14) / 255;
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: 0.018,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
const points = new THREE.Points(geometry, material);
|
||||
|
||||
if (pointsRef.current) {
|
||||
scene.remove(pointsRef.current);
|
||||
pointsRef.current.geometry.dispose();
|
||||
pointsRef.current.material.dispose();
|
||||
}
|
||||
pointsRef.current = points;
|
||||
scene.add(points);
|
||||
renderOnce();
|
||||
}, [renderOnce]);
|
||||
|
||||
useEffect(() => {
|
||||
frameRef.current = frame;
|
||||
rebuildGeometry();
|
||||
}, [frame, rebuildGeometry]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
let resizeObserver = null;
|
||||
let intersectionObserver = null;
|
||||
|
||||
async function initRenderer() {
|
||||
// Three.js is loaded only after a point-cloud frame exists and this view
|
||||
// mounts. The import resolves from locally built assets, so the viewer
|
||||
// still works without internet access.
|
||||
const [threeModule, controlsModule] = await Promise.all([
|
||||
import('three'),
|
||||
import('three/examples/jsm/controls/OrbitControls.js'),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
const THREE = threeModule;
|
||||
const { OrbitControls } = controlsModule;
|
||||
threeRef.current = THREE;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a0a);
|
||||
const camera = new THREE.PerspectiveCamera(55, 4 / 3, 0.01, 20);
|
||||
camera.position.set(0, 0.15, 2.2);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
|
||||
renderer.setSize(host.clientWidth || 640, host.clientHeight || 480, false);
|
||||
host.appendChild(renderer.domElement);
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = false;
|
||||
controls.target.set(0, 0, -1.4);
|
||||
controls.addEventListener('change', renderOnce);
|
||||
|
||||
sceneRef.current = scene;
|
||||
cameraRef.current = camera;
|
||||
rendererRef.current = renderer;
|
||||
controlsRef.current = controls;
|
||||
|
||||
resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const width = Math.max(1, entry.contentRect.width);
|
||||
const height = Math.max(1, entry.contentRect.height);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
renderOnce();
|
||||
});
|
||||
resizeObserver.observe(host);
|
||||
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const nextVisible = Boolean(entry?.isIntersecting);
|
||||
visibleRef.current = nextVisible;
|
||||
if (nextVisible) {
|
||||
// When the card becomes visible again, rebuild from the latest
|
||||
// cached frame so users see current data without rendering while
|
||||
// hidden.
|
||||
rebuildGeometry();
|
||||
renderOnce();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.01 },
|
||||
);
|
||||
intersectionObserver.observe(host);
|
||||
}
|
||||
|
||||
initRenderer();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
intersectionObserver?.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
controlsRef.current?.removeEventListener('change', renderOnce);
|
||||
controlsRef.current?.dispose();
|
||||
if (pointsRef.current) {
|
||||
sceneRef.current?.remove(pointsRef.current);
|
||||
pointsRef.current.geometry.dispose();
|
||||
pointsRef.current.material.dispose();
|
||||
pointsRef.current = null;
|
||||
}
|
||||
rendererRef.current?.dispose();
|
||||
rendererRef.current?.domElement?.remove();
|
||||
sceneRef.current = null;
|
||||
cameraRef.current = null;
|
||||
rendererRef.current = null;
|
||||
controlsRef.current = null;
|
||||
threeRef.current = null;
|
||||
};
|
||||
}, [rebuildGeometry, renderOnce]);
|
||||
|
||||
return <div ref={hostRef} className="h-full w-full" />;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Kinect Panel
|
||||
// Purpose: Displays request-only Kinect image and 3D snapshots shared by the server.
|
||||
// Scope: Owns browser socket events, cached frame display, request controls, and card layout.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import PointCloudViewer from './PointCloudViewer.jsx';
|
||||
import {
|
||||
buildStatusPill,
|
||||
normalizeBinaryPayload,
|
||||
normalizeKinectStatus,
|
||||
} from './utils.js';
|
||||
|
||||
export default function KinectPanel() {
|
||||
const socket = useSocket();
|
||||
const status = useSessionSelector((state) => normalizeKinectStatus(state.session?.kinect));
|
||||
const [activeView, setActiveView] = useState('3d');
|
||||
const [pointCloudFrame, setPointCloudFrame] = useState(null);
|
||||
const [colorUrl, setColorUrl] = useState(null);
|
||||
const [requestError, setRequestError] = useState(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return undefined;
|
||||
|
||||
const handlePointCloudFrame = (meta = {}, buffer) => {
|
||||
const normalized = normalizeBinaryPayload(buffer);
|
||||
if (!normalized) return;
|
||||
setPointCloudFrame({ meta, buffer: normalized });
|
||||
setActiveView('3d');
|
||||
setRequestError(null);
|
||||
};
|
||||
|
||||
const handleColorFrame = (...args) => {
|
||||
const buffer = args[1];
|
||||
const normalized = normalizeBinaryPayload(buffer);
|
||||
if (!normalized) return;
|
||||
const blob = new Blob([normalized], { type: 'image/jpeg' });
|
||||
const nextUrl = URL.createObjectURL(blob);
|
||||
setColorUrl((previous) => {
|
||||
if (previous) URL.revokeObjectURL(previous);
|
||||
return nextUrl;
|
||||
});
|
||||
setActiveView('image');
|
||||
setRequestError(null);
|
||||
};
|
||||
|
||||
socket.on('kinect:pointCloudFrame', handlePointCloudFrame);
|
||||
socket.on('kinect:colorFrame', handleColorFrame);
|
||||
socket.emit('kinect:requestCachedFrames', {}, () => {});
|
||||
|
||||
return () => {
|
||||
socket.off('kinect:pointCloudFrame', handlePointCloudFrame);
|
||||
socket.off('kinect:colorFrame', handleColorFrame);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (colorUrl) URL.revokeObjectURL(colorUrl);
|
||||
}, [colorUrl]);
|
||||
|
||||
const cooldownRemainingMs = Math.max(0, Number(status.captureCooldownUntil || 0) - nowMs);
|
||||
const controlsDisabled = !status.enabled || status.busy || cooldownRemainingMs > 0;
|
||||
const cooldownText = cooldownRemainingMs > 0 ? `${Math.ceil(cooldownRemainingMs / 1000)}s` : null;
|
||||
const statusPill = useMemo(
|
||||
() =>
|
||||
buildStatusPill({
|
||||
cooldownText,
|
||||
enabled: status.enabled,
|
||||
busy: status.busy,
|
||||
lastError: status.lastError,
|
||||
}),
|
||||
[cooldownText, status.busy, status.enabled, status.lastError],
|
||||
);
|
||||
const visibleError = requestError || status.lastError;
|
||||
|
||||
const emitRequest = useCallback(
|
||||
(eventName) => {
|
||||
if (!socket || controlsDisabled) return;
|
||||
setRequestError(null);
|
||||
socket.emit(eventName, {}, (resp = {}) => {
|
||||
if (resp.error) {
|
||||
setRequestError(resp.error);
|
||||
}
|
||||
});
|
||||
},
|
||||
[controlsDisabled, socket],
|
||||
);
|
||||
|
||||
const actions = (
|
||||
<div className="flex flex-wrap items-center justify-end gap-0.5 text-[0.68rem] text-slate-400">
|
||||
<span className={`inline-flex min-w-[3.7rem] justify-center rounded border px-1 py-0.5 text-xs font-semibold ${statusPill.className}`}>
|
||||
{statusPill.label}
|
||||
</span>
|
||||
<div className="inline-flex overflow-hidden rounded border border-slate-700">
|
||||
{['3d', 'image'].map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={`px-1 py-0.5 ${activeView === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
||||
onClick={() => setActiveView(option)}
|
||||
>
|
||||
{option === '3d' ? '3D' : 'Image'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark px-1 py-0.25"
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => emitRequest('kinect:requestPointCloud')}
|
||||
>
|
||||
Request 3D
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark px-1 py-0.25"
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => emitRequest('kinect:requestColorImage')}
|
||||
>
|
||||
Request Image
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<CardFrame title="Kinect Viewer" actions={actions} bodyClassName="space-y-0.5 p-0.5 text-sm">
|
||||
<div className="aspect-[4/3] w-full overflow-hidden rounded bg-black">
|
||||
{activeView === '3d' && pointCloudFrame?.buffer ? (
|
||||
<PointCloudViewer frame={pointCloudFrame} />
|
||||
) : activeView === '3d' ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-sm text-slate-400">
|
||||
Request a 3D frame
|
||||
</div>
|
||||
) : colorUrl ? (
|
||||
<img src={colorUrl} alt="Kinect image" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-center text-sm text-slate-400">
|
||||
Request an image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{visibleError ? (
|
||||
<p className="m-0 break-words text-[0.7rem] text-red-300">{String(visibleError).toLowerCase()}</p>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Kinect Panel Utilities
|
||||
// Purpose: Keeps small normalization helpers out of the visual component files.
|
||||
// Scope: Handles session status defaults, socket binary payload conversion, and title-bar status pill state.
|
||||
export const EMPTY_KINECT_STATUS = {
|
||||
enabled: false,
|
||||
available: false,
|
||||
busy: false,
|
||||
captureCooldownUntil: 0,
|
||||
lastError: null,
|
||||
hasPointCloud: false,
|
||||
hasColorImage: false,
|
||||
};
|
||||
|
||||
export function normalizeKinectStatus(status) {
|
||||
return {
|
||||
...EMPTY_KINECT_STATUS,
|
||||
...(status && typeof status === 'object' ? status : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBinaryPayload(buffer) {
|
||||
if (!buffer) return null;
|
||||
if (buffer instanceof ArrayBuffer) return buffer;
|
||||
if (ArrayBuffer.isView(buffer)) {
|
||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildStatusPill({ cooldownText, enabled, busy, lastError }) {
|
||||
if (cooldownText) {
|
||||
return {
|
||||
label: cooldownText,
|
||||
className: 'border-red-400 bg-red-600 text-red-50',
|
||||
};
|
||||
}
|
||||
if (!enabled || lastError) {
|
||||
return {
|
||||
label: 'Off',
|
||||
className: 'border-red-400 bg-red-700 text-red-50',
|
||||
};
|
||||
}
|
||||
if (busy) {
|
||||
return {
|
||||
label: '...',
|
||||
className: 'border-amber-200 bg-amber-500 text-amber-950',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'Ready',
|
||||
className: 'border-emerald-300 bg-emerald-600 text-emerald-50',
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Defines the Right Pane Tabs module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import RoomCameraPanel from '../RoomCameraPanel/index.jsx';
|
||||
import KinectPanel from '../KinectPanel/index.jsx';
|
||||
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
|
||||
import SettingsPanel from '../SettingsPanel/index.jsx';
|
||||
import HelpPanel from '../HelpPanel/index.jsx';
|
||||
@@ -196,6 +197,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
<HomeAssistantControls />
|
||||
<ButtonBoxPanel />
|
||||
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
||||
<KinectPanel />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="vip" keepMounted>
|
||||
|
||||
Reference in New Issue
Block a user