fix camera recovery blocking the dora event loop

Camera::open() retried forever inside a fixed sleep, so a camera that did not
come back left the node stuck there: it stopped reading dora events and no
longer honoured Stop or AllInputsClosed.

Recovery is now a state machine over ticks. open() makes one attempt and
returns; the loop decides when to try again from a 1/2/5/10 s backoff deadline,
and nothing sleeps, so events keep being read while the camera is down. The
first attempt closes the handle; past that the device is wedged and the node
escalates to sl::Camera::reboot(), guarded to the ZED 2 and newer since it does
nothing on the ZED and ZED Mini. Reopening pins the serial so recovery gets the
same physical camera back.

Set async_grab_camera_recovery so a link hiccup returns CAMERA_REBOOTING
immediately instead of blocking the grab for ~17 s, and enable_image_validity_
check so the SDK flags frames a stalled camera hands back looking valid. Those
transient errors no longer drop the handle, since the SDK is already
reconnecting it.

An all-black frame now counts as a failed grab rather than being published, and
a camera delivering nothing usable for 5 s (30 s while the SDK reports it is
reconnecting) triggers recovery. A new camera_state output publishes
opening/streaming/recovering so a downstream node can tell "no frame yet" from
"camera lost".

ZED_RETRY_INTERVAL_SECONDS is replaced by the backoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7g2cBJ5e3r15ZbJ9WZn8c
This commit is contained in:
cesar
2026-09-08 09:26:17 -03:00
co-authored by Claude Opus 5
parent 42c2b791fd
commit d8956f269d
6 changed files with 307 additions and 31 deletions
+139 -7
View File
@@ -4,10 +4,12 @@
#include <dora-node-api.h>
#include <opencv2/opencv.hpp>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <initializer_list>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
@@ -15,6 +17,28 @@ namespace {
enum class PcMode { FULL, HALF };
using Clock = std::chrono::steady_clock;
// Going this long without a usable frame means the camera is wedged rather than
// dropping the odd frame. Measured in elapsed time rather than in failed grabs,
// because a stalling camera can spend seconds inside a single grab.
constexpr double STALL_TIMEOUT_S = 5.0;
// How long the SDK is left to reconnect the camera on its own before the loop
// stops waiting on it and rebuilds the camera anyway. A driver that says it is
// recovering is usually right, but it has no way to tell us it has given up.
constexpr double DRIVER_RECOVERY_TIMEOUT_S = 30.0;
// How long to wait before each reopen: one entry per attempt, the last repeated
// for every attempt beyond it.
constexpr int REOPEN_BACKOFF_S[] = {1, 2, 5, 10};
// Published on the camera_state output so a downstream node can tell a camera
// that has not delivered its first frame yet from one that has been lost.
constexpr const char* STATE_OPENING = "opening";
constexpr const char* STATE_STREAMING = "streaming";
constexpr const char* STATE_RECOVERING = "recovering";
rust::Vec<int64_t> make_shape(int height, int width, int channels) {
rust::Vec<int64_t> shape;
shape.push_back(height);
@@ -82,6 +106,35 @@ std::vector<float> pack_depth(sl::Mat& depth_mat) {
return out;
}
std::chrono::seconds reopen_backoff(int attempt) {
const size_t index =
std::min(static_cast<size_t>(attempt), std::size(REOPEN_BACKOFF_S)) - 1;
return std::chrono::seconds(REOPEN_BACKOFF_S[index]);
}
double seconds_since(Clock::time_point since) {
return std::chrono::duration<double>(Clock::now() - since).count();
}
// Release the camera and schedule the next open attempt. The first attempt only
// closes the handle, which is enough when the driver rather than the device lost
// its way; past that the device itself is wedged and nothing short of resetting
// it will bring the frames back.
//
// The wait is a deadline rather than a sleep: the loop returns to dora between
// attempts, so Stop is still honoured while the camera is down.
void begin_recovery(zed::Camera& camera, int attempt, Clock::time_point& next_attempt_at) {
if (attempt <= 1) {
camera.close();
} else {
camera.reset();
}
const auto wait = reopen_backoff(attempt);
std::cerr << "recovering camera (attempt " << attempt << "); next open in " << wait.count()
<< "s" << std::endl;
next_attempt_at = Clock::now() + wait;
}
int64_t host_timestamp_ns() {
return std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::system_clock::now().time_since_epoch())
@@ -97,9 +150,29 @@ int main() {
auto dora_node = init_dora_node();
zed::Camera camera(config);
camera.open();
camera.warmup();
auto calib = camera.calibration();
zed::Calibration calib;
std::string camera_state;
auto publish_camera_state = [&](const char* state) {
if (camera_state == state) {
return;
}
camera_state = state;
rust::Slice<const uint8_t> state_slice{
reinterpret_cast<const uint8_t*>(camera_state.data()), camera_state.size()};
auto metadata = new_metadata();
metadata->set_string("dtype", "string");
metadata->set_string("encoding", "utf8");
metadata->set_timestamp("host_timestamp_ns", host_timestamp_ns());
auto result = send_output_with_metadata(
dora_node.send_output, "camera_state", state_slice, std::move(metadata));
if (!std::string(result.error).empty()) {
std::cerr << "Error sending camera_state: " << std::string(result.error) << std::endl;
}
std::cout << "camera state: " << camera_state << std::endl;
};
std::string pc_mode_str = util::get_env_str("ZED_PC_MODE", "FULL");
PcMode pc_mode = (pc_mode_str == "HALF") ? PcMode::HALF : PcMode::FULL;
@@ -110,6 +183,16 @@ int main() {
// carries that frame's timestamps like every other output.
bool camera_info_pending = true;
// The camera is opened from inside the event loop, one attempt per tick, so
// the node keeps reading dora events while the camera is unavailable. There
// is no retry sleep: the tick timer paces the attempts, and the backoff
// deadline decides which ticks actually try.
int recovery_attempts = 0;
bool streamed_once = false;
bool stall_reported = false;
auto next_open_attempt_at = Clock::now();
auto last_good_frame_at = Clock::now();
int64_t frame_id = 0;
while (true) {
auto event = dora_node.events->next();
@@ -123,15 +206,64 @@ int main() {
continue;
}
if (!camera.grab()) {
std::cerr << "Grab failed; attempting to reconnect..." << std::endl;
camera.close();
camera.open();
const char* down_state = streamed_once ? STATE_RECOVERING : STATE_OPENING;
if (!camera.is_open()) {
publish_camera_state(down_state);
if (Clock::now() < next_open_attempt_at) {
continue;
}
if (!camera.open()) {
recovery_attempts++;
begin_recovery(camera, recovery_attempts, next_open_attempt_at);
continue;
}
camera.warmup();
calib = camera.calibration();
camera_info_pending = true;
// Opening the ZED takes seconds. Start the stall clock from here so
// that cost is never mistaken for a stalled stream.
last_good_frame_at = Clock::now();
// recovery_attempts is deliberately not cleared here. A wedged device
// can open and still deliver nothing; clearing on open would restart
// the escalation ladder every cycle and never reach the reset.
continue;
}
// A blank frame counts as a failed grab: a camera whose link has dropped
// does not necessarily fail its grabs, it can keep reporting success and
// hand back a uniform frame with an empty depth map.
if (!camera.grab() || camera.image_is_blank()) {
publish_camera_state(down_state);
const double stalled_for = seconds_since(last_good_frame_at);
// The SDK holds the camera open while it reconnects a dropped link.
// Taking the handle away mid-reconnect aborts that and leaves us to
// redo it the slow way, so it gets longer than a plain stall.
const double patience =
camera.recovering() ? DRIVER_RECOVERY_TIMEOUT_S : STALL_TIMEOUT_S;
if (stalled_for < patience) {
continue;
}
if (!stall_reported) {
stall_reported = true;
std::cerr << "camera delivered no usable frame for " << stalled_for << "s"
<< std::endl;
}
recovery_attempts++;
begin_recovery(camera, recovery_attempts, next_open_attempt_at);
continue;
}
if (stall_reported) {
std::cout << "camera recovered after " << recovery_attempts << " attempt(s)"
<< std::endl;
stall_reported = false;
}
last_good_frame_at = Clock::now();
recovery_attempts = 0;
streamed_once = true;
publish_camera_state(STATE_STREAMING);
sl::Mat& image_mat = camera.image();
sl::Mat& depth_mat = camera.depth();