diff --git a/common/params.cc b/common/params.cc index dee6809..f4ffe23 100755 --- a/common/params.cc +++ b/common/params.cc @@ -110,6 +110,7 @@ std::unordered_map keys = { {"CurrentBootlog", PERSISTENT}, {"CurrentRoute", CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION}, {"DashcamDebug", PERSISTENT}, + {"DashcamShutdown", CLEAR_ON_MANAGER_START}, {"DisableLogging", CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION}, {"DisablePowerDown", PERSISTENT}, {"DisableUpdates", PERSISTENT}, diff --git a/selfdrive/clearpilot/dashcamd b/selfdrive/clearpilot/dashcamd index 571ea51..007f2c3 100755 Binary files a/selfdrive/clearpilot/dashcamd and b/selfdrive/clearpilot/dashcamd differ diff --git a/selfdrive/clearpilot/dashcamd.cc b/selfdrive/clearpilot/dashcamd.cc index 12b0df5..c0b07df 100644 --- a/selfdrive/clearpilot/dashcamd.cc +++ b/selfdrive/clearpilot/dashcamd.cc @@ -2,9 +2,43 @@ * CLEARPILOT dashcamd — records raw camera footage to MP4 using OMX H.264 hardware encoder. * * Connects to camerad via VisionIPC, receives NV12 frames, and feeds them directly - * to the Qualcomm OMX encoder. Produces 3-minute MP4 segments in /data/media/0/videos/. + * to the Qualcomm OMX encoder. Produces 3-minute MP4 segments organized by trip. * - * Suspends recording after 10 minutes of standstill, resumes when car moves. + * Trip directory structure: + * /data/media/0/videos/YYYYMMDD-HHMMSS/ (trip directory, named at trip start) + * YYYYMMDD-HHMMSS.mp4 (3-minute segments) + * + * Trip lifecycle state machine: + * + * On process start (after time-valid wait): + * - Create trip directory, start recording immediately with 10-min idle timer + * - If car is already in drive, timer is cancelled and recording continues + * - If car stays parked/off for 10 minutes, trip ends + * + * IDLE_TIMEOUT → RECORDING: + * - Car enters drive gear before timer expires → cancel timer, resume recording + * in the same trip (no new trip directory) + * + * RECORDING → IDLE_TIMEOUT: + * - Car leaves drive gear (park, off, neutral) → start 10-minute idle timer, + * continue recording frames during the timeout period + * + * IDLE_TIMEOUT → TRIP_ENDED: + * - 10-minute timer expires → close segment, trip is over + * + * TRIP_ENDED → RECORDING (new trip): + * - Car enters drive gear → create new trip directory, start recording + * + * Any state → (new trip) on ignition off→on: + * - Ignition transitions off→on → close current trip if active, create new trip + * directory, start recording with 10-min idle timer. This applies even from + * TRIP_ENDED — turning the car on always starts a new trip. If the car is in + * park after ignition on, the idle timer runs; entering drive cancels it. + * + * Graceful shutdown (DashcamShutdown param): + * - thermald sets DashcamShutdown="1" before device power-off + * - dashcamd closes current segment, sets DashcamShutdown="0" (ack), exits + * - thermald waits up to 15s for ack, then proceeds with shutdown */ #include @@ -22,33 +56,54 @@ #include "common/util.h" #include "selfdrive/frogpilot/screenrecorder/omx_encoder.h" -const std::string VIDEOS_DIR = "/data/media/0/videos"; +const std::string VIDEOS_BASE = "/data/media/0/videos"; const int SEGMENT_SECONDS = 180; // 3 minutes const int CAMERA_FPS = 20; const int FRAMES_PER_SEGMENT = SEGMENT_SECONDS * CAMERA_FPS; -const int BITRATE = 4 * 1024 * 1024; // 4 Mbps -const double STANDSTILL_TIMEOUT_SECONDS = 600.0; // 10 minutes +const int BITRATE = 2500 * 1024; // 2500 kbps +const double IDLE_TIMEOUT_SECONDS = 600.0; // 10 minutes ExitHandler do_exit; -static std::string make_filename() { +enum TripState { + IDLE, // no trip active, waiting for drive + RECORDING, // actively recording, car in drive + IDLE_TIMEOUT, // car parked/off, recording with 10-min timer + TRIP_ENDED, // trip closed, waiting for next drive +}; + +static std::string make_timestamp() { char buf[64]; time_t t = time(NULL); struct tm tm = *localtime(&t); - snprintf(buf, sizeof(buf), "%04d%02d%02d-%02d%02d%02d.mp4", + snprintf(buf, sizeof(buf), "%04d%02d%02d-%02d%02d%02d", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec); return std::string(buf); } +static bool system_time_valid() { + time_t t = time(NULL); + struct tm tm = *gmtime(&t); + return (tm.tm_year + 1900) >= 2024; +} + int main(int argc, char *argv[]) { setpriority(PRIO_PROCESS, 0, -10); - // Ensure output directory exists - mkdir(VIDEOS_DIR.c_str(), 0755); + // Ensure base output directory exists + mkdir(VIDEOS_BASE.c_str(), 0755); + + // Wait for valid system time so trip/segment names have real timestamps + LOGW("dashcamd: waiting for system time"); + while (!do_exit) { + if (system_time_valid()) break; + usleep(1000000); // 1 Hz poll + } + if (do_exit) return 0; + LOGW("dashcamd: system time valid"); LOGW("dashcamd: connecting to camerad road stream"); - VisionIpcClient vipc("camerad", VISION_STREAM_ROAD, false); while (!do_exit && !vipc.connect(false)) { usleep(100000); @@ -59,80 +114,178 @@ int main(int argc, char *argv[]) { int height = vipc.buffers[0].height; int y_stride = vipc.buffers[0].stride; int uv_stride = y_stride; - LOGW("dashcamd: connected %dx%d, stride=%d", width, height, y_stride); - // Subscribe to carState for standstill detection - SubMaster sm({"carState"}); + // Subscribe to carState (gear, standstill) and deviceState (ignition/started) + SubMaster sm({"carState", "deviceState"}); + Params params; - // Create encoder — H.264, no downscale, with MP4 remuxing (h265=false) - OmxEncoder encoder(VIDEOS_DIR.c_str(), width, height, CAMERA_FPS, BITRATE, false, false); + // Trip state + TripState state = IDLE; + OmxEncoder *encoder = nullptr; + std::string trip_dir; + int frame_count = 0; + uint64_t segment_start_ts = 0; + double idle_timer_start = 0.0; - int frame_count = FRAMES_PER_SEGMENT; // force new segment on first frame - uint64_t start_ts = 0; - bool recording = false; - bool suspended = false; - double standstill_start = 0.0; + // Ignition tracking for off→on detection + bool prev_started = false; + bool started_initialized = false; + + // Param check throttle (don't hit filesystem every frame) + int param_check_counter = 0; + + // Helper: start a new trip with recording + optional idle timer + auto start_new_trip = [&]() { + // Close existing encoder if any + if (encoder) { + if (state == RECORDING || state == IDLE_TIMEOUT) { + encoder->encoder_close(); + } + delete encoder; + encoder = nullptr; + } + + trip_dir = VIDEOS_BASE + "/" + make_timestamp(); + mkdir(trip_dir.c_str(), 0755); + LOGW("dashcamd: new trip %s", trip_dir.c_str()); + + encoder = new OmxEncoder(trip_dir.c_str(), width, height, CAMERA_FPS, BITRATE, false, false); + + std::string filename = make_timestamp() + ".mp4"; + LOGW("dashcamd: opening segment %s", filename.c_str()); + encoder->encoder_open(filename.c_str()); + frame_count = 0; + segment_start_ts = nanos_since_boot(); + state = RECORDING; + }; + + auto close_trip = [&]() { + if (encoder) { + if (state == RECORDING || state == IDLE_TIMEOUT) { + encoder->encoder_close(); + LOGW("dashcamd: segment closed"); + } + delete encoder; + encoder = nullptr; + } + state = TRIP_ENDED; + frame_count = 0; + idle_timer_start = 0.0; + LOGW("dashcamd: trip ended"); + }; + + // Start recording immediately — if the car is in drive, great; if parked/off, + // the 10-min idle timer will stop the trip if drive is never detected. + start_new_trip(); + idle_timer_start = nanos_since_boot() / 1e9; + state = IDLE_TIMEOUT; + LOGW("dashcamd: recording started, waiting for drive (10-min idle timer active)"); while (!do_exit) { VisionBuf *buf = vipc.recv(); if (buf == nullptr) continue; - // Check standstill state sm.update(0); - bool is_standstill = sm.valid("carState") && sm["carState"].getCarState().getStandstill(); double now = nanos_since_boot() / 1e9; - if (is_standstill) { - if (standstill_start == 0.0) { - standstill_start = now; + // Read vehicle state + bool started = sm.valid("deviceState") && + sm["deviceState"].getDeviceState().getStarted(); + auto gear = sm.valid("carState") ? + sm["carState"].getCarState().getGearShifter() : + cereal::CarState::GearShifter::UNKNOWN; + bool in_drive = (gear == cereal::CarState::GearShifter::DRIVE || + gear == cereal::CarState::GearShifter::SPORT || + gear == cereal::CarState::GearShifter::LOW || + gear == cereal::CarState::GearShifter::MANUMATIC); + + // Detect ignition off→on transition (new ignition cycle = new trip) + if (started_initialized && !prev_started && started) { + LOGW("dashcamd: ignition on — new cycle"); + if (state == RECORDING || state == IDLE_TIMEOUT) { + close_trip(); } - // Suspend after 10 minutes of continuous standstill - if (!suspended && (now - standstill_start) >= STANDSTILL_TIMEOUT_SECONDS) { - LOGW("dashcamd: suspending — standstill for 10 minutes"); - if (recording) { - encoder.encoder_close(); - recording = false; - frame_count = FRAMES_PER_SEGMENT; + // Start recording immediately, idle timer until drive is detected + start_new_trip(); + idle_timer_start = now; + state = IDLE_TIMEOUT; + } + prev_started = started; + started_initialized = true; + + // Check for graceful shutdown request (every ~1 second = 20 frames) + if (++param_check_counter >= CAMERA_FPS) { + param_check_counter = 0; + if (params.getBool("DashcamShutdown")) { + LOGW("dashcamd: shutdown requested, closing trip"); + if (state == RECORDING || state == IDLE_TIMEOUT) { + close_trip(); } - suspended = true; - } - } else { - standstill_start = 0.0; - if (suspended) { - LOGW("dashcamd: resuming — car moving"); - suspended = false; + params.putBool("DashcamShutdown", false); + LOGW("dashcamd: shutdown ack sent, exiting"); + break; } } - if (suspended) continue; + // State machine transitions + switch (state) { + case IDLE: + case TRIP_ENDED: + if (in_drive) { + start_new_trip(); + } + break; - // Start new segment if needed + case RECORDING: + if (!in_drive) { + // Car left drive — start idle timeout + idle_timer_start = now; + state = IDLE_TIMEOUT; + LOGW("dashcamd: car not in drive, starting 10-min idle timer"); + } + break; + + case IDLE_TIMEOUT: + if (in_drive) { + // Back in drive — cancel timer, resume recording same trip + idle_timer_start = 0.0; + state = RECORDING; + LOGW("dashcamd: back in drive, resuming trip"); + } else if ((now - idle_timer_start) >= IDLE_TIMEOUT_SECONDS) { + // Timer expired — end trip + LOGW("dashcamd: idle timeout expired"); + close_trip(); + } + break; + } + + // Only encode frames when we have an active recording + if (state != RECORDING && state != IDLE_TIMEOUT) continue; + + // Segment rotation if (frame_count >= FRAMES_PER_SEGMENT) { - if (recording) { - encoder.encoder_close(); - } - - std::string filename = make_filename(); + encoder->encoder_close(); + std::string filename = make_timestamp() + ".mp4"; LOGW("dashcamd: opening segment %s", filename.c_str()); - encoder.encoder_open(filename.c_str()); + encoder->encoder_open(filename.c_str()); frame_count = 0; - start_ts = nanos_since_boot(); - recording = true; + segment_start_ts = nanos_since_boot(); } - uint64_t ts = nanos_since_boot() - start_ts; + uint64_t ts = nanos_since_boot() - segment_start_ts; // Feed NV12 frame directly to OMX encoder - uint8_t *y_ptr = buf->y; - uint8_t *uv_ptr = buf->uv; - encoder.encode_frame_nv12(y_ptr, y_stride, uv_ptr, uv_stride, width, height, ts); - + encoder->encode_frame_nv12(buf->y, y_stride, buf->uv, uv_stride, width, height, ts); frame_count++; } - if (recording) { - encoder.encoder_close(); + // Clean exit + if (encoder) { + if (state == RECORDING || state == IDLE_TIMEOUT) { + encoder->encoder_close(); + } + delete encoder; } LOGW("dashcamd: stopped"); diff --git a/selfdrive/manager/process_config.py b/selfdrive/manager/process_config.py index 78b4872..128cf21 100755 --- a/selfdrive/manager/process_config.py +++ b/selfdrive/manager/process_config.py @@ -59,7 +59,7 @@ def dashcam_should_run(started, params, CP: car.CarParams) -> bool: procs = [ DaemonProcess("manage_athenad", "selfdrive.athena.manage_athenad", "AthenadPid"), - NativeProcess("camerad", "system/camerad", ["./camerad"], driverview), + NativeProcess("camerad", "system/camerad", ["./camerad"], always_run), NativeProcess("logcatd", "system/logcatd", ["./logcatd"], allow_logging), NativeProcess("proclogd", "system/proclogd", ["./proclogd"], allow_logging), PythonProcess("logmessaged", "system.logmessaged", allow_logging), diff --git a/selfdrive/thermald/thermald.py b/selfdrive/thermald/thermald.py index ddebd87..2275a2b 100755 --- a/selfdrive/thermald/thermald.py +++ b/selfdrive/thermald/thermald.py @@ -411,6 +411,16 @@ def thermald_thread(end_event, hw_queue) -> None: # Check if we need to shut down if power_monitor.should_shutdown(onroad_conditions["ignition"], in_car, off_ts, started_seen): cloudlog.warning(f"shutting device down, offroad since {off_ts}") + # CLEARPILOT: signal dashcamd to close recording gracefully before power-off + params.put_bool("DashcamShutdown", True) + deadline = time.monotonic() + 15.0 + while time.monotonic() < deadline: + if not params.getBool("DashcamShutdown"): + cloudlog.info("dashcamd shutdown ack received") + break + time.sleep(0.5) + else: + cloudlog.warning("dashcamd shutdown ack timeout, proceeding") params.put_bool("DoShutdown", True) msg.deviceState.started = started_ts is not None diff --git a/system/loggerd/deleter.py b/system/loggerd/deleter.py index 5dcec7b..32a0086 100755 --- a/system/loggerd/deleter.py +++ b/system/loggerd/deleter.py @@ -49,18 +49,51 @@ def get_preserved_segments(dirs_by_creation: list[str]) -> list[str]: def delete_oldest_video(): - """CLEARPILOT: delete the oldest screen recording MP4 when disk space is low.""" + """CLEARPILOT: delete oldest dashcam footage when disk space is low. + Trip directories are /data/media/0/videos/YYYYMMDD-HHMMSS/ containing .mp4 segments. + Deletes entire oldest trip directory first. If only one trip remains (active), + deletes individual segments oldest-first within it. Also cleans up legacy flat .mp4 files.""" try: if not os.path.isdir(VIDEOS_DIR): return False - videos = sorted( - (f for f in os.listdir(VIDEOS_DIR) if f.endswith('.mp4')), - key=lambda f: os.path.getctime(os.path.join(VIDEOS_DIR, f)) - ) - if not videos: + + # Collect legacy flat mp4 files and trip directories + legacy_files = [] + trip_dirs = [] + for entry in os.listdir(VIDEOS_DIR): + path = os.path.join(VIDEOS_DIR, entry) + if os.path.isfile(path) and entry.endswith('.mp4'): + legacy_files.append(entry) + elif os.path.isdir(path): + trip_dirs.append(entry) + + # Delete legacy flat files first (oldest by name) + if legacy_files: + legacy_files.sort() + delete_path = os.path.join(VIDEOS_DIR, legacy_files[0]) + cloudlog.info(f"deleting legacy video {delete_path}") + os.remove(delete_path) + return True + + if not trip_dirs: return False - delete_path = os.path.join(VIDEOS_DIR, videos[0]) - cloudlog.info(f"deleting video {delete_path}") + + trip_dirs.sort() # sorted by timestamp name = chronological order + + # If more than one trip, delete the oldest entire trip directory + if len(trip_dirs) > 1: + delete_path = os.path.join(VIDEOS_DIR, trip_dirs[0]) + cloudlog.info(f"deleting trip {delete_path}") + shutil.rmtree(delete_path) + return True + + # Only one trip left (likely active) — delete oldest segment within it + trip_path = os.path.join(VIDEOS_DIR, trip_dirs[0]) + segments = sorted(f for f in os.listdir(trip_path) if f.endswith('.mp4')) + if not segments: + return False + delete_path = os.path.join(trip_path, segments[0]) + cloudlog.info(f"deleting segment {delete_path}") os.remove(delete_path) return True except OSError: