Merge corrected PRs #104 and #105

PR #104 by @inauner; PR #105 by @dirwin31.

Co-authored-by: inauner <inauner@users.noreply.github.com>

Co-authored-by: dirwin31 <dirwin31@users.noreply.github.com>
This commit is contained in:
firestar5683
2026-08-29 14:09:01 -05:00
14 changed files with 4820 additions and 614 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,284 @@
export const MAX_RENDERED_ROUTES = 250
const SEARCH_MONTHS = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]
function validDate(value) {
if (!value) return null
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
export function normalizeRouteSearchText(value) {
return String(value || "")
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLocaleLowerCase()
.replace(/(\d+)(?:st|nd|rd|th)\b/g, "$1")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim()
}
export function formatRouteDate(value, locale) {
const date = validDate(value)
if (!date) return "Unknown date"
return new Intl.DateTimeFormat(locale, {
dateStyle: "long",
timeStyle: "short",
}).format(date)
}
export function normalizeRoute(route, locale) {
const timestamp = route?.timestamp == null ? null : String(route.timestamp)
const startedAtDate = validDate(route?.startedAt)
const timestampDate = validDate(timestamp)
const routeDate = startedAtDate || timestampDate
const displayDate = formatRouteDate(routeDate, locale)
const isCustomName = Boolean(route?.isCustomName) || Boolean(timestamp && !timestampDate)
const displayName = isCustomName ? timestamp : displayDate
const dateAliases = routeDate ? [
new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(routeDate),
new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric" }).format(routeDate),
new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(routeDate),
`${routeDate.getMonth() + 1}/${routeDate.getDate()}/${routeDate.getFullYear()}`,
`${routeDate.getFullYear()}-${routeDate.getMonth() + 1}-${routeDate.getDate()}`,
] : []
const timeAliases = routeDate ? [
new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(routeDate),
new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true }).format(routeDate),
`${String(routeDate.getHours()).padStart(2, "0")}:${String(routeDate.getMinutes()).padStart(2, "0")}`,
] : []
return {
...route,
name: String(route?.name || ""),
timestamp,
startedAt: route?.startedAt || null,
isCustomName,
displayDate,
displayName,
_startedAtMs: startedAtDate?.getTime() ?? timestampDate?.getTime() ?? null,
_searchIndex: {
ids: [normalizeRouteSearchText(route?.name)].filter(Boolean),
titles: [normalizeRouteSearchText(isCustomName ? displayName : "")].filter(Boolean),
dates: dateAliases.map(normalizeRouteSearchText).filter(Boolean),
times: timeAliases.map(normalizeRouteSearchText).filter(Boolean),
},
}
}
export function formatTotalDuration(seconds) {
const totalMinutes = Math.max(0, Math.round(Number(seconds) / 60) || 0)
if (totalMinutes < 1) return "0 min"
if (totalMinutes < 60) return `${totalMinutes} min`
const hours = Math.floor(totalMinutes / 60)
const remaining = totalMinutes % 60
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
}
export function computeRouteStats(routes = []) {
const list = Array.isArray(routes) ? routes : []
let totalDurationSeconds = 0
let preservedCount = 0
let totalSegments = 0
for (const route of list) {
if (route) {
if (Number.isFinite(route.approxDurationSeconds)) {
totalDurationSeconds += Math.max(0, route.approxDurationSeconds)
}
if (route.is_preserved) {
preservedCount += 1
}
if (Number.isFinite(route.segmentCount)) {
totalSegments += Math.max(0, route.segmentCount)
}
}
}
return {
count: list.length,
totalDurationSeconds,
formattedDuration: formatTotalDuration(totalDurationSeconds),
preservedCount,
totalSegments,
}
}
export function sortRoutes(routes, sortOrder = "newest") {
if (sortOrder === "longest" || sortOrder === "shortest") {
const direction = sortOrder === "longest" ? -1 : 1
return [...routes].sort((left, right) => {
const leftDur = Number.isFinite(left?.approxDurationSeconds) ? left.approxDurationSeconds : -1
const rightDur = Number.isFinite(right?.approxDurationSeconds) ? right.approxDurationSeconds : -1
if (leftDur !== rightDur) return (leftDur - rightDur) * direction
return String(left?.name || "").localeCompare(String(right?.name || ""))
})
}
const direction = sortOrder === "oldest" ? 1 : -1
return [...routes].sort((left, right) => {
const leftTime = left?._startedAtMs
const rightTime = right?._startedAtMs
if (leftTime == null && rightTime == null) return String(left?.name || "").localeCompare(String(right?.name || ""))
if (leftTime == null) return 1
if (rightTime == null) return -1
if (leftTime !== rightTime) return (leftTime - rightTime) * direction
return String(left?.name || "").localeCompare(String(right?.name || "")) * -direction
})
}
export function routeMatchesSearch(route, searchQuery) {
const rawQuery = String(searchQuery || "").trim()
const normalizedQuery = normalizeRouteSearchText(searchQuery)
const queryTokens = normalizedQuery.split(" ").filter(Boolean)
if (!queryTokens.length) return true
const fallbackIndex = {
ids: [route?.name],
titles: [route?.timestamp, route?.displayName],
dates: [route?.displayDate],
times: [route?.displayDate],
}
const searchIndex = route?._searchIndex || Object.fromEntries(
Object.entries(fallbackIndex).map(([key, values]) => [key, values.map(normalizeRouteSearchText).filter(Boolean)]),
)
const hasMonth = queryTokens.some(token => token.length >= 3 && SEARCH_MONTHS.some(month => month.startsWith(token)))
const isDateQuery = hasMonth || /\d\s*[/-]\s*\d/.test(rawQuery) || /\d+(?:st|nd|rd|th)\b/i.test(rawQuery) || /^\d{4}$/.test(normalizedQuery)
const isTimeQuery = /^\d{1,2}$/.test(normalizedQuery) || /\d\s*:\s*\d/.test(rawQuery) || queryTokens.some(token => token === "am" || token === "pm")
const compactQuery = normalizedQuery.replaceAll(" ", "")
const isHexQuery = /^[0-9a-f]+$/.test(compactQuery)
const isIdQuery = rawQuery.includes("--") || (isHexQuery && (
compactQuery.length >= 8 || (compactQuery.length >= 4 && /\d/.test(compactQuery) && /[a-f]/.test(compactQuery))
))
const valuesFor = key => Array.isArray(searchIndex[key]) ? searchIndex[key] : []
const matchesValue = (value, dateValue = false) => {
if (value.includes(normalizedQuery)) return true
const searchTokens = value.split(" ").filter(Boolean)
return queryTokens.every(queryToken => searchTokens.some(searchToken => {
// In a date query, "20" is a day prefix, not a match for the year "2026".
if (dateValue && /^\d{1,2}$/.test(queryToken) && /^\d{4}$/.test(searchToken)) return false
return searchToken.startsWith(queryToken)
}))
}
return valuesFor("titles").some(value => matchesValue(value))
|| (isDateQuery && valuesFor("dates").some(value => matchesValue(value, true)))
|| (isTimeQuery && valuesFor("times").some(value => matchesValue(value)))
|| (isIdQuery && valuesFor("ids").some(value => matchesValue(value)))
}
export function buildRouteView(routes, options = {}) {
const matching = sortRoutes(
routes.filter(route => (!options.preservedOnly || route.is_preserved) && routeMatchesSearch(route, options.searchQuery)),
options.sortOrder,
)
return {
matching,
visible: matching.slice(0, MAX_RENDERED_ROUTES),
truncated: matching.length > MAX_RENDERED_ROUTES,
}
}
export function routeViewRenderKey(routes, sortOrder = "newest", viewMode = "list") {
const routeNames = Array.isArray(routes) ? routes.map(route => String(route?.name || "")).join(",") : ""
return `${viewMode}:${sortOrder}:${routeNames}`
}
function localDayKey(date) {
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
}
export function groupRoutesByDate(routes, now = new Date(), locale) {
const today = validDate(now) || new Date()
today.setHours(0, 0, 0, 0)
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
const groups = []
const byKey = new Map()
for (const route of routes) {
const routeDate = route?._startedAtMs == null ? null : new Date(route._startedAtMs)
const key = routeDate ? localDayKey(routeDate) : "unknown"
let group = byKey.get(key)
if (!group) {
let label = "Unknown date"
if (routeDate) {
if (key === localDayKey(today)) label = "Today"
else if (key === localDayKey(yesterday)) label = "Yesterday"
else label = new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(routeDate)
}
group = { key, label, routes: [] }
byKey.set(key, group)
groups.push(group)
}
group.routes.push(route)
}
return groups
}
export function groupRoutesForView(routes, sortOrder = "newest", now, locale) {
// Date headers would scramble a duration sort, so show one flat group instead.
if (sortOrder === "longest" || sortOrder === "shortest") {
const list = Array.isArray(routes) ? [...routes] : []
if (!list.length) return []
return [{ key: sortOrder, label: sortOrder === "longest" ? "Longest first" : "Shortest first", routes: list }]
}
return groupRoutesByDate(routes, now, locale)
}
export function formatApproxDuration(seconds) {
const minutes = Math.max(0, Math.round(Number(seconds) / 60) || 0)
if (minutes < 1) return "Less than 1 min"
if (minutes < 60) return `About ${minutes} min`
const hours = Math.floor(minutes / 60)
const remaining = minutes % 60
return `About ${hours} hr${remaining ? ` ${remaining} min` : ""}`
}
export function parseStoredSegmentNumber(segmentUrl) {
const cleanPath = String(segmentUrl || "").split(/[?#]/, 1)[0]
const match = cleanPath.match(/--(\d+)\/?$/)
if (!match) return null
const value = Number(match[1])
return Number.isSafeInteger(value) ? value : null
}
export function getSegmentStatus(segmentUrls, playbackIndex) {
if (!Array.isArray(segmentUrls) || !Number.isInteger(playbackIndex) || playbackIndex < 0 || playbackIndex >= segmentUrls.length) return ""
const segmentNumber = parseStoredSegmentNumber(segmentUrls[playbackIndex])
if (segmentNumber == null) return ""
return `Segment ${segmentNumber}`
}
export function getSegmentOptions(segmentUrls) {
if (!Array.isArray(segmentUrls)) return []
return segmentUrls.map((_, index) => ({
index,
label: getSegmentStatus(segmentUrls, index) || `Clip ${index + 1}`,
}))
}
export function cameraVideoUrl(segmentUrl, camera, quality) {
const separator = String(segmentUrl).includes("?") ? "&" : "?"
const url = `${segmentUrl}${separator}camera=${encodeURIComponent(camera)}`
return quality ? `${url}&quality=${encodeURIComponent(quality)}` : url
}
export function routeMetadataErrorMessage(status, serverError) {
if (status === 404) {
return "This route is no longer available on this device. Its local video segments may have been deleted or moved."
}
const detail = String(serverError || "").trim()
return detail || `Could not load route details (${status}).`
}
// loggerd only writes qcamera.ts alongside the road camera.
export function supportsLowQuality(camera) {
return camera === "forward"
}
// qcamera is 526x330. Only a positively taller frame proves the real stream is already
// playing; an unknown height upgrades rather than stranding the viewer on the preview.
export function shouldUpgradeFromHeight(height) {
return !(Number.isFinite(height) && height > 400)
}
@@ -37,6 +37,20 @@ export function parseErrorLogToDate(filename) {
return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}`)
}
/**
* Escape a value for interpolation into an HTML string
* @param {unknown} value
* @returns {string}
*/
export function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
/**
* Capitalize the first character of a string
* @param {string} str
@@ -59,6 +59,8 @@ sys.modules.setdefault("openpilot.starpilot.assets.theme_manager", theme_manager
import utilities
_REAL_COMMON_PARAMS_MODULE = sys.modules.get("openpilot.common.params")
for _module_name, _module in _INITIAL_MODULES.items():
if _module is None:
sys.modules.pop(_module_name, None)
@@ -86,6 +88,8 @@ def _simple_module(name, **attrs):
def _install_server_import_stubs():
if _REAL_COMMON_PARAMS_MODULE is not None:
sys.modules["openpilot.common.params"] = _REAL_COMMON_PARAMS_MODULE
sys.modules["openpilot.system.loggerd.config"] = loggerd_config
sys.modules["openpilot.system.loggerd.deleter"] = loggerd_deleter
sys.modules["openpilot.system.loggerd.uploader"] = loggerd_uploader
@@ -130,6 +134,14 @@ def _install_server_import_stubs():
)
sys.modules["openpilot.common.realtime"] = _simple_module("openpilot.common.realtime", DT_HW=0.01)
sys.modules["openpilot.common.swaglog"] = _simple_module(
"openpilot.common.swaglog",
cloudlog=SimpleNamespace(
error=lambda *args, **kwargs: None,
exception=lambda *args, **kwargs: None,
info=lambda *args, **kwargs: None,
),
)
sys.modules["openpilot.common.time_helpers"] = _simple_module("openpilot.common.time_helpers", system_time_valid=lambda: True)
sys.modules["openpilot.system.hardware"] = _simple_module(
"openpilot.system.hardware",
@@ -149,6 +161,15 @@ def _install_server_import_stubs():
get_longitudinal_maneuver_support=lambda *args, **kwargs: {},
)
sys.modules["panda"] = _simple_module("panda", Panda=lambda *args, **kwargs: SimpleNamespace(can_send=lambda *send_args, **send_kwargs: None))
msgq_module = _simple_module("msgq")
msgq_visionipc = _simple_module(
"msgq.visionipc",
VisionIpcClient=lambda *args, **kwargs: SimpleNamespace(connect=lambda *connect_args: False),
VisionStreamType=SimpleNamespace(VISION_STREAM_DRIVER=0),
)
msgq_module.visionipc = msgq_visionipc
sys.modules["msgq"] = msgq_module
sys.modules["msgq.visionipc"] = msgq_visionipc
model_manager.is_builtin_model_key = lambda value: False
model_manager.model_key_aliases = lambda value: [value]
@@ -337,17 +358,16 @@ class FakeDashboardAnalyzerProcess:
def test_route_inventory_counts_segments_without_video_probing(monkeypatch):
segments = [
SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")),
SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")),
SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")),
SimpleNamespace(route_name=SimpleNamespace(time_str="route-old")),
]
def segment(time_str, segment_num):
return SimpleNamespace(route_name=SimpleNamespace(time_str=time_str), segment_num=segment_num)
# route-new has aged out of its first two segments, so it no longer starts at --0.
segments = [segment("route-new", 4), segment("route-new", 2), segment("route-new", 3), segment("route-old", 0)]
monkeypatch.setattr(utilities, "get_all_segment_names", lambda _path: segments)
assert utilities.get_routes_with_segment_counts("/tmp/routes") == [
("route-old", 1),
("route-new", 3),
assert utilities.get_routes_with_segment_details("/tmp/routes") == [
("route-old", {"segmentCount": 1, "firstSegmentNum": 0}),
("route-new", {"segmentCount": 3, "firstSegmentNum": 2}),
]
@@ -1071,6 +1091,28 @@ def test_clear_dashboard_route_history_keeps_durable_records(tmp_path, monkeypat
assert stats["modelUsage"]["orion"]["drives"] == 3
def test_clear_dashboard_route_history_can_retain_preserved_routes(tmp_path, monkeypatch):
monkeypatch.setattr(utilities, "DASHBOARD_PARAMS_DIR", tmp_path)
params = FakeParams({
utilities.DASHBOARD_PERSISTENT_STATS_PARAM: {
"routes": {
"0000006a--9f0a7bdf9c": {"date": "2026-06-15T08:00:00"},
"0000006b--9f0a7bdf9d": {"date": "2026-06-16T08:00:00"},
},
"ignoredRoutes": ["0000006a--9f0a7bdf9c", "0000006b--9f0a7bdf9d"],
"personalRecords": {"cleanDriveStreak": {"drives": 4}},
},
})
removed = utilities.clear_dashboard_route_history(params, retained_route_names={"0000006a--9f0a7bdf9c"})
assert removed == 1
stats = utilities._load_dashboard_persistent_stats(params)
assert list(stats["routes"]) == ["0000006a--9f0a7bdf9c"]
assert stats["ignoredRoutes"] == ["0000006a--9f0a7bdf9c"]
assert stats["personalRecords"]["cleanDriveStreak"]["drives"] == 4
def test_lightweight_routes_surface_recent_drives_without_log_analysis(monkeypatch):
utilities._invalidate_dashboard_cache()
now = utilities.datetime.now().replace(hour=12, minute=0, second=0, microsecond=0)
@@ -0,0 +1,854 @@
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
import io
import os
from pathlib import Path
import subprocess
import threading
import time
import pytest
from test_dashboard_stats import FakeParams, MODULE_DIR, _install_server_import_stubs
def _load_server_module():
import importlib.util
import sys
_install_server_import_stubs()
spec = importlib.util.spec_from_file_location("dashcam_routes_server", MODULE_DIR / "the_galaxy.py")
module = importlib.util.module_from_spec(spec)
sys.modules["dashcam_routes_server"] = module
spec.loader.exec_module(module)
return module
the_galaxy = _load_server_module()
utilities = the_galaxy.utilities
ROUTE_NAME = "0000006a--9f0a7bdf9c"
def _make_segment(root, route_name=ROUTE_NAME, segment_num=0):
segment = root / f"{route_name}--{segment_num}"
segment.mkdir(parents=True)
return segment
def _make_client(monkeypatch, root):
assert the_galaxy._import_galaxy_web_symbols()
monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(root) + "/"])
monkeypatch.setattr(the_galaxy, "params", FakeParams())
app = the_galaxy.Flask(
f"dashcam_routes_{time.monotonic_ns()}",
template_folder=str(MODULE_DIR / "templates"),
static_folder=str(MODULE_DIR / "assets"),
)
the_galaxy.setup(app)
return app.test_client()
def test_process_route_is_metadata_only_and_retains_fields(monkeypatch, tmp_path):
segment = _make_segment(tmp_path, segment_num=3)
(segment / "qlog.zst").write_bytes(b"log")
(segment / "Morning school run").touch()
started_at = datetime(2026, 8, 26, 15, 30, tzinfo=timezone.utc)
monkeypatch.setattr(utilities, "get_route_start_time", lambda path: started_at)
monkeypatch.setattr(utilities, "has_preserve_attr", lambda path: True)
monkeypatch.setattr(utilities, "video_to_png", lambda *args: (_ for _ in ()).throw(AssertionError("preview generation must stay lazy")))
result = utilities.process_route(str(tmp_path), ROUTE_NAME, segment_count=4, first_segment_num=3)
assert result == {
"name": ROUTE_NAME,
"png": f"/thumbnails/{ROUTE_NAME}--3/preview.png",
"timestamp": "Morning school run",
"startedAt": "2026-08-26T15:30:00Z",
"isCustomName": True,
"is_preserved": True,
"segmentCount": 4,
"approxDurationSeconds": 240,
}
def test_process_route_uses_display_timestamp_without_losing_started_at(monkeypatch, tmp_path):
_make_segment(tmp_path)
started_at = datetime(2026, 8, 26, 15, 30, tzinfo=timezone.utc)
monkeypatch.setattr(utilities, "get_route_start_time", lambda path: started_at)
result = utilities.process_route(str(tmp_path), ROUTE_NAME, segment_count=1)
assert result["timestamp"] == started_at.isoformat()
assert result["startedAt"] == "2026-08-26T15:30:00Z"
assert result["isCustomName"] is False
def test_route_scan_deduplicates_using_footage_root_priority(monkeypatch):
first = "/priority/"
second = "/fallback/"
details = {
first: [(ROUTE_NAME, {"segmentCount": 2, "firstSegmentNum": 1})],
second: [
(ROUTE_NAME, {"segmentCount": 8, "firstSegmentNum": 0}),
("0000006b--9f0a7bdf9d", {"segmentCount": 1, "firstSegmentNum": 4}),
],
}
monkeypatch.setattr(utilities, "get_routes_with_segment_details", lambda path: details[path])
entries = the_galaxy._route_scan_entries([first, second])
assert entries == [
(first, ROUTE_NAME, 2, 1),
(second, "0000006b--9f0a7bdf9d", 1, 4),
]
def test_route_metadata_stream_batches_eight_with_progress_and_retained_fields():
entries = [
("/routes/", f"{index:08x}--{index:010x}", index + 1, index % 3)
for index in range(18)
]
def process(path, name, segment_count, first_segment_num):
return {
"name": name,
"png": f"/thumbnails/{name}--{first_segment_num}/preview.png",
"timestamp": f"Route {segment_count}",
"startedAt": "2026-08-26T15:30:00Z",
"isCustomName": True,
"is_preserved": False,
"segmentCount": segment_count,
"approxDurationSeconds": segment_count * 60,
}
events = list(the_galaxy._route_metadata_events(entries, "dongle", process))
assert events[0] == {"routes": [], "progress": 0, "total": 18, "connectDongleId": "dongle"}
assert [len(event["routes"]) for event in events[1:]] == [8, 8, 2]
assert [event["progress"] for event in events[1:]] == [8, 16, 18]
assert all(event["total"] == 18 for event in events)
results = [route for event in events[1:] for route in event["routes"]]
assert len(results) == 18
assert all({
"name", "png", "timestamp", "startedAt", "isCustomName",
"is_preserved", "segmentCount", "approxDurationSeconds",
} <= result.keys() for result in results)
def test_route_metadata_stream_cancels_queued_work_when_closed():
entries = [("/routes/", f"{index:08x}--{index:010x}", 1, 0) for index in range(40)]
release = threading.Event()
started = []
lock = threading.Lock()
def process(path, name, segment_count, first_segment_num):
index = int(name.split("--", 1)[0], 16)
with lock:
started.append(index)
if index >= 8:
release.wait(timeout=2)
return {"name": name}
stream = the_galaxy._route_metadata_events(entries, process_route=process)
next(stream)
batch = next(stream)
assert len(batch["routes"]) == 8
stream.close()
release.set()
time.sleep(0.1)
# At most four already-running workers continue; the remaining queue is cancelled.
assert len(started) <= 12
def test_thumbnail_path_validation_is_strict(tmp_path):
_make_segment(tmp_path)
valid = f"{ROUTE_NAME}--0/preview.png"
assert the_galaxy._resolve_route_thumbnail(valid, [tmp_path]) == tmp_path / f"{ROUTE_NAME}--0" / "preview.png"
for invalid in (
"../preview.png",
f"{ROUTE_NAME}--0/qcamera.ts",
f"{ROUTE_NAME}--0/subdir/preview.png",
f"{ROUTE_NAME}--nope/preview.png",
f"/{ROUTE_NAME}--0/preview.png",
f"{ROUTE_NAME}--0\\preview.png",
):
assert the_galaxy._resolve_route_thumbnail(invalid, [tmp_path]) is None
def test_thumbnail_path_validation_rejects_symlinks_outside_the_footage_root(tmp_path):
footage_root = tmp_path / "footage"
outside_segment = tmp_path / "outside"
footage_root.mkdir()
outside_segment.mkdir()
(footage_root / f"{ROUTE_NAME}--0").symlink_to(outside_segment, target_is_directory=True)
assert the_galaxy._resolve_route_thumbnail(f"{ROUTE_NAME}--0/preview.png", [footage_root]) is None
def test_thumbnail_generation_is_lazy_and_reuses_completed_preview(monkeypatch, tmp_path):
segment = _make_segment(tmp_path)
(segment / "qcamera.ts").write_bytes(b"video")
calls = []
def generate(source, output):
calls.append((Path(source), Path(output)))
Path(output).write_bytes(b"png")
return True
monkeypatch.setattr(utilities, "video_to_png", generate)
relative_path = f"{ROUTE_NAME}--0/preview.png"
first = the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path])
second = the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path])
assert first == second == segment / "preview.png"
assert len(calls) == 1
assert calls[0][0] == segment / "qcamera.ts"
def test_thumbnail_failure_returns_none_and_does_not_cache_partial_file(monkeypatch, tmp_path):
segment = _make_segment(tmp_path)
(segment / "qcamera.ts").write_bytes(b"video")
monkeypatch.setattr(utilities, "video_to_png", lambda source, output: False)
result = the_galaxy._get_or_create_route_thumbnail(f"{ROUTE_NAME}--0/preview.png", [tmp_path])
assert result is None
assert not (segment / "preview.png").exists()
def test_duplicate_thumbnail_requests_share_one_generation_job(monkeypatch, tmp_path):
segment = _make_segment(tmp_path)
(segment / "qcamera.ts").write_bytes(b"video")
release = threading.Event()
started = threading.Event()
calls = []
def generate(preview_path):
calls.append(preview_path)
started.set()
release.wait(timeout=2)
preview_path.write_bytes(b"png")
return preview_path
monkeypatch.setattr(the_galaxy, "_generate_route_thumbnail", generate)
relative_path = f"{ROUTE_NAME}--0/preview.png"
with ThreadPoolExecutor(max_workers=2) as callers:
first = callers.submit(the_galaxy._get_or_create_route_thumbnail, relative_path, [tmp_path])
assert started.wait(timeout=1)
second = callers.submit(the_galaxy._get_or_create_route_thumbnail, relative_path, [tmp_path])
time.sleep(0.05)
release.set()
assert first.result(timeout=1) == segment / "preview.png"
assert second.result(timeout=1) == segment / "preview.png"
assert len(calls) == 1
assert the_galaxy._ROUTE_THUMBNAIL_EXECUTOR._max_workers == 2
def test_timed_out_thumbnail_job_stays_deduplicated_until_completion(monkeypatch, tmp_path):
segment = _make_segment(tmp_path)
(segment / "qcamera.ts").write_bytes(b"video")
release = threading.Event()
started = threading.Event()
calls = []
def generate(preview_path):
calls.append(preview_path)
started.set()
release.wait(timeout=2)
preview_path.write_bytes(b"png")
return preview_path
monkeypatch.setattr(the_galaxy, "_generate_route_thumbnail", generate)
monkeypatch.setattr(the_galaxy, "ROUTE_THUMBNAIL_WAIT_SECONDS", 0.01)
relative_path = f"{ROUTE_NAME}--0/preview.png"
preview_key = str((tmp_path / f"{ROUTE_NAME}--0" / "preview.png").resolve())
assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) is None
assert started.is_set()
assert preview_key in the_galaxy._ROUTE_THUMBNAIL_FUTURES
# A retry while the original job is still running must reuse that job.
assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) is None
assert len(calls) == 1
release.set()
for _ in range(100):
if preview_key not in the_galaxy._ROUTE_THUMBNAIL_FUTURES:
break
time.sleep(0.01)
assert preview_key not in the_galaxy._ROUTE_THUMBNAIL_FUTURES
assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) == segment / "preview.png"
assert len(calls) == 1
def test_routes_endpoint_uses_sse_no_buffering_headers(monkeypatch, tmp_path):
segment = _make_segment(tmp_path)
(segment / "qlog.zst").write_bytes(b"log")
monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc))
client = _make_client(monkeypatch, tmp_path)
response = client.get("/api/routes")
assert response.status_code == 200
assert response.mimetype == "text/event-stream"
assert response.headers["X-Accel-Buffering"] == "no"
assert "no-cache" in response.headers["Cache-Control"]
assert b'"progress": 1' in response.data
assert b'"startedAt": "2026-08-26T00:00:00Z"' in response.data
def test_thumbnail_endpoint_sets_cache_headers(monkeypatch, tmp_path):
segment = _make_segment(tmp_path)
preview = segment / "preview.png"
preview.write_bytes(b"not-a-real-png-but-send-file-does-not-mind")
client = _make_client(monkeypatch, tmp_path)
with client.get(f"/thumbnails/{ROUTE_NAME}--0/preview.png") as response:
assert response.status_code == 200
assert response.mimetype == "image/png"
assert response.headers["Cache-Control"] == f"public, max-age={the_galaxy.ROUTE_THUMBNAIL_CACHE_SECONDS}"
assert response.data == preview.read_bytes()
def test_rename_and_reset_keep_logs_and_use_both_reset_urls(monkeypatch, tmp_path):
segments = [_make_segment(tmp_path, segment_num=number) for number in (0, 3)]
for segment in segments:
(segment / "qlog.zst").write_bytes(b"log")
(segment / "Old_name").touch()
monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc))
client = _make_client(monkeypatch, tmp_path)
renamed = client.post("/api/routes/rename", json={"old": ROUTE_NAME, "new": "New name"})
assert renamed.status_code == 200
assert renamed.get_json()["name"] == "New_name"
assert all((segment / "New_name").exists() for segment in segments)
assert all((segment / "qlog.zst").read_bytes() == b"log" for segment in segments)
reset = client.post("/api/routes/reset_name", json={"name": ROUTE_NAME})
assert reset.status_code == 200
assert reset.get_json()["timestamp"].startswith("2026-08-26")
assert all(not (segment / "New_name").exists() for segment in segments)
assert all((segment / "qlog.zst").exists() for segment in segments)
# The legacy URL remains available for older clients.
for segment in segments:
(segment / "Another_name").touch()
assert client.post("/api/routes/clear_name", json={"name": ROUTE_NAME}).status_code == 200
def test_preserve_unpreserve_and_delete_route_endpoints(monkeypatch, tmp_path):
segment = _make_segment(tmp_path)
client = _make_client(monkeypatch, tmp_path)
attributes = set()
deleted = []
monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 10)
monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: list(attributes), raising=False)
monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False)
monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: attributes.add(name), raising=False)
monkeypatch.setattr(the_galaxy.os, "removexattr", lambda path, name: attributes.discard(name), raising=False)
monkeypatch.setattr(the_galaxy, "delete_file", deleted.append)
assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200
assert the_galaxy.PRESERVE_ATTR_NAME in attributes
assert client.delete(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200
assert the_galaxy.PRESERVE_ATTR_NAME not in attributes
assert client.delete(f"/api/routes/{ROUTE_NAME}").status_code == 200
assert deleted == [str(segment)]
def test_preserve_follows_the_first_surviving_segment(monkeypatch, tmp_path):
segment = _make_segment(tmp_path, segment_num=3) # --0 and --1 already aged out
client = _make_client(monkeypatch, tmp_path)
attributes = {}
monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 10)
monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: list(attributes.get(str(path), ())), raising=False)
monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False)
monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: attributes.setdefault(str(path), set()).add(name), raising=False)
monkeypatch.setattr(the_galaxy.os, "removexattr", lambda path, name: attributes[str(path)].discard(name), raising=False)
assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200
assert attributes == {str(segment): {the_galaxy.PRESERVE_ATTR_NAME}}
assert utilities.process_route(str(tmp_path) + "/", ROUTE_NAME, 1, 3)["is_preserved"] is True
assert client.delete(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200
assert attributes[str(segment)] == set()
def test_preserve_limit_counts_routes_not_segments(monkeypatch, tmp_path):
for segment_num in (5, 6, 7):
_make_segment(tmp_path, segment_num=segment_num)
client = _make_client(monkeypatch, tmp_path)
monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 1)
monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: [the_galaxy.PRESERVE_ATTR_NAME], raising=False)
monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False)
monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: None, raising=False)
# Three preserved segments belong to one route, so the cap of 1 is not already spent on it.
assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200
assert client.post("/api/routes/00000099--9f0a7bdf9c/preserve").status_code == 400
def test_delete_all_non_preserved_keeps_entire_preserved_route_across_roots(monkeypatch, tmp_path):
standard = tmp_path / "standard"
high_resolution = tmp_path / "high_resolution"
preserved_route = ROUTE_NAME
ordinary_route = "0000006b--9f0a7bdf9d"
preserved_marker = _make_segment(standard, preserved_route, 3)
preserved_other_root = _make_segment(high_resolution, preserved_route, 4)
ordinary_standard = _make_segment(standard, ordinary_route, 0)
ordinary_other_root = _make_segment(high_resolution, ordinary_route, 1)
unrelated = high_resolution / "video_cache"
unrelated.mkdir()
client = _make_client(monkeypatch, standard)
monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(standard), str(high_resolution)])
monkeypatch.setattr(utilities, "has_preserve_attr", lambda path: path == str(preserved_marker))
monkeypatch.setattr(utilities, "stop_dashboard_background_analysis", lambda: None)
monkeypatch.setattr(the_galaxy, "delete_file", lambda path: Path(path).rmdir())
history_calls = []
monkeypatch.setattr(utilities, "clear_dashboard_route_history", lambda params, retained_route_names=None: history_calls.append(retained_route_names) or 1)
factory_delete_calls = []
monkeypatch.setattr(the_galaxy, "_run_factory_reset_delete", factory_delete_calls.append)
response = client.delete("/api/routes/delete_all?include_preserved=false")
assert response.status_code == 200
assert response.get_json()["deletedRoutes"] == 1
assert response.get_json()["preservedRoutes"] == 1
assert preserved_marker.is_dir()
assert preserved_other_root.is_dir()
assert not ordinary_standard.exists()
assert not ordinary_other_root.exists()
assert unrelated.is_dir()
assert history_calls == [{preserved_route}]
assert factory_delete_calls == []
def test_delete_all_including_preserved_keeps_existing_full_wipe_behavior(monkeypatch, tmp_path):
first_root = tmp_path / "standard"
second_root = tmp_path / "high_resolution"
_make_segment(first_root)
_make_segment(second_root)
client = _make_client(monkeypatch, first_root)
monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(first_root) + "/", str(second_root), str(first_root)])
monkeypatch.setattr(utilities, "stop_dashboard_background_analysis", lambda: None)
history_calls = []
monkeypatch.setattr(utilities, "clear_dashboard_route_history", lambda params, retained_route_names=None: history_calls.append(retained_route_names) or 2)
factory_delete_calls = []
monkeypatch.setattr(the_galaxy, "_run_factory_reset_delete", factory_delete_calls.append)
response = client.delete("/api/routes/delete_all?include_preserved=true")
assert response.status_code == 200
assert factory_delete_calls == [str(first_root), str(second_root)]
assert history_calls == [None]
assert "including preserved routes" in response.get_json()["message"]
def test_video_cache_evicts_oldest_instead_of_wiping_everything(monkeypatch, tmp_path):
"""A tight disk used to delete every cached mp4, so playback re-muxed on every request."""
cache = tmp_path / "video_cache"
cache.mkdir()
monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache)
monkeypatch.setattr(utilities, "VIDEO_CACHE_MAX_BYTES", 300)
for index in range(4):
entry = cache / f"{index}.mp4"
entry.write_bytes(b"x" * 100)
os.utime(entry, (1000 + index, 1000 + index))
utilities._prune_video_cache()
survivors = sorted(path.name for path in cache.glob("*.mp4"))
# Budget is 300 bytes of 400 used, so only the oldest goes.
assert survivors == ["1.mp4", "2.mp4", "3.mp4"]
def test_video_cache_never_evicts_the_entry_being_written(monkeypatch, tmp_path):
cache = tmp_path / "video_cache"
cache.mkdir()
monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache)
monkeypatch.setattr(utilities, "VIDEO_CACHE_MAX_BYTES", 50)
for index in range(3):
entry = cache / f"{index}.mp4"
entry.write_bytes(b"x" * 100)
os.utime(entry, (1000 + index, 1000 + index))
keep = cache / "0.mp4"
utilities._prune_video_cache(keep_path=keep)
assert keep.exists()
def test_combined_video_streams_fragmented_mp4_without_a_full_cache_file(monkeypatch, tmp_path):
cache = tmp_path / "video_cache"
first = tmp_path / "first.hevc"
second = tmp_path / "second.hevc"
first.write_bytes(b"first")
second.write_bytes(b"second")
monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache)
captured = {}
class FakeProcess:
def __init__(self):
self.stdout = io.BytesIO(b"streamed-video")
self.returncode = None
def wait(self, timeout=None):
self.returncode = 0
return 0
def poll(self):
return self.returncode
def popen(command, **kwargs):
captured["command"] = command
list_path = Path(command[command.index("-i") + 1])
captured["list_path"] = list_path
captured["list_contents"] = list_path.read_text()
return FakeProcess()
monkeypatch.setattr(utilities.subprocess, "Popen", popen)
payload = b"".join(utilities.ffmpeg_stream_concatenated_mp4([first, second], chunk_size=4))
assert payload == b"streamed-video"
assert "frag_keyframe+empty_moov+default_base_moof" in captured["command"]
assert captured["command"][-1] == "pipe:1"
assert captured["list_contents"] == f"file '{first}'\nfile '{second}'\n"
assert not captured["list_path"].exists()
assert not list(cache.glob("*.mp4"))
def test_combined_video_stream_has_a_hard_timeout(monkeypatch, tmp_path):
cache = tmp_path / "video_cache"
source = tmp_path / "first.hevc"
source.write_bytes(b"first")
monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache)
monkeypatch.setattr(utilities, "VIDEO_STREAM_TIMEOUT_SECONDS", 0.01)
class HangingStdout:
def read(self, _):
time.sleep(10)
return b""
def close(self):
pass
class HangingProcess:
def __init__(self):
self.stdout = HangingStdout()
self.returncode = None
self.terminated = False
def poll(self):
return self.returncode
def terminate(self):
self.terminated = True
self.returncode = -15
def wait(self, timeout=None):
del timeout
return self.returncode
process = HangingProcess()
monkeypatch.setattr(utilities.subprocess, "Popen", lambda *args, **kwargs: process)
with pytest.raises(TimeoutError, match="Timed out streaming"):
b"".join(utilities.ffmpeg_stream_concatenated_mp4([source], chunk_size=4))
assert process.terminated
assert not list(cache.glob("route-download-*.txt"))
def test_route_endpoints_reject_invalid_names(monkeypatch, tmp_path):
client = _make_client(monkeypatch, tmp_path)
for method, path in (
(client.delete, "/api/routes/not-a-route"),
(client.post, "/api/routes/not-a-route/preserve"),
(client.delete, "/api/routes/not-a-route/preserve"),
(client.get, "/api/routes/not-a-route"),
(client.get, "/video/not-a-route/combined"),
):
assert method(path).status_code == 400
def _stub_remux(monkeypatch, tmp_path, payload=b"wrapped-video"):
"""Stand in for the ffmpeg remux, returning a real file so send_file can stream it."""
wrapped = tmp_path / "wrapped.mp4"
wrapped.write_bytes(payload)
monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_to_path", lambda path: wrapped)
return wrapped
def test_sparse_route_metadata_and_video_downloads(monkeypatch, tmp_path):
segments = [_make_segment(tmp_path, segment_num=number) for number in (0, 3, 11)]
for segment in segments:
(segment / "fcamera.hevc").write_bytes(b"hevc")
monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc))
_stub_remux(monkeypatch, tmp_path)
monkeypatch.setattr(utilities, "ffmpeg_stream_concatenated_mp4", lambda paths: iter((b"combined-", b"video")))
client = _make_client(monkeypatch, tmp_path)
metadata = client.get(f"/api/routes/{ROUTE_NAME}")
assert metadata.status_code == 200
assert metadata.get_json()["segment_urls"] == [f"/video/{ROUTE_NAME}--{number}" for number in (0, 3, 11)]
# One minute per segment, without probing each one with ffprobe.
assert metadata.get_json()["total_duration"] == 180
with client.get(f"/video/{ROUTE_NAME}--3?camera=forward") as segment_video:
assert segment_video.status_code == 200
assert segment_video.mimetype == "video/mp4"
assert segment_video.data == b"wrapped-video"
with client.get(f"/video/{ROUTE_NAME}/combined?camera=forward") as combined_video:
assert combined_video.status_code == 200
assert combined_video.mimetype == "video/mp4"
assert combined_video.data == b"combined-video"
assert combined_video.headers["X-Accel-Buffering"] == "no"
def test_route_metadata_never_probes_segments_with_ffprobe(monkeypatch, tmp_path):
"""Probing each segment put one subprocess per segment in front of playback."""
for number in (0, 1, 2):
segment = _make_segment(tmp_path, segment_num=number)
(segment / "fcamera.hevc").write_bytes(b"hevc")
def explode(path):
raise AssertionError(f"ffprobe must stay off the route metadata path: {path}")
monkeypatch.setattr(utilities, "get_video_duration", explode)
monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc))
client = _make_client(monkeypatch, tmp_path)
metadata = client.get(f"/api/routes/{ROUTE_NAME}")
assert metadata.status_code == 200
assert metadata.get_json()["total_duration"] == 180
def test_low_quality_serves_the_wrapped_qcamera_preview(monkeypatch, tmp_path):
"""qcamera.ts is tiny, but it still needs the mp4 wrap - MPEG-TS will not play in a <video>."""
segment = _make_segment(tmp_path, segment_num=0)
(segment / "fcamera.hevc").write_bytes(b"hevc")
(segment / "qcamera.ts").write_bytes(b"ts")
preview_mp4 = tmp_path / "preview.mp4"
preview_mp4.write_bytes(b"preview-video")
full_mp4 = tmp_path / "full.mp4"
full_mp4.write_bytes(b"full-video")
wrapped = []
def wrap(path):
wrapped.append(os.path.basename(str(path)))
return preview_mp4 if str(path).endswith("qcamera.ts") else full_mp4
monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_to_path", wrap)
client = _make_client(monkeypatch, tmp_path)
with client.get(f"/video/{ROUTE_NAME}--0?camera=forward&quality=low") as low:
assert low.status_code == 200
assert low.mimetype == "video/mp4"
assert low.data == b"preview-video"
with client.get(f"/video/{ROUTE_NAME}--0?camera=forward") as full:
assert full.data == b"full-video"
assert wrapped == ["qcamera.ts", "fcamera.hevc"]
def test_low_quality_falls_through_to_the_full_stream_when_qcamera_is_missing(monkeypatch, tmp_path):
"""The player always asks for the preview, so a missing one must never be an error."""
segment = _make_segment(tmp_path, segment_num=0)
(segment / "fcamera.hevc").write_bytes(b"hevc")
_stub_remux(monkeypatch, tmp_path)
client = _make_client(monkeypatch, tmp_path)
with client.get(f"/video/{ROUTE_NAME}--0?camera=forward&quality=low") as low:
assert low.status_code == 200
assert low.data == b"wrapped-video"
def test_low_quality_falls_through_when_the_preview_cannot_be_wrapped(monkeypatch, tmp_path):
segment = _make_segment(tmp_path, segment_num=0)
(segment / "fcamera.hevc").write_bytes(b"hevc")
(segment / "qcamera.ts").write_bytes(b"ts")
full_mp4 = tmp_path / "full.mp4"
full_mp4.write_bytes(b"full-video")
def wrap(path):
if str(path).endswith("qcamera.ts"):
raise ValueError("corrupt preview")
return full_mp4
monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_to_path", wrap)
client = _make_client(monkeypatch, tmp_path)
with client.get(f"/video/{ROUTE_NAME}--0?camera=forward&quality=low") as low:
assert low.status_code == 200
assert low.data == b"full-video"
def test_only_the_road_camera_has_a_preview(monkeypatch, tmp_path):
segment = _make_segment(tmp_path, segment_num=0)
(segment / "ecamera.hevc").write_bytes(b"hevc")
(segment / "qcamera.ts").write_bytes(b"ts")
_stub_remux(monkeypatch, tmp_path)
client = _make_client(monkeypatch, tmp_path)
with client.get(f"/video/{ROUTE_NAME}--0?camera=wide&quality=low") as wide:
assert wide.data == b"wrapped-video"
def test_preview_timeout_does_not_wait_again_for_the_full_stream(monkeypatch, tmp_path):
segment = _make_segment(tmp_path, segment_num=0)
(segment / "fcamera.hevc").write_bytes(b"hevc")
(segment / "qcamera.ts").write_bytes(b"ts")
calls = []
def not_ready(path):
calls.append(Path(path).name)
return None
monkeypatch.setattr(the_galaxy, "_get_or_create_segment_mp4", not_ready)
client = _make_client(monkeypatch, tmp_path)
response = client.get(f"/video/{ROUTE_NAME}--0?camera=forward&quality=low")
assert response.status_code == 503
assert calls == ["qcamera.ts"]
def test_in_progress_segment_is_not_playable(monkeypatch, tmp_path):
segment = _make_segment(tmp_path, segment_num=0)
(segment / "fcamera.hevc").write_bytes(b"hevc")
(segment / "qcamera.ts").write_bytes(b"ts")
(segment / "rlog.lock").touch()
client = _make_client(monkeypatch, tmp_path)
response = client.get(f"/video/{ROUTE_NAME}--0?camera=forward&quality=low")
assert response.status_code == 409
assert "still being recorded" in response.get_json()["error"]
def test_completed_segment_remux_reuses_the_disk_cache(monkeypatch, tmp_path):
source = tmp_path / "fcamera.hevc"
source.write_bytes(b"hevc")
os.utime(source, (1000, 1000))
cache = tmp_path / "video_cache"
monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache)
calls = []
def wrap(command, check, timeout):
calls.append(timeout)
Path(command[-1]).write_bytes(b"mp4")
monkeypatch.setattr(utilities.subprocess, "run", wrap)
first = utilities.ffmpeg_mp4_wrap_to_path(source)
second = utilities.ffmpeg_mp4_wrap_to_path(source)
assert first == second
assert len(calls) == 1
assert 0 < calls[0] <= utilities.VIDEO_REMUX_TIMEOUT_SECONDS
def test_segment_remux_timeout_is_bounded_and_removes_partial_output(monkeypatch, tmp_path):
source = tmp_path / "fcamera.hevc"
source.write_bytes(b"hevc")
cache = tmp_path / "video_cache"
monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache)
timeouts = []
def timeout(command, check, timeout):
timeouts.append(timeout)
Path(command[-1]).write_bytes(b"partial")
raise subprocess.TimeoutExpired(command, timeout)
monkeypatch.setattr(utilities.subprocess, "run", timeout)
with pytest.raises(ValueError, match="Timed out processing video file"):
utilities.ffmpeg_mp4_wrap_to_path(source)
assert len(timeouts) == 1
assert 0 < timeouts[0] <= utilities.VIDEO_REMUX_TIMEOUT_SECONDS
assert not list(cache.glob("*.mp4"))
def test_segment_video_falls_back_across_cameras(monkeypatch, tmp_path):
segment = _make_segment(tmp_path, segment_num=0)
(segment / "fcamera.hevc").write_bytes(b"hevc")
_stub_remux(monkeypatch, tmp_path)
client = _make_client(monkeypatch, tmp_path)
with client.get(f"/video/{ROUTE_NAME}--0?camera=forward") as forward:
assert forward.data == b"wrapped-video"
# No ecamera.hevc on disk for this segment.
assert client.get(f"/video/{ROUTE_NAME}--0?camera=wide").status_code == 404
assert client.get("/video/not-a-segment?camera=forward").status_code == 400
def test_segment_video_supports_range_requests(monkeypatch, tmp_path):
segment = _make_segment(tmp_path, segment_num=0)
(segment / "fcamera.hevc").write_bytes(b"hevc")
_stub_remux(monkeypatch, tmp_path, payload=b"0123456789")
client = _make_client(monkeypatch, tmp_path)
with client.get(f"/video/{ROUTE_NAME}--0?camera=forward", headers={"Range": "bytes=2-5"}) as partial:
assert partial.status_code == 206
assert partial.data == b"2345"
assert partial.headers["Content-Range"] == "bytes 2-5/10"
# A malformed range used to raise inside the hand-rolled parser.
with client.get(f"/video/{ROUTE_NAME}--0?camera=forward", headers={"Range": "bytes=abc"}) as malformed_range:
assert malformed_range.status_code in (200, 416)
def test_head_request_prepares_full_quality_without_sending_the_body(monkeypatch, tmp_path):
segment = _make_segment(tmp_path, segment_num=0)
(segment / "fcamera.hevc").write_bytes(b"hevc")
_stub_remux(monkeypatch, tmp_path)
client = _make_client(monkeypatch, tmp_path)
with client.head(f"/video/{ROUTE_NAME}--0?camera=forward") as prepared:
assert prepared.status_code == 200
assert prepared.mimetype == "video/mp4"
assert prepared.data == b""
def test_concurrent_requests_for_one_segment_share_a_single_remux(monkeypatch, tmp_path):
segment = _make_segment(tmp_path, segment_num=0)
(segment / "fcamera.hevc").write_bytes(b"hevc")
wrapped = tmp_path / "wrapped.mp4"
wrapped.write_bytes(b"wrapped-video")
calls = []
started = threading.Event()
def slow_remux(path):
calls.append(path)
started.set()
time.sleep(0.3)
return wrapped
monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_to_path", slow_remux)
client = _make_client(monkeypatch, tmp_path)
results = []
def fetch():
with client.get(f"/video/{ROUTE_NAME}--0?camera=forward") as response:
results.append(response.status_code)
threads = [threading.Thread(target=fetch) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert results == [200, 200, 200, 200]
assert len(calls) == 1
@@ -0,0 +1,426 @@
"""Covers assets/components/recordings/dashcam_routes_helpers.js.
The helpers are browser ES modules, so pytest drives them through node rather than
re-implementing the date/sort/grouping rules in Python. Snippets run with helper
exports in scope and return JSON, which keeps every assertion here in pytest.
"""
import json
import os
from pathlib import Path
import shutil
import subprocess
import pytest
HELPERS_PATH = Path(__file__).resolve().parent.parent / "assets" / "components" / "recordings" / "dashcam_routes_helpers.js"
COMPONENT_PATH = HELPERS_PATH.with_name("dashcam_routes.js")
COMPONENT_CSS_PATH = HELPERS_PATH.with_name("dashcam_routes.css")
# node infers ESM from `export` syntax in a bare .js file from 22.7 on, so the helpers
# need no package.json and stay a normal asset next to the component that imports them.
MIN_NODE_MAJOR = 23
HARNESS = f'''
import * as helpers from {json.dumps(HELPERS_PATH.as_uri())}
const run = new Function(...Object.keys(helpers), process.env.DASHCAM_HELPER_SNIPPET)
process.stdout.write(JSON.stringify(run(...Object.values(helpers)) ?? null))
'''
PRELUDE = '''
const route = (name, startedAt, extra = {}) => normalizeRoute({
name,
startedAt,
timestamp: startedAt,
segmentCount: 1,
approxDurationSeconds: 60,
is_preserved: false,
...extra,
}, "en-US")
'''
def _node_binary():
node = shutil.which("node")
if node is None:
pytest.skip("node is not installed")
version = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=30).stdout.strip()
try:
major = int(version.lstrip("v").split(".")[0])
except ValueError:
pytest.skip(f"could not read node version from {version!r}")
if major < MIN_NODE_MAJOR:
pytest.skip(f"node {version} cannot import a bare .js ES module; need v{MIN_NODE_MAJOR}+")
return node
def evaluate(snippet):
"""Run a snippet with the helper exports in scope and return its JSON value."""
node = _node_binary()
# Fixed TZ so "Today"/"Yesterday" grouping does not depend on the developer's clock.
environment = {**os.environ, "TZ": "UTC", "DASHCAM_HELPER_SNIPPET": PRELUDE + snippet}
result = subprocess.run([node, "--input-type=module"], input=HARNESS, env=environment,
capture_output=True, text=True, timeout=60)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_helpers_module_is_a_plain_js_asset():
assert HELPERS_PATH.is_file()
assert not list(HELPERS_PATH.parent.glob("*.mjs"))
def test_route_titles_and_custom_name_badges_are_reactive():
source = COMPONENT_PATH.read_text(encoding="utf-8")
# Both grid and row views must subscribe directly to the renamed route fields.
assert source.count('${() => route.displayName}') >= 4
assert source.count('${() => route.isCustomName ? html`') >= 4
def test_sort_order_select_and_route_items_are_keyed_and_reactive():
source = COMPONENT_PATH.read_text(encoding="utf-8")
assert '<select value="${() => state.sortOrder}" @input="${changeSortOrder}" @change="${changeSortOrder}">' in source
assert 'data-view-key="${renderKey}"' in source
assert ".key(group.key)" in source
assert ".key(route.name)" in source
def test_player_shell_keeps_both_quality_levels_at_one_fixed_size():
source = COMPONENT_CSS_PATH.read_text(encoding="utf-8")
component = COMPONENT_PATH.read_text(encoding="utf-8")
assert "aspect-ratio: 526 / 330;" in source
assert "contain: layout paint;" in source
assert "height: 100% !important;" in source
assert "width: 100% !important;" in source
assert "object-fit: cover;" in source
assert ".dashcam-video-shell.qcamera-framing video" in source
assert "object-fit: fill;" in source
assert 'videoShell.classList.toggle("qcamera-framing", showingPreview)' in component
assert "deferNativeControlsUntilInteraction(stagingVideo)" in component
assert "stagingVideo.controls = true" not in component
def test_groups_routes_into_today_yesterday_dates_and_unknown():
groups = evaluate('''
const routes = [
route("today", "2026-08-26T08:00:00Z"),
route("yesterday", "2026-08-25T08:00:00Z"),
route("older", "2026-08-20T08:00:00Z"),
route("unknown", null, { timestamp: null }),
]
return groupRoutesByDate(routes, new Date("2026-08-26T12:00:00Z"), "en-US")
.map(group => [group.label, group.routes[0].name])
''')
assert groups == [
["Today", "today"],
["Yesterday", "yesterday"],
["August 20, 2026", "older"],
["Unknown date", "unknown"],
]
def test_sorts_newest_and_oldest_while_leaving_unknown_dates_last():
order = evaluate('''
const routes = [
route("middle", "2026-08-20T08:00:00Z"),
route("unknown", null, { timestamp: null }),
route("new", "2026-08-26T08:00:00Z"),
route("old", "2026-08-10T08:00:00Z"),
]
return {
newest: sortRoutes(routes, "newest").map(item => item.name),
oldest: sortRoutes(routes, "oldest").map(item => item.name),
}
''')
assert order["newest"] == ["new", "middle", "old", "unknown"]
assert order["oldest"] == ["old", "middle", "new", "unknown"]
def test_searches_custom_names_displayed_dates_and_route_ids():
matches = evaluate('''
const custom = route("0000006a--9f0a7bdf9c", "2026-08-26T08:00:00Z", {
timestamp: "Morning school run",
isCustomName: true,
})
return ["school", "August 26", "9f0a7b", "evening"]
.map(searchQuery => buildRouteView([custom], { searchQuery }).matching.length)
''')
assert matches == [1, 1, 1, 0]
def test_search_matches_partial_title_tokens_and_friendly_dates_as_the_user_types():
result = evaluate('''
const routes = [
route("0000006a--9f0a7bdf9c", "2026-08-27T08:00:00Z", { timestamp: "Test_31", isCustomName: true }),
route("0000006b--9f0a7bdf9d", "2026-08-28T08:00:00Z", { timestamp: "Morning drive", isCustomName: true }),
route("0000006c--9f0a7bdf9e", "2026-09-27T08:00:00Z", { timestamp: "Test_4", isCustomName: true }),
]
return ["test", "test 3", "aug", "aug 2", "aug 27th", "8/27"]
.map(searchQuery => buildRouteView(routes, { searchQuery }).matching.map(item => item.name))
''')
assert result == [
["0000006c--9f0a7bdf9e", "0000006a--9f0a7bdf9c"],
["0000006a--9f0a7bdf9c"],
["0000006b--9f0a7bdf9d", "0000006a--9f0a7bdf9c"],
["0000006b--9f0a7bdf9d", "0000006a--9f0a7bdf9c"],
["0000006a--9f0a7bdf9c"],
["0000006a--9f0a7bdf9c"],
]
def test_partial_day_does_not_match_the_four_digit_year():
result = evaluate('''
const routes = Array.from({ length: 9 }, (_, offset) => {
const day = 19 + offset
return route(`aug-${day}`, `2026-08-${day}T12:00:00Z`)
})
return {
partial: buildRouteView(routes, { searchQuery: "aug 2" }).matching.map(item => item.name),
complete: buildRouteView(routes, { searchQuery: "aug 20" }).matching.map(item => item.name),
}
''')
assert result == {
"partial": ["aug-27", "aug-26", "aug-25", "aug-24", "aug-23", "aug-22", "aug-21", "aug-20"],
"complete": ["aug-20"],
}
def test_route_search_input_updates_on_every_keystroke():
source = COMPONENT_PATH.read_text(encoding="utf-8")
assert '@input="${event => { state.searchQuery = event.target.value }}"' in source
def test_route_summary_only_shows_loading_or_active_search_status():
source = COMPONENT_PATH.read_text(encoding="utf-8")
assert "const hasActiveSearch = Boolean(state.searchQuery.trim())" in source
assert '${state.loading || hasActiveSearch ? html`' in source
assert '${hasActiveSearch ? html`<span>${view.matching.length} matching drive' in source
assert '${state.loading ? html`<span>Loading routes</span>` : ""}' in source
assert "Loading ${state.progress} of ${state.total}" not in source
assert "total local" not in source
def test_search_indexes_the_displayed_time_for_every_route():
result = evaluate('''
const routes = [
route("0000006a--9f0a7bdf9c", "2026-08-27T12:15:00Z"),
route("0000006b--9f0a7bdf9d", "2026-08-27T12:50:00Z"),
route("0000006c--9f0a7bdf9e", "2026-08-27T13:05:00Z"),
]
return ["12", "12:5", "12:15"]
.map(searchQuery => buildRouteView(routes, { searchQuery }).matching.map(item => item.name))
''')
assert result == [
["0000006b--9f0a7bdf9d", "0000006a--9f0a7bdf9c"],
["0000006b--9f0a7bdf9d"],
["0000006a--9f0a7bdf9c"],
]
def test_short_numeric_search_does_not_match_hidden_ids_or_unrelated_dates():
result = evaluate('''
const routes = [
route("00000012--9f0a7bdf9c", "2026-08-27T13:05:00Z", { timestamp: "Morning drive", isCustomName: true }),
route("0000006b--9f0a7bdf9d", "2026-12-12T13:05:00Z", { timestamp: "Afternoon drive", isCustomName: true }),
route("0000006c--9f0a7bdf9e", "2026-08-27T13:05:00Z", { timestamp: "Test_12", isCustomName: true }),
]
return {
plainNumber: buildRouteView(routes, { searchQuery: "12" }).matching.map(item => item.name),
ordinalDate: buildRouteView(routes, { searchQuery: "dec 12th" }).matching.map(item => item.name),
explicitId: buildRouteView(routes, { searchQuery: "00000012" }).matching.map(item => item.name),
}
''')
assert result == {
"plainNumber": ["0000006c--9f0a7bdf9e"],
"ordinalDate": ["0000006b--9f0a7bdf9d"],
"explicitId": ["00000012--9f0a7bdf9c"],
}
def test_filters_preserved_routes_before_applying_the_render_limit():
view = evaluate('''
const routes = Array.from({ length: MAX_RENDERED_ROUTES + 25 }, (_, index) => route(
`route-${index}`,
new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString(),
{ is_preserved: index % 2 === 0 },
))
const all = buildRouteView(routes)
const preserved = buildRouteView(routes, { preservedOnly: true })
return {
limit: MAX_RENDERED_ROUTES,
all: [all.matching.length, all.visible.length, all.truncated],
preserved: [preserved.matching.length, preserved.visible.length, preserved.truncated],
allPreserved: preserved.visible.every(item => item.is_preserved),
}
''')
assert view["limit"] == 250
assert view["all"] == [275, 250, True]
assert view["preserved"] == [138, 138, False]
assert view["allPreserved"] is True
def test_duration_sorts_render_as_one_flat_group():
result = evaluate("""
const routes = [
{ name: "a", _startedAtMs: Date.parse("2026-08-20T10:00:00Z"), approxDurationSeconds: 3000 },
{ name: "b", _startedAtMs: Date.parse("2026-08-22T10:00:00Z"), approxDurationSeconds: 2700 },
{ name: "c", _startedAtMs: Date.parse("2026-08-21T10:00:00Z"), approxDurationSeconds: 1800 },
]
const view = buildRouteView(routes, { sortOrder: "longest" })
const groups = groupRoutesForView(view.visible, "longest", new Date("2026-08-27T12:00:00Z"), "en-US")
return { labels: groups.map(g => g.label), order: groups.flatMap(g => g.routes.map(r => r.name)) }
""")
assert result["labels"] == ["Longest first"]
assert result["order"] == ["a", "b", "c"]
def test_date_sorts_still_group_by_day():
labels = evaluate("""
const routes = [
{ name: "a", _startedAtMs: Date.parse("2026-08-20T10:00:00Z"), approxDurationSeconds: 3000 },
{ name: "b", _startedAtMs: Date.parse("2026-08-22T10:00:00Z"), approxDurationSeconds: 2700 },
]
const view = buildRouteView(routes, { sortOrder: "newest" })
return groupRoutesForView(view.visible, "newest", new Date("2026-08-27T12:00:00Z"), "en-US").map(g => g.label)
""")
assert labels == ["August 22, 2026", "August 20, 2026"]
def test_route_view_render_key_changes_with_displayed_order_and_mode():
result = evaluate('''
const routes = [{ name: "a" }, { name: "b" }]
return [
routeViewRenderKey(routes, "newest", "list"),
routeViewRenderKey([...routes].reverse(), "oldest", "list"),
routeViewRenderKey(routes, "newest", "grid"),
]
''')
assert result == ["list:newest:a,b", "list:oldest:b,a", "grid:newest:a,b"]
def test_grouping_an_empty_list_yields_no_groups():
assert evaluate("""
return [
groupRoutesForView([], "longest").length,
groupRoutesForView([], "newest").length,
]
""") == [0, 0]
def test_segment_status_reports_the_stored_segment_number():
statuses = evaluate('''
const segments = [
"/video/0000006a--9f0a7bdf9c--0",
"/video/0000006a--9f0a7bdf9c--3",
"/video/0000006a--9f0a7bdf9c--11",
]
return segments.map((_, index) => getSegmentStatus(segments, index))
''')
assert statuses == ["Segment 0", "Segment 3", "Segment 11"]
def test_segment_options_label_every_clip_for_the_jump_picker():
options = evaluate("""
return getSegmentOptions([
"/video/0000006a--9f0a7bdf9c--12",
"/video/0000006a--9f0a7bdf9c--13",
"/video/not-a-segment",
])
""")
assert options == [
{"index": 0, "label": "Segment 12"},
{"index": 1, "label": "Segment 13"},
{"index": 2, "label": "Clip 3"},
]
def test_segment_options_tolerate_a_missing_segment_list():
assert evaluate("return [getSegmentOptions(undefined), getSegmentOptions([])]") == [[], []]
def test_hides_segment_status_when_the_stored_number_is_unsafe():
results = evaluate('''
return [
parseStoredSegmentNumber("/video/route--9007199254740992"),
getSegmentStatus(["/video/not-a-segment"], 0),
getSegmentStatus(undefined, 0),
]
''')
assert results == [None, "", ""]
def test_camera_video_url_carries_an_optional_quality_tier():
result = evaluate("""
const segment = "/video/0000006a--9f0a7bdf9c--7"
return { full: cameraVideoUrl(segment, "forward"), low: cameraVideoUrl(segment, "forward", "low") }
""")
assert result["full"] == "/video/0000006a--9f0a7bdf9c--7?camera=forward"
assert result["low"] == "/video/0000006a--9f0a7bdf9c--7?camera=forward&quality=low"
def test_route_metadata_errors_explain_missing_local_segments():
result = evaluate('''
return {
missing: routeMetadataErrorMessage(404, "Route not found"),
backend: routeMetadataErrorMessage(400, "Invalid route name"),
fallback: routeMetadataErrorMessage(503),
}
''')
assert result == {
"missing": "This route is no longer available on this device. Its local video segments may have been deleted or moved.",
"backend": "Invalid route name",
"fallback": "Could not load route details (503).",
}
def test_only_the_road_camera_has_a_preview():
"""loggerd writes qcamera.ts alongside the road camera only."""
assert evaluate('return ["forward", "wide", "driver"].map(supportsLowQuality)') == [True, False, False]
def test_upgrade_decision_only_trusts_a_positively_tall_frame():
"""The server falls back to the full stream, so the frame size is what settles it."""
assert evaluate("""
return {
qcamera: shouldUpgradeFromHeight(330),
full: shouldUpgradeFromHeight(1080),
unknown: shouldUpgradeFromHeight(0),
missing: shouldUpgradeFromHeight(undefined),
}
""") == {"qcamera": True, "full": False, "unknown": True, "missing": True}
def test_switching_camera_changes_only_the_url_and_not_segment_status():
result = evaluate('''
const segments = ["/video/0000006a--9f0a7bdf9c--7"]
const before = getSegmentStatus(segments, 0)
return {
url: cameraVideoUrl(segments[0], "driver"),
unchanged: getSegmentStatus(segments, 0) === before,
}
''')
assert result["url"] == "/video/0000006a--9f0a7bdf9c--7?camera=driver"
assert result["unchanged"] is True
@@ -0,0 +1,111 @@
import io
import tarfile
from test_dashboard_stats import MODULE_DIR, _install_server_import_stubs
def _load_server_module():
import importlib.util
import sys
_install_server_import_stubs()
spec = importlib.util.spec_from_file_location("route_logs_server", MODULE_DIR / "the_galaxy.py")
module = importlib.util.module_from_spec(spec)
sys.modules["route_logs_server"] = module
spec.loader.exec_module(module)
return module
the_galaxy = _load_server_module()
def _make_route(root, name, segments, filename="rlog.zst", size=32):
for segment_num in segments:
segment_dir = root / f"{name}--{segment_num}"
segment_dir.mkdir(parents=True)
(segment_dir / filename).write_bytes(bytes([segment_num]) * size)
# a sibling that must never be offered as a full log
(segment_dir / "qlog.zst").write_bytes(b"q")
def _use_footage_root(monkeypatch, root):
monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(root) + "/"])
def test_route_log_files_are_ordered_numerically(monkeypatch, tmp_path):
_make_route(tmp_path, "0000006a--9f0a7bdf9c", [0, 1, 2, 10])
_use_footage_root(monkeypatch, tmp_path)
logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c")
# 10 must sort after 2, not lexically between 1 and 2
assert [segment for segment, _, _, _ in logs] == [
"0000006a--9f0a7bdf9c--0",
"0000006a--9f0a7bdf9c--1",
"0000006a--9f0a7bdf9c--2",
"0000006a--9f0a7bdf9c--10",
]
assert {filename for _, filename, _, _ in logs} == {"rlog.zst"}
assert [size for _, _, _, size in logs] == [32, 32, 32, 32]
def test_route_log_files_prefers_newest_available_format(monkeypatch, tmp_path):
_make_route(tmp_path, "0000006a--9f0a7bdf9c", [0], filename="rlog.bz2")
(tmp_path / "0000006a--9f0a7bdf9c--0" / "rlog.zst").write_bytes(b"zstd")
_use_footage_root(monkeypatch, tmp_path)
logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c")
assert [filename for _, filename, _, _ in logs] == ["rlog.zst"]
def test_route_log_files_rejects_names_that_are_not_routes(monkeypatch, tmp_path):
_make_route(tmp_path, "0000006a--9f0a7bdf9c", [0])
_use_footage_root(monkeypatch, tmp_path)
for name in ("", None, "..", "../..", "0000006a--9f0a7bdf9c--0", "0000006a--9f0a7bdf9cx", "/etc"):
assert the_galaxy._route_log_files(name) == [], name
def test_route_log_files_skips_segments_without_logs(monkeypatch, tmp_path):
_make_route(tmp_path, "0000006a--9f0a7bdf9c", [0, 1])
(tmp_path / "0000006a--9f0a7bdf9c--1" / "rlog.zst").unlink()
_use_footage_root(monkeypatch, tmp_path)
logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c")
assert [segment for segment, _, _, _ in logs] == ["0000006a--9f0a7bdf9c--0"]
def test_tar_buffer_hands_back_each_write_once():
buffer = the_galaxy._TarBuffer()
buffer.write(b"one")
buffer.write(b"two")
assert buffer.pop() == b"onetwo"
assert buffer.pop() == b""
def test_streamed_archive_is_a_readable_tar(monkeypatch, tmp_path):
_make_route(tmp_path, "0000006a--9f0a7bdf9c", [0, 1], size=4096)
_use_footage_root(monkeypatch, tmp_path)
logs = the_galaxy._route_log_files("0000006a--9f0a7bdf9c")
buffer = the_galaxy._TarBuffer()
chunks = []
with tarfile.open(fileobj=buffer, mode="w|") as archive:
for segment, filename, path, _ in logs:
archive.add(path, arcname=f"{segment}/{filename}")
chunks.append(buffer.pop())
chunks.append(buffer.pop())
# more than one chunk means a long route never has to be buffered whole
assert sum(1 for chunk in chunks if chunk) > 1
with tarfile.open(fileobj=io.BytesIO(b"".join(chunks)), mode="r:") as archive:
assert archive.getnames() == [
"0000006a--9f0a7bdf9c--0/rlog.zst",
"0000006a--9f0a7bdf9c--1/rlog.zst",
]
assert archive.extractfile("0000006a--9f0a7bdf9c--1/rlog.zst").read() == bytes([1]) * 4096
@@ -0,0 +1,100 @@
from types import SimpleNamespace
from test_navigation_params import _params_client, the_galaxy
class _FakeCarParams:
class SafetyModel:
toyota = 42
def __init__(self, brand="toyota", car_name="toyota"):
self.brand = brand
self.carName = car_name
def __enter__(self):
return self
def __exit__(self, *args):
return False
class _FakePanda:
instances = []
def __init__(self, **kwargs):
self.kwargs = kwargs
self.safety_modes = []
self.commands = []
self.instances.append(self)
def __enter__(self):
return self
def __exit__(self, *args):
return False
def set_safety_mode(self, mode):
self.safety_modes.append(mode)
def can_send(self, address, data, bus):
self.commands.append((address, data, bus))
def _install_door_stubs(monkeypatch, client, params, status_values):
del client
_FakePanda.instances = []
monkeypatch.setattr(the_galaxy.car.CarParams, "from_bytes", lambda _: _FakeCarParams())
monkeypatch.setattr(the_galaxy.car.CarParams, "SafetyModel", _FakeCarParams.SafetyModel, raising=False)
monkeypatch.setattr(the_galaxy, "Panda", _FakePanda)
monkeypatch.setattr(the_galaxy, "CANParser", lambda *args, **kwargs: SimpleNamespace())
monkeypatch.setattr(the_galaxy.messaging, "sub_sock", lambda *args, **kwargs: object())
monkeypatch.setattr(the_galaxy, "get_lock_status", lambda *args: status_values.pop(0))
monkeypatch.setattr(the_galaxy.time, "sleep", lambda _: None)
params.values["IsOnroad"] = False
def test_door_lock_rejects_onroad(monkeypatch):
client, params = _params_client(monkeypatch, {"IsOnroad": True}, "pc")
_install_door_stubs(monkeypatch, client, params, [])
params.values["IsOnroad"] = True
response = client.post("/api/doors/lock")
assert response.status_code == 409
assert not _FakePanda.instances
def test_door_lock_reports_success_only_after_confirmation(monkeypatch):
client, params = _params_client(monkeypatch, {"IsOnroad": False}, "pc")
_install_door_stubs(monkeypatch, client, params, [1, 0])
response = client.post("/api/doors/lock")
assert response.status_code == 200
assert response.get_json() == {"message": "Doors locked!"}
assert len(_FakePanda.instances) == 2
assert all(len(instance.commands) == 2 for instance in _FakePanda.instances)
assert all(instance.safety_modes == [42] for instance in _FakePanda.instances)
def test_door_unlock_reports_failure_after_bounded_retries(monkeypatch):
client, params = _params_client(monkeypatch, {"IsOnroad": False}, "pc")
_install_door_stubs(monkeypatch, client, params, [0] * 6)
response = client.post("/api/doors/unlock")
assert response.status_code == 502
assert response.get_json() == {"error": "Unable to confirm that the doors were unlocked."}
assert len(_FakePanda.instances) == 6
def test_door_feature_is_toyota_only(monkeypatch):
client, params = _params_client(monkeypatch, {}, "tici")
monkeypatch.setattr(the_galaxy.car.CarParams, "from_bytes", lambda _: _FakeCarParams(brand="honda", car_name="honda"))
response = client.get("/api/car_features_check?tool=doors")
assert response.status_code == 200
assert response.get_json() == {"result": False}
del params
+471 -150
View File
@@ -10,6 +10,7 @@ import sys
import sysconfig
import tarfile
import io
from io import BytesIO
from pathlib import Path
@@ -1116,6 +1117,28 @@ def _get_toggle_backup_keys():
return keys
def _route_log_files(name):
"""Full logs for a route as [(segment, filename, path, size)], oldest segment first."""
if not utilities.ROUTE_RE.fullmatch(str(name or "")):
return []
for footage_path in FOOTAGE_PATHS:
logs = []
try:
segments = utilities.get_segments_in_route(name, footage_path)
except OSError:
continue
for segment in sorted(segments, key=lambda s: int(s.rsplit("--", 1)[1])):
for filename in ROUTE_LOG_CANDIDATES:
path = os.path.join(footage_path, segment, filename)
if os.path.isfile(path):
logs.append((segment, filename, path, os.path.getsize(path)))
break
if logs:
return logs
return []
def _coerce_toggle_restore_value(key, value):
value_type = _get_param_key_type(_params_raw, key)
@@ -1192,6 +1215,206 @@ except TypeError:
str(Paths.log_root()),
]
# Full drive logs, newest format first. comma only accepts qlog/qcamera uploads, so these come off the device directly.
ROUTE_LOG_CANDIDATES = ("rlog.zst", "rlog.bz2", "rlog")
ROUTE_METADATA_WORKERS = 4
ROUTE_METADATA_BATCH_SIZE = 8
ROUTE_THUMBNAIL_CACHE_SECONDS = 7 * 24 * 60 * 60
# Browsers only allow a handful of connections per origin, so a request must never
# park on the preview queue: give up and let the card fall back, the job keeps running.
ROUTE_THUMBNAIL_WAIT_SECONDS = 25
# One minute per segment, matching loggerd's segment length.
SEGMENT_DURATION_SECONDS = 60
# Only ever remux one segment at a time; the driving stack needs the headroom. The
# subprocess timeout is the hard bound, with a small allowance for executor handoff.
VIDEO_REMUX_WAIT_SECONDS = utilities.VIDEO_REMUX_TIMEOUT_SECONDS + 5
# Segment media never changes once loggerd has closed it, so let the browser keep it.
VIDEO_CACHE_SECONDS = 7 * 24 * 60 * 60
_VIDEO_REMUX_EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="video-remux")
_VIDEO_REMUX_FUTURES = {}
_VIDEO_REMUX_LOCK = threading.Lock()
_ROUTE_THUMBNAIL_EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="route-thumbnail")
_ROUTE_THUMBNAIL_FUTURES = {}
_ROUTE_THUMBNAIL_LOCK = threading.Lock()
def _route_scan_entries(footage_paths):
"""Route scan entries in footage-root priority order, deduplicated by route id."""
entries = []
seen_names = set()
for footage_path in footage_paths:
try:
route_details = utilities.get_routes_with_segment_details(footage_path)
except OSError:
continue
for name, details in route_details:
if name in seen_names:
continue
seen_names.add(name)
entries.append((
footage_path,
name,
max(0, int(details.get("segmentCount", 0))),
max(0, int(details.get("firstSegmentNum", 0))),
))
return entries
def _route_metadata_events(entries, connect_dongle_id="", process_route=None):
"""Yield SSE payloads while keeping queued metadata work cancellable."""
route_processor = process_route or utilities.process_route
total = len(entries)
yield {"routes": [], "progress": 0, "total": total, "connectDongleId": connect_dongle_id}
if total == 0:
return
executor = ThreadPoolExecutor(max_workers=ROUTE_METADATA_WORKERS, thread_name_prefix="route-metadata")
futures = []
try:
futures = [
executor.submit(route_processor, path, name, segment_count, first_segment_num)
for path, name, segment_count, first_segment_num in entries
]
batch = []
for processed, future in enumerate(as_completed(futures), start=1):
try:
batch.append(future.result())
except Exception as exception:
print(f"Error processing route: {exception}")
if len(batch) >= ROUTE_METADATA_BATCH_SIZE or processed == total:
yield {"routes": batch, "progress": processed, "total": total}
batch = []
finally:
for future in futures:
future.cancel()
executor.shutdown(wait=False, cancel_futures=True)
def _route_first_segment_path(name, footage_path):
"""Oldest surviving segment of a route. loggerd ages out --0 first, so it is not always --0."""
try:
segments = utilities.get_segments_in_route(name, footage_path)
except OSError:
return None
return os.path.join(footage_path, segments[0]) if segments else None
def _resolve_route_thumbnail(file_path, footage_paths=None):
"""Resolve only <segment>/preview.png below a configured footage root."""
parts = Path(str(file_path or "")).parts
if len(parts) != 2 or parts[1] != "preview.png" or not utilities.SEGMENT_RE.fullmatch(parts[0]):
return None
for footage_path in footage_paths if footage_paths is not None else FOOTAGE_PATHS:
footage_root = Path(footage_path).resolve()
segment_path = (footage_root / parts[0]).resolve()
if segment_path.parent != footage_root or not segment_path.is_dir():
continue
preview_path = segment_path / "preview.png"
if preview_path.is_symlink():
continue
if preview_path.exists():
resolved_preview = preview_path.resolve()
if resolved_preview.parent != segment_path:
continue
return resolved_preview
return preview_path
return None
def _generate_route_thumbnail(preview_path):
if preview_path.is_file():
return preview_path
for filename in ("qcamera.ts", "fcamera.hevc"):
source_path = preview_path.parent / filename
if source_path.resolve().parent == preview_path.parent and source_path.is_file() and utilities.video_to_png(source_path, preview_path) and preview_path.is_file():
return preview_path
return None
def _remove_video_remux_future(key, future):
with _VIDEO_REMUX_LOCK:
if _VIDEO_REMUX_FUTURES.get(key) is future:
_VIDEO_REMUX_FUTURES.pop(key, None)
def _get_or_create_segment_mp4(source_path):
"""Remuxed mp4 for one segment, or None if it is not ready in time.
Concurrent requests share one ffmpeg run instead of racing to write the same file.
"""
key = str(source_path)
created = False
with _VIDEO_REMUX_LOCK:
future = _VIDEO_REMUX_FUTURES.get(key)
if future is None:
future = _VIDEO_REMUX_EXECUTOR.submit(utilities.ffmpeg_mp4_wrap_to_path, source_path)
_VIDEO_REMUX_FUTURES[key] = future
created = True
if created:
future.add_done_callback(lambda completed: _remove_video_remux_future(key, completed))
try:
return future.result(timeout=VIDEO_REMUX_WAIT_SECONDS)
except TimeoutError:
# The callback keeps the running job deduplicated, then evicts it when done.
return None
def _remove_route_thumbnail_future(key, future):
with _ROUTE_THUMBNAIL_LOCK:
if _ROUTE_THUMBNAIL_FUTURES.get(key) is future:
_ROUTE_THUMBNAIL_FUTURES.pop(key, None)
def _get_or_create_route_thumbnail(file_path, footage_paths=None):
preview_path = _resolve_route_thumbnail(file_path, footage_paths)
if preview_path is None:
return None
if preview_path.is_file():
return preview_path
key = str(preview_path)
created = False
with _ROUTE_THUMBNAIL_LOCK:
future = _ROUTE_THUMBNAIL_FUTURES.get(key)
if future is None:
future = _ROUTE_THUMBNAIL_EXECUTOR.submit(_generate_route_thumbnail, preview_path)
_ROUTE_THUMBNAIL_FUTURES[key] = future
created = True
if created:
future.add_done_callback(lambda completed: _remove_route_thumbnail_future(key, completed))
try:
return future.result(timeout=ROUTE_THUMBNAIL_WAIT_SECONDS)
except TimeoutError:
# The completion callback keeps the running job deduplicated, then evicts it when done.
return None
class _TarBuffer(io.RawIOBase):
"""Collects tarfile output so a route archive can be streamed out instead of built on disk."""
def __init__(self):
self._chunks = []
def writable(self):
return True
def write(self, data):
self._chunks.append(bytes(data))
return len(data)
def pop(self):
data = b"".join(self._chunks)
self._chunks.clear()
return data
KEYS = {
"amap1": ("amap1", "", "AMapKey1", "AMap / Gaode key #1", 39),
"amap2": ("amap2", "", "AMapKey2", "AMap / Gaode key #2", 39),
@@ -4682,50 +4905,51 @@ def setup(app):
try:
with car.CarParams.from_bytes(params.get("CarParamsPersistent")) as cp:
if tool == "doors":
return jsonify({"result": HARDWARE.get_device_type() != "tici" and cp.carName == "toyota"})
car_brand = getattr(cp, "brand", getattr(cp, "carName", ""))
return jsonify({"result": car_brand == "toyota"})
elif tool == "tsk":
return jsonify({"result": cp.secOcRequired})
return jsonify({"result": getattr(cp, "secOcRequired", False)})
except Exception:
pass
return jsonify({"result": False})
def _send_door_command(command, should_be_locked, success_message, action):
if params.get_bool("IsOnroad"):
return jsonify({"error": "Door controls are unavailable while driving."}), 409
try:
can_parser = CANParser("toyota_nodsu_pt_generated", [("DOOR_LOCKS", 3)], bus=0)
can_sock = messaging.sub_sock("can", timeout=100)
for _ in range(6):
if params.get_bool("IsOnroad"):
return jsonify({"error": "Door controls are unavailable while driving."}), 409
try:
with Panda(disable_checks=True) as panda:
panda.set_safety_mode(car.CarParams.SafetyModel.toyota)
panda.can_send(0x750, command, 0)
panda.can_send(0x750, command, 1)
except Exception as error:
cloudlog.warning("Galaxy door %s attempt failed: %s", action, error)
continue
time.sleep(1)
lock_status = get_lock_status(can_parser, can_sock)
if (lock_status == 0) == should_be_locked:
return {"message": success_message}, 200
except Exception as error:
cloudlog.exception("Galaxy door %s failed: %s", action, error)
return jsonify({"error": f"Unable to confirm that the doors were {action}ed."}), 502
@app.route("/api/doors/lock", methods=["POST"])
def lock_doors():
can_parser = CANParser("toyota_nodsu_pt_generated", [("DOOR_LOCKS", 3)], bus=0)
can_sock = messaging.sub_sock("can", timeout=100)
while True:
with Panda(disable_checks=True) as panda:
if not params.get_bool("IsOnroad"):
panda.set_safety_mode(panda.SAFETY_TOYOTA)
panda.can_send(0x750, LOCK_CMD, 0)
time.sleep(1)
lock_status = get_lock_status(can_parser, can_sock)
if lock_status == 0:
break
return {"message": "Doors locked!"}
return _send_door_command(LOCK_CMD, True, "Doors locked!", "lock")
@app.route("/api/doors/unlock", methods=["POST"])
def unlock_doors():
can_parser = CANParser("toyota_nodsu_pt_generated", [("DOOR_LOCKS", 3)], bus=0)
can_sock = messaging.sub_sock("can", timeout=100)
while True:
with Panda(disable_checks=True) as panda:
if not params.get_bool("IsOnroad"):
panda.set_safety_mode(panda.SAFETY_TOYOTA)
panda.can_send(0x750, UNLOCK_CMD, 0)
time.sleep(1)
lock_status = get_lock_status(can_parser, can_sock)
if lock_status != 0:
break
return {"message": "Doors unlocked!"}
return _send_door_command(UNLOCK_CMD, False, "Doors unlocked!", "unlock")
@app.route("/api/error_logs", methods=["GET"])
def get_error_logs():
@@ -6121,35 +6345,31 @@ def setup(app):
@app.route("/api/routes", methods=["GET"])
def list_routes():
def generate():
routes = [
(path, name, segment_count)
for path in FOOTAGE_PATHS
for name, segment_count in utilities.get_routes_with_segment_counts(path)
]
total = len(routes)
routes = _route_scan_entries(FOOTAGE_PATHS)
connect_dongle_id = params.get("StockDongleId", encoding="utf-8") or params.get("DongleId", encoding="utf-8") or ""
yield f"data: {json.dumps({'progress': 0, 'total': total, 'connectDongleId': connect_dongle_id})}\n\n"
for payload in _route_metadata_events(routes, connect_dongle_id):
yield f"data: {json.dumps(payload)}\n\n"
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {
executor.submit(utilities.process_route, path, name, segment_count): (path, name)
for path, name, segment_count in routes
}
for processed, future in enumerate(as_completed(futures), start=1):
try:
result = future.result()
yield f"data: {json.dumps({'routes': [result]})}\n\n"
except Exception as exception:
print(f"Error processing route: {exception}")
yield f"data: {json.dumps({'progress': processed, 'total': total})}\n\n"
response = Response(generate(), mimetype="text/event-stream")
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no"
return response
return Response(generate(), mimetype="text/event-stream")
def _valid_route_name(name):
return bool(utilities.ROUTE_RE.fullmatch(str(name or "")))
@app.route("/api/routes/<name>", methods=["DELETE"])
def delete_route(name):
if not _valid_route_name(name):
return jsonify({"error": "Invalid route name."}), 400
segment_prefix = f"{name}--"
for footage_path in FOOTAGE_PATHS:
if not os.path.isdir(footage_path):
continue
for segment in os.listdir(footage_path):
if segment.startswith(name):
if utilities.SEGMENT_RE.fullmatch(segment) and segment.startswith(segment_prefix):
delete_file(os.path.join(footage_path, segment))
return {"message": "Route deleted!"}, 200
@@ -6163,6 +6383,7 @@ def setup(app):
try:
utilities.stop_dashboard_background_analysis()
include_preserved = request.args.get("include_preserved", "true").strip().lower() not in ("0", "false", "no", "off")
route_paths = []
seen_paths = set()
@@ -6172,18 +6393,51 @@ def setup(app):
seen_paths.add(path)
route_paths.append(path)
for route_path in route_paths:
_run_factory_reset_delete(route_path)
preserved_route_names = set()
deleted_route_names = set()
if include_preserved:
for route_path in route_paths:
_run_factory_reset_delete(route_path)
else:
# The preserve xattr lives on one segment, but preservation applies to the
# whole route in every footage root.
for route_path in route_paths:
if not os.path.isdir(route_path):
continue
for segment in os.listdir(route_path):
if utilities.SEGMENT_RE.fullmatch(segment) and utilities.has_preserve_attr(os.path.join(route_path, segment)):
preserved_route_names.add(segment.rsplit("--", 1)[0])
persisted_route_count = utilities.clear_dashboard_route_history(params)
for route_path in route_paths:
if not os.path.isdir(route_path):
continue
for segment in os.listdir(route_path):
if not utilities.SEGMENT_RE.fullmatch(segment):
continue
route_name = segment.rsplit("--", 1)[0]
if route_name in preserved_route_names:
continue
delete_file(os.path.join(route_path, segment))
deleted_route_names.add(route_name)
persisted_route_count = utilities.clear_dashboard_route_history(
params,
retained_route_names=preserved_route_names if not include_preserved else None,
)
_STATS_RESPONSE_CACHE.update({
"updated_at": 0.0,
"payload": None,
})
return jsonify({
"success": True,
"message": "All local driving routes deleted. Saved personal records were kept.",
"deletedPaths": len(route_paths),
"message": (
"All local driving routes deleted, including preserved routes. Saved personal records were kept."
if include_preserved else
"All non-preserved local driving routes deleted. Preserved routes were kept."
),
"deletedPaths": len(route_paths) if include_preserved else 0,
"deletedRoutes": len(deleted_route_names) if not include_preserved else None,
"preservedRoutes": len(preserved_route_names) if not include_preserved else 0,
"clearedDashboardRoutes": persisted_route_count,
}), 200
except Exception as exception:
@@ -6193,39 +6447,51 @@ def setup(app):
@app.route("/api/routes/<name>/preserve", methods=["POST"])
def preserve_route(name):
preserved_routes = 0
for footage_path in FOOTAGE_PATHS:
for segment in os.listdir(footage_path):
if segment.endswith("--0"):
segment_path = os.path.join(footage_path, segment)
if PRESERVE_ATTR_NAME in os.listxattr(segment_path) and os.getxattr(segment_path, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE:
preserved_routes += 1
if not _valid_route_name(name):
return jsonify({"error": "Invalid route name."}), 400
if preserved_routes >= PRESERVE_COUNT:
preserved_routes = set()
for footage_path in FOOTAGE_PATHS:
if not os.path.isdir(footage_path):
continue
for segment in os.listdir(footage_path):
if utilities.SEGMENT_RE.fullmatch(segment) and utilities.has_preserve_attr(os.path.join(footage_path, segment)):
preserved_routes.add(segment.rsplit("--", 1)[0])
if name not in preserved_routes and len(preserved_routes) >= PRESERVE_COUNT:
return {"error": f"Maximum of {PRESERVE_COUNT} preserved routes reached..."}, 400
for footage_path in FOOTAGE_PATHS:
route_path = os.path.join(footage_path, f"{name}--0")
if os.path.exists(route_path):
os.setxattr(route_path, PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE)
segment_path = _route_first_segment_path(name, footage_path)
if segment_path is not None:
os.setxattr(segment_path, PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE)
return {"message": "Route preserved!!"}, 200
return {"error": "Route not found"}, 404
@app.route("/api/routes/<name>/preserve", methods=["DELETE"])
def un_preserve_route(name):
if not _valid_route_name(name):
return jsonify({"error": "Invalid route name."}), 400
for footage_path in FOOTAGE_PATHS:
route_path = os.path.join(footage_path, f"{name}--0")
if PRESERVE_ATTR_NAME in os.listxattr(route_path):
os.removexattr(route_path, PRESERVE_ATTR_NAME)
segment_path = _route_first_segment_path(name, footage_path)
if segment_path is not None and utilities.has_preserve_attr(segment_path):
os.removexattr(segment_path, PRESERVE_ATTR_NAME)
return {"message": "Route unpreserved!"}, 200
return {"error": "Route not found"}, 404
@app.route("/video/<name>/combined", methods=["GET"])
def get_combined_route_video(name):
if not _valid_route_name(name):
return jsonify({"error": "Invalid route name."}), 400
camera = request.args.get("camera", "forward")
for footage_path in FOOTAGE_PATHS:
segments = utilities.get_segments_in_route(name, footage_path)
try:
segments = utilities.get_segments_in_route(name, footage_path)
except OSError:
continue
if segments:
cam_file = {
"forward": "fcamera.hevc",
@@ -6242,38 +6508,100 @@ def setup(app):
if not input_files:
return {"error": "No video files found"}, 404
mp4_file = utilities.ffmpeg_concat_segments_to_mp4(input_files, cache_key=f"{name}-{camera}")
return send_file(mp4_file, mimetype="video/mp4")
response = Response(utilities.ffmpeg_stream_concatenated_mp4(input_files), mimetype="video/mp4")
response.headers["Cache-Control"] = "no-store"
response.headers["X-Accel-Buffering"] = "no"
return response
return {"error": "Route not found"}, 404
@app.route("/api/routes/<name>", methods=["GET"])
def get_route(name):
for footage_path in FOOTAGE_PATHS:
base_path = f"{footage_path}{name}--0"
if os.path.exists(base_path):
segments = utilities.get_segments_in_route(name, footage_path)
if not segments:
break
if not _valid_route_name(name):
return jsonify({"error": "Invalid route name."}), 400
for footage_path in FOOTAGE_PATHS:
try:
segments = utilities.get_segments_in_route(name, footage_path)
except OSError:
continue
if segments:
base_path = os.path.join(footage_path, segments[0])
segment_urls = [f"/video/{segment}" for segment in segments]
total_duration = sum(utilities.get_video_duration(f"{footage_path}{name}--{i}/fcamera.hevc") for i in range(len(segment_urls)))
# Probing each segment cost an ffprobe before playback could even start,
# and segments are a fixed minute anyway.
total_duration = len(segments) * SEGMENT_DURATION_SECONDS
return {
"name": name,
"segment_urls": segment_urls,
"total_duration": round(total_duration),
"total_duration": total_duration,
"date": utilities.get_route_start_time(base_path),
"available_cameras": utilities.get_available_cameras(base_path),
}, 200
return {"error": "Route not found"}, 404
@app.route("/api/routes/<name>/logs", methods=["GET"])
def list_route_logs(name):
logs = _route_log_files(name)
if not logs:
return jsonify({"error": "No full logs are stored on the device for this route."}), 404
return jsonify({
"name": name,
"totalBytes": sum(size for *_, size in logs),
"segments": [
{
"segment": segment,
"segmentNum": int(segment.rsplit("--", 1)[1]),
"filename": filename,
"bytes": size,
"url": f"/api/routes/{name}/logs/{int(segment.rsplit('--', 1)[1])}",
}
for segment, filename, _, size in logs
],
}), 200
@app.route("/api/routes/<name>/logs/<int:segment_num>", methods=["GET"])
def download_route_log(name, segment_num):
for segment, filename, path, _ in _route_log_files(name):
if int(segment.rsplit("--", 1)[1]) == segment_num:
return send_file(path, as_attachment=True, download_name=f"{segment}-{filename}")
return jsonify({"error": "No full log is stored on the device for this segment."}), 404
@app.route("/api/routes/<name>/logs/download", methods=["GET"])
def download_route_logs_archive(name):
logs = _route_log_files(name)
if not logs:
return jsonify({"error": "No full logs are stored on the device for this route."}), 404
def generate():
buffer = _TarBuffer()
# streamed a file at a time so a long route never needs its whole archive in memory
with tarfile.open(fileobj=buffer, mode="w|") as archive:
for segment, filename, path, _ in logs:
try:
archive.add(path, arcname=f"{segment}/{filename}")
except OSError:
continue
chunk = buffer.pop()
if chunk:
yield chunk
chunk = buffer.pop()
if chunk:
yield chunk
response = Response(generate(), mimetype="application/x-tar")
response.headers["Content-Disposition"] = f'attachment; filename="{name}-logs.tar"'
return response
@app.route("/api/routes/clear_name", methods=["POST"])
@app.route("/api/routes/reset_name", methods=["POST"])
def clear_route_name():
data = request.get_json()
route_name = data.get("name")
if not route_name:
return jsonify({"error": "Missing route name"}), 400
if not _valid_route_name(route_name):
return jsonify({"error": "Invalid route name"}), 400
cleared = False
original_timestamp = None
@@ -6288,7 +6616,7 @@ def setup(app):
for segment in segments_to_process:
segment_dir = os.path.join(footage_path, segment)
for item in os.listdir(segment_dir):
if not item.endswith((".hevc", ".ts", ".png", ".gif")) and item not in utilities.LOG_CANDIDATES:
if utilities.is_route_marker_file(item):
try:
os.remove(os.path.join(segment_dir, item))
cleared = True
@@ -6310,8 +6638,8 @@ def setup(app):
old_name = data.get("old")
new_name_raw = data.get("new")
if not old_name or not new_name_raw:
return jsonify({"error": "Missing old or new name"}), 400
if not _valid_route_name(old_name) or not new_name_raw:
return jsonify({"error": "Missing or invalid route name"}), 400
new_name = utilities.secure_filename(new_name_raw)
renamed = False
@@ -6327,7 +6655,7 @@ def setup(app):
for segment in segments_to_process:
segment_dir = os.path.join(footage_path, segment)
for item in os.listdir(segment_dir):
if not item.endswith((".hevc", ".ts", ".png", ".gif", "rlog")):
if utilities.is_route_marker_file(item):
try:
os.remove(os.path.join(segment_dir, item))
except OSError:
@@ -6345,7 +6673,7 @@ def setup(app):
return jsonify({"error": f"Error creating new name file: {e}"}), 500
if renamed:
return jsonify({"message": "Route renamed successfully!"}), 200
return jsonify({"message": "Route renamed successfully!", "name": new_name}), 200
else:
return jsonify({"error": "Route not found"}), 404
@@ -8681,72 +9009,65 @@ def setup(app):
@app.route("/thumbnails/<path:file_path>", methods=["GET"])
def get_thumbnail(file_path):
for footage_path in FOOTAGE_PATHS:
if os.path.exists(os.path.join(footage_path, file_path)):
return send_from_directory(footage_path, file_path, as_attachment=True)
return {"error": "Thumbnail not found"}, 404
preview_path = _get_or_create_route_thumbnail(file_path)
if preview_path is None:
return {"error": "Thumbnail not found"}, 404
response = send_file(
preview_path,
mimetype="image/png",
conditional=True,
max_age=ROUTE_THUMBNAIL_CACHE_SECONDS,
)
response.headers["Cache-Control"] = f"public, max-age={ROUTE_THUMBNAIL_CACHE_SECONDS}"
return response
@app.route("/video/<path>", methods=["GET"])
def get_video(path):
if not utilities.SEGMENT_RE.fullmatch(path or ""):
return {"error": "Invalid segment name"}, 400
camera = request.args.get("camera")
filename = {"driver": "dcamera.hevc", "wide": "ecamera.hevc"}.get(camera, "fcamera.hevc")
# qcamera.ts is a 526x330 companion to the road camera, so wrapping it costs a
# fraction of the full stream. It still needs the mp4 wrap - a bare MPEG-TS will
# not play in a <video>. Anything missing falls through to the full stream.
if request.args.get("quality") == "low" and filename == "fcamera.hevc":
for footage_path in FOOTAGE_PATHS:
preview_path = os.path.join(footage_path, path, "qcamera.ts")
if not os.path.isfile(preview_path):
continue
try:
preview_mp4 = _get_or_create_segment_mp4(preview_path)
except (FileNotFoundError, ValueError):
break
if preview_mp4 is None:
return {"error": "Preview video is still being prepared"}, 503
return send_file(
preview_mp4,
mimetype="video/mp4",
conditional=True,
max_age=VIDEO_CACHE_SECONDS,
)
for footage_path in FOOTAGE_PATHS:
filepath = f"{footage_path}{path}/{filename}"
filepath = os.path.join(footage_path, path, filename)
if os.path.exists(filepath):
file_handle = utilities.ffmpeg_mp4_wrap_process_builder(filepath)
try:
cache_path = _get_or_create_segment_mp4(filepath)
except (FileNotFoundError, ValueError) as error:
return {"error": str(error)}, 409
if cache_path is None:
return {"error": "Video is still being prepared"}, 503
file_handle.seek(0, 2)
file_size = file_handle.tell()
file_handle.seek(0)
range_header = request.headers.get('Range', None)
if range_header:
byte_start = 0
byte_end = file_size - 1
if range_header.startswith('bytes='):
range_spec = range_header[6:]
if '-' in range_spec:
start, end = range_spec.split('-', 1)
if start:
byte_start = max(0, int(start))
if end:
byte_end = min(file_size - 1, int(end))
if byte_start >= file_size:
file_handle.close()
return Response("Requested Range Not Satisfiable", 416)
byte_end = max(byte_start, byte_end)
file_handle.seek(byte_start)
read_length = byte_end - byte_start + 1
data = file_handle.read(read_length)
response = Response(
data,
206,
headers={
'Content-Range': f'bytes {byte_start}-{byte_end}/{file_size}',
'Accept-Ranges': 'bytes',
'Content-Length': str(len(data)),
'Content-Type': 'video/mp4'
}
)
else:
data = file_handle.read()
response = Response(
data,
200,
headers={
'Accept-Ranges': 'bytes',
'Content-Length': str(file_size),
'Content-Type': 'video/mp4'
}
)
file_handle.close()
return response
# send_file streams from disk and handles Range and ETag itself.
return send_file(
cache_path,
mimetype="video/mp4",
conditional=True,
max_age=VIDEO_CACHE_SECONDS,
)
return {"error": "Video not found"}, 404
def main():
+236 -47
View File
@@ -5,6 +5,7 @@ import hashlib
import ipaddress
import json
import os
import queue
import re
import secrets
import shutil
@@ -12,10 +13,11 @@ import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import List
from urllib.parse import quote
@@ -650,6 +652,62 @@ def encode_parameters(params_dict):
encoded_data = base64.b64encode(obfuscated_data.encode("utf-8")).decode("utf-8")
return encoded_data
# The venv ships ffmpeg/ffprobe as console scripts that exec the real binary only
# after a full CPython startup. Resolve past the shim once and skip that per call.
def _resolve_ffmpeg_binary(name):
try:
import ffmpeg as ffmpeg_package
candidate = Path(ffmpeg_package.__file__).parent / "install" / "bin" / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
except Exception:
pass
return shutil.which(name) or name
FFMPEG_BIN = _resolve_ffmpeg_binary("ffmpeg")
FFPROBE_BIN = _resolve_ffmpeg_binary("ffprobe")
# Bound the cache by its own size rather than reacting to free space: loggerd already
# keeps the disk near full, so the old policy wiped every mp4 on almost every request.
VIDEO_CACHE_MAX_BYTES = 512 * 1024 * 1024
# A malformed or truncated segment must not occupy the Galaxy's only remux worker
# forever. Stream-copy normally finishes in seconds; this also bounds the fallback.
VIDEO_REMUX_TIMEOUT_SECONDS = 60
# Bound combined-route streams as well. Scale the deadline with route length below.
VIDEO_STREAM_TIMEOUT_SECONDS = 120
def _prune_video_cache(keep_path=None):
"""Evict oldest-first until the cache fits its budget."""
try:
entries = []
for cache_file in VIDEO_CACHE_PATH.glob("*.mp4"):
try:
stat = cache_file.stat()
except OSError:
continue
entries.append((stat.st_mtime, stat.st_size, cache_file))
except OSError:
return
total = sum(size for _, size, _ in entries)
if total <= VIDEO_CACHE_MAX_BYTES:
return
keep = str(keep_path) if keep_path else None
for _, size, cache_file in sorted(entries):
if total <= VIDEO_CACHE_MAX_BYTES:
break
if keep and str(cache_file) == keep:
continue
try:
cache_file.unlink()
total -= size
except OSError:
pass
def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
if not input_files:
raise ValueError("No input files provided for concatenation")
@@ -665,6 +723,8 @@ def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
if cache_path.exists() and all(cache_path.stat().st_mtime > Path(f).stat().st_mtime for f in input_files):
return open(cache_path, "rb")
_prune_video_cache(keep_path=cache_path)
list_file = VIDEO_CACHE_PATH / f"{file_hash}.txt"
with open(list_file, "w") as f:
for seg in input_files:
@@ -672,14 +732,14 @@ def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
try:
subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
"-i", str(list_file), "-c", "copy", "-movflags", "faststart", "-y", str(cache_path)],
check=True
)
except subprocess.CalledProcessError:
try:
subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
"-i", str(list_file), "-c:v", "libx264", "-movflags", "faststart", "-y", str(cache_path)],
check=True
)
@@ -693,7 +753,84 @@ def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
return open(cache_path, "rb")
def ffmpeg_mp4_wrap_process_builder(filename):
def ffmpeg_stream_concatenated_mp4(input_files, chunk_size=256 * 1024):
"""Stream-copy camera segments as fragmented MP4 without building a full cache file."""
if not input_files:
raise ValueError("No input files provided for concatenation")
VIDEO_CACHE_PATH.mkdir(exist_ok=True)
with tempfile.NamedTemporaryFile("w", suffix=".txt", prefix="route-download-", dir=VIDEO_CACHE_PATH, delete=False) as list_file:
list_path = Path(list_file.name)
for segment in input_files:
list_file.write(f"file '{Path(segment)}'\n")
process = None
reader_thread = None
try:
process = subprocess.Popen(
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-f", "concat", "-safe", "0",
"-i", str(list_path), "-c", "copy", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4", "pipe:1"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
chunks = queue.Queue(maxsize=4)
deadline = time.monotonic() + max(VIDEO_STREAM_TIMEOUT_SECONDS, len(input_files) * 2.0)
def read_stdout():
try:
while True:
chunk = process.stdout.read(chunk_size)
if not chunk:
chunks.put(("eof", None))
return
chunks.put(("data", chunk))
except Exception as error:
chunks.put(("error", error))
reader_thread = threading.Thread(target=read_stdout, name="route-video-reader", daemon=True)
reader_thread.start()
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("Timed out streaming the combined route video")
try:
kind, value = chunks.get(timeout=remaining)
except queue.Empty as error:
raise TimeoutError("Timed out streaming the combined route video") from error
if kind == "data":
yield value
elif kind == "error":
raise ValueError("Could not read the combined route video") from value
else:
if process.wait(timeout=max(0.1, remaining)) != 0:
raise ValueError("Could not stream the combined route video")
break
finally:
if process is not None:
if process.stdout is not None:
process.stdout.close()
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
if reader_thread is not None:
reader_thread.join(timeout=1)
try:
list_path.unlink()
except OSError:
pass
def ffmpeg_mp4_wrap_to_path(filename):
"""Remux one raw .hevc segment to mp4 and return the cache path.
Callers get a path, not a handle, so send_file can stream it without reading it all.
"""
input_path = Path(filename)
if not input_path.exists():
@@ -708,31 +845,45 @@ def ffmpeg_mp4_wrap_process_builder(filename):
VIDEO_CACHE_PATH.mkdir(exist_ok=True)
total, used, free = shutil.disk_usage(VIDEO_CACHE_PATH)
if free < 500 * 1024 * 1024:
for cache_file in VIDEO_CACHE_PATH.glob("*.mp4"):
try:
cache_file.unlink()
except:
pass
file_hash = hashlib.md5(str(input_path).encode()).hexdigest()
cache_path = VIDEO_CACHE_PATH / f"{file_hash}.mp4"
if cache_path.exists() and cache_path.stat().st_mtime > input_path.stat().st_mtime:
return open(cache_path, "rb")
return cache_path
_prune_video_cache(keep_path=cache_path)
deadline = time.monotonic() + VIDEO_REMUX_TIMEOUT_SECONDS
def remaining_time():
return max(0.1, deadline - time.monotonic())
try:
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(input_path), "-c", "copy", "-movflags", "faststart", "-y", str(cache_path)], check=True)
subprocess.run(
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-i", str(input_path),
"-c", "copy", "-movflags", "faststart", "-y", str(cache_path)],
check=True,
timeout=remaining_time(),
)
except subprocess.TimeoutExpired:
cache_path.unlink(missing_ok=True)
raise ValueError(f"Timed out processing video file: {input_path}")
except subprocess.CalledProcessError:
try:
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(input_path), "-c:v", "libx264", "-movflags", "faststart", "-y", str(cache_path)], check=True)
except subprocess.CalledProcessError:
if cache_path.exists():
cache_path.unlink()
subprocess.run(
[FFMPEG_BIN, "-hide_banner", "-loglevel", "error", "-i", str(input_path),
"-c:v", "libx264", "-movflags", "faststart", "-y", str(cache_path)],
check=True,
timeout=remaining_time(),
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
cache_path.unlink(missing_ok=True)
raise ValueError(f"Cannot process video file: {input_path}")
return open(cache_path, "rb")
return cache_path
def ffmpeg_mp4_wrap_process_builder(filename):
return open(ffmpeg_mp4_wrap_to_path(filename), "rb")
def format_git_date(raw_date: str):
date_object = datetime.strptime(raw_date.split()[1], "%Y-%m-%d")
@@ -1699,12 +1850,30 @@ def _invalidate_dashboard_cache():
})
def clear_dashboard_route_history(params_obj):
"""Remove route-backed dashboard history while keeping durable records."""
def clear_dashboard_route_history(params_obj, retained_route_names=None):
"""Remove route-backed dashboard history while keeping durable records and optional retained routes."""
stats = _load_dashboard_persistent_stats(params_obj)
route_count = len(stats.get("routes", {}))
stats["routes"] = {}
stats["ignoredRoutes"] = []
routes = stats.get("routes", {})
retained_routes = None if retained_route_names is None else {
str(route_name or "").strip()
for route_name in retained_route_names
if ROUTE_RE.fullmatch(str(route_name or "").strip())
}
if retained_routes is None:
stats["routes"] = {}
stats["ignoredRoutes"] = []
else:
stats["routes"] = {
route_name: entry
for route_name, entry in routes.items()
if route_name in retained_routes
}
stats["ignoredRoutes"] = [
route_name
for route_name in stats.get("ignoredRoutes", [])
if route_name in retained_routes
]
route_count = len(routes) - len(stats["routes"])
serialized = json.dumps(stats, separators=(",", ":"))
persisted_to_params = False
@@ -2993,24 +3162,28 @@ def get_routes_names(footage_path):
route_times = {segment.route_name.time_str for segment in segments}
return sorted(route_times, reverse=True)
def get_routes_with_segment_counts(footage_path):
route_counts = {}
def get_routes_with_segment_details(footage_path):
route_details = {}
for segment in get_all_segment_names(footage_path):
route_name = segment.route_name.time_str
route_counts[route_name] = route_counts.get(route_name, 0) + 1
return sorted(route_counts.items(), reverse=True)
segment_num = int(getattr(segment, "segment_num", 0))
details = route_details.setdefault(route_name, {"segmentCount": 0, "firstSegmentNum": segment_num})
details["segmentCount"] += 1
details["firstSegmentNum"] = min(details["firstSegmentNum"], segment_num)
return sorted(route_details.items(), reverse=True)
def get_segments_in_route(route_time_str, footage_path):
return [
segments = [
f"{segment.time_str}--{segment.segment_num}"
for segment in get_all_segment_names(footage_path)
if segment.time_str == route_time_str
]
return sorted(segments, key=lambda segment: int(segment.rsplit("--", 1)[1]))
def get_video_duration(input_path):
try:
result = subprocess.run([
"ffprobe", "-v", "error", "-show_entries", "format=duration",
FFPROBE_BIN, "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(input_path)
], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True)
return float(result.stdout)
@@ -3018,7 +3191,10 @@ def get_video_duration(input_path):
return 60
def has_preserve_attr(path: str):
return PRESERVE_ATTR_NAME in os.listxattr(path) and os.getxattr(path, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE
try:
return PRESERVE_ATTR_NAME in os.listxattr(path) and os.getxattr(path, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE
except (AttributeError, OSError):
return False
def list_file(path):
return sorted(os.listdir(path), reverse=True)
@@ -3035,30 +3211,35 @@ def normalize_theme_name(name, for_path=False):
return f"{normalized_parts[0]} ({' '.join(normalized_parts[1:])})".replace(" Week", "")
return ' '.join(normalized_parts).replace(" Week", "")
def process_route(footage_path, route_name, segment_count=0):
segment_path = f"{footage_path}{route_name}--0"
qcamera_path = f"{segment_path}/qcamera.ts"
def is_route_marker_file(filename):
"""A renamed route stores its display name as an empty marker file in the segment."""
return not filename.endswith((".hevc", ".ts", ".png", ".gif")) and filename not in LOG_CANDIDATES
png_output_path = os.path.join(segment_path, "preview.png")
if not os.path.exists(png_output_path):
video_to_png(qcamera_path, png_output_path)
def _utc_rfc3339(value):
if value is None:
return None
# Naive values come off the filesystem in local time; astimezone reads them that way.
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def process_route(footage_path, route_name, segment_count=0, first_segment_num=0):
segment_name = f"{route_name}--{max(0, int(first_segment_num))}"
segment_path = os.path.join(footage_path, segment_name)
custom_name = None
if os.path.isdir(segment_path):
for item in os.listdir(segment_path):
if not item.endswith((".hevc", ".ts", ".png", ".gif")) and item not in LOG_CANDIDATES:
if is_route_marker_file(item):
custom_name = item
break
route_timestamp_str = custom_name
if not custom_name:
route_timestamp_dt = get_route_start_time(segment_path)
route_timestamp_str = route_timestamp_dt.isoformat() if route_timestamp_dt else None
route_timestamp_dt = get_route_start_time(segment_path)
route_timestamp_str = custom_name or (route_timestamp_dt.isoformat() if route_timestamp_dt else None)
return {
"name": route_name,
"png": f"/thumbnails/{route_name}--0/preview.png",
"png": f"/thumbnails/{segment_name}/preview.png",
"timestamp": route_timestamp_str,
"startedAt": _utc_rfc3339(route_timestamp_dt),
"isCustomName": custom_name is not None,
"is_preserved": has_preserve_attr(segment_path),
"segmentCount": max(0, int(segment_count)),
"approxDurationSeconds": max(0, int(segment_count)) * 60,
@@ -3088,20 +3269,28 @@ def segment_to_segment_name(data_dir, segment):
full_path = os.path.join(data_dir, f"FakeDongleID1337|{segment}")
return SegmentName(full_path)
VIDEO_TO_PNG_TIMEOUT_SECONDS = 20
def video_to_png(input_path, output_path):
try:
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error",
FFMPEG_BIN, "-hide_banner", "-loglevel", "error",
"-ss", "1",
"-i", str(input_path),
"-frames:v", "1",
"-y",
str(output_path)
], capture_output=True, check=True, text=True)
except subprocess.CalledProcessError as e:
], capture_output=True, check=True, text=True, timeout=VIDEO_TO_PNG_TIMEOUT_SECONDS)
return os.path.isfile(output_path)
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
print(f"Failed to generate PNG for {input_path}")
if e.stderr:
if getattr(e, "stderr", None):
print(e.stderr)
try:
Path(output_path).unlink(missing_ok=True)
except OSError:
pass
return False
def xor_encrypt_decrypt(data, key):
return "".join(chr(ord(c) ^ ord(key[i % len(key)])) for i, c in enumerate(data))
+47
View File
@@ -82,6 +82,32 @@ def notify_sentry_power_off(reason: str, power_monitor: PowerMonitoring) -> bool
return False
def notify_sentry_low_voltage(power_monitor: PowerMonitoring) -> bool:
port = os.environ.get("SP_GALAXY_PORT", "8083" if PC else "8082")
v = round(power_monitor.car_voltage_mV / 1000, 2)
event = {
"eventId": f"low-voltage-{time.time_ns()}",
"kind": "warning",
"detectedAt": datetime.now(timezone.utc).isoformat(),
"reason": "low_voltage",
"message": f"Low vehicle battery warning: {v:.2f}V (at or below 11.8V).",
"voltage": v,
"instantVoltage": round(power_monitor.car_voltage_instant_mV / 1000, 2),
"batteryCapacityUwh": power_monitor.get_car_battery_capacity(),
}
try:
response = requests.post(
f"http://127.0.0.1:{port}/api/sentry/events",
json=event,
timeout=4,
)
response.raise_for_status()
return True
except requests.RequestException as error:
cloudlog.warning(f"Sentry low-voltage notification unavailable: {error}")
return False
class Chestnut:
"""Keep the ASM2464PD dock on the firmware expected by the GPU runtime."""
MAX_ATTEMPTS = 3
@@ -305,6 +331,8 @@ def hardware_thread(end_event, hw_queue) -> None:
pwrsave = False
offroad_cycle_count = 0
sentry_power_off_notified = False
sentry_low_voltage_notified = False
last_low_voltage_notify_ts = 0.0
params = Params()
power_monitor = PowerMonitoring()
@@ -523,6 +551,10 @@ def hardware_thread(end_event, hw_queue) -> None:
statlog.sample("som_power_draw", som_power_draw)
msg.deviceState.somPowerDrawW = som_power_draw
if not onroad_conditions["ignition"] and (count % int(30. / DT_HW) == 0):
low_v_str = f" [LOW VOLTAGE SUSTAINED: {time.monotonic() - power_monitor.low_voltage_start_time:.1f}s / 30.0s]" if power_monitor.low_voltage_start_time else ""
print(f"[hardwared] Offroad Power: {power_monitor.car_voltage_mV / 1000.0:.2f}V (instant: {power_monitor.car_voltage_instant_mV / 1000.0:.2f}V), draw: {current_power_draw:.1f}W{low_v_str}", flush=True)
# Check if we need to shut down
shutdown_reason = power_monitor.shutdown_reason(
onroad_conditions["ignition"], in_car, off_ts, started_seen, starpilot_toggles,
@@ -536,6 +568,21 @@ def hardware_thread(end_event, hw_queue) -> None:
else:
sentry_power_off_notified = False
# Low voltage warning notification (without device shutdown)
if in_car and not onroad_conditions["ignition"] and off_ts is not None:
voltage_v = power_monitor.car_voltage_mV / 1000.0
if voltage_v <= 11.8:
now_mono = time.monotonic()
if not sentry_low_voltage_notified or (now_mono - last_low_voltage_notify_ts > 1800):
sentry_low_voltage_notified = True
last_low_voltage_notify_ts = now_mono
if params.get_bool("SentryModeEnabled"):
notify_sentry_low_voltage(power_monitor)
elif voltage_v > 12.2:
sentry_low_voltage_notified = False
else:
sentry_low_voltage_notified = False
msg.deviceState.started = started_ts is not None
msg.deviceState.startedMonoTime = int(1e9*(started_ts or 0))
+26 -6
View File
@@ -19,6 +19,7 @@ MAX_TIME_OFFROAD_S = 30*3600
MIN_ON_TIME_S = 3600
DELAY_SHUTDOWN_TIME_S = 300 # Wait at least DELAY_SHUTDOWN_TIME_S seconds after offroad_time to shutdown.
VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S = 60
VOLTAGE_SHUTDOWN_SUSTAINED_TIME_S = 30.0
class PowerMonitoring:
def __init__(self):
@@ -29,12 +30,19 @@ class PowerMonitoring:
self.next_pulsed_measurement_time = None
self.car_voltage_mV = 12e3 # Low-passed version of peripheralState voltage
self.car_voltage_instant_mV = 12e3 # Last value of peripheralState voltage
self.low_voltage_start_time = None # Monotonic timestamp when low voltage was first observed
self.integration_lock = threading.Lock()
car_battery_capacity_uWh = self.params.get("CarBatteryCapacity") or 0
# Preserve an exhausted persisted value so the shutdown policy can act on it.
# A missing or malformed value is treated as a newly initialized battery.
car_battery_capacity_uWh = self.params.get_int("CarBatteryCapacity", default=CAR_BATTERY_CAPACITY_uWh)
if car_battery_capacity_uWh < 0:
car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
# Reset capacity if it's low
self.car_battery_capacity_uWh = max((CAR_BATTERY_CAPACITY_uWh / 10), car_battery_capacity_uWh)
# Reset low but non-zero estimates; zero means the estimate is exhausted.
self.car_battery_capacity_uWh = (
0 if car_battery_capacity_uWh == 0 else max((CAR_BATTERY_CAPACITY_uWh / 2), car_battery_capacity_uWh)
)
# Calculation tick
def calculate(self, voltage: int | None, ignition: bool):
@@ -110,14 +118,26 @@ class PowerMonitoring:
def shutdown_reason(self, ignition: bool, in_car: bool, offroad_timestamp: float | None,
started_seen: bool, starpilot_toggles: SimpleNamespace) -> str | None:
if offroad_timestamp is None:
self.low_voltage_start_time = None
return None
now = time.monotonic()
offroad_time = (now - offroad_timestamp)
low_voltage_shutdown = (self.car_voltage_mV < (starpilot_toggles.low_voltage_shutdown * 1e3) and
offroad_time > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S)
cutoff_voltage = starpilot_toggles.low_voltage_shutdown if getattr(starpilot_toggles, "low_voltage_shutdown", 0) > 0 else 11.8
is_below_voltage = self.car_voltage_mV < (cutoff_voltage * 1e3)
if is_below_voltage and offroad_time > VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S:
if self.low_voltage_start_time is None:
self.low_voltage_start_time = now
low_voltage_sustained_time = now - self.low_voltage_start_time
low_voltage_shutdown = low_voltage_sustained_time >= VOLTAGE_SHUTDOWN_SUSTAINED_TIME_S
else:
self.low_voltage_start_time = None
low_voltage_shutdown = False
reason = None
if offroad_time > starpilot_toggles.device_shutdown_time:
if starpilot_toggles.device_shutdown_time > 0 and offroad_time > starpilot_toggles.device_shutdown_time:
reason = "offroad_timeout"
elif low_voltage_shutdown:
reason = "low_voltage"
+42 -1
View File
@@ -42,7 +42,48 @@ class TestPowerMonitoring:
for _ in range(10):
pm.calculate(None, None)
assert pm.get_power_used() == 0
assert pm.get_car_battery_capacity() == (CAR_BATTERY_CAPACITY_uWh / 10)
assert pm.get_car_battery_capacity() == CAR_BATTERY_CAPACITY_uWh
def test_persisted_exhausted_capacity_is_not_reset(self):
self.params.put_int("CarBatteryCapacity", 0)
try:
pm = PowerMonitoring()
assert pm.get_car_battery_capacity() == 0
finally:
self.params.remove("CarBatteryCapacity")
def test_exhausted_capacity_requests_shutdown(self, mocker):
pm_patch(mocker, "DELAY_SHUTDOWN_TIME_S", 0, constant=True)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = 0
start_time = ssb
# The capacity guard remains independent from the voltage debounce.
assert pm.shutdown_reason(False, True, start_time, True, self.toggles()) == "battery_capacity_exhausted"
def test_low_voltage_requires_sustained_signal_and_resets_on_recovery(self, mocker):
pm_patch(mocker, "VOLTAGE_SHUTDOWN_MIN_OFFROAD_TIME_S", 0, constant=True)
pm_patch(mocker, "VOLTAGE_SHUTDOWN_SUSTAINED_TIME_S", 3, constant=True)
pm_patch(mocker, "DELAY_SHUTDOWN_TIME_S", 0, constant=True)
pm = PowerMonitoring()
pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh
start_time = ssb
pm.car_voltage_mV = 11.0 * 1e3
assert pm.shutdown_reason(False, True, start_time, True, self.toggles()) is None
assert pm.low_voltage_start_time is not None
assert pm.shutdown_reason(False, True, start_time, True, self.toggles()) is None
pm.car_voltage_mV = 12.0 * 1e3
assert pm.shutdown_reason(False, True, start_time, True, self.toggles()) is None
assert pm.low_voltage_start_time is None
pm.car_voltage_mV = 11.0 * 1e3
assert pm.shutdown_reason(False, True, start_time, True, self.toggles()) is None
for _ in range(2):
assert pm.shutdown_reason(False, True, start_time, True, self.toggles()) is None
assert pm.shutdown_reason(False, True, start_time, True, self.toggles()) == "low_voltage"
# Test to see that it doesn't integrate offroad when ignition is True
def test_offroad_ignition(self):