mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-04 07:03:44 +08:00
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:
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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -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():
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
Reference in New Issue
Block a user