mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-13 13:22:22 +08:00
2738 lines
96 KiB
Python
2738 lines
96 KiB
Python
#!/usr/bin/env python3
|
|
import base64
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import List
|
|
from urllib.parse import quote
|
|
|
|
from openpilot.common.constants import CV
|
|
from openpilot.common.params import Params
|
|
from openpilot.system.loggerd.config import get_available_bytes, get_used_bytes
|
|
from openpilot.system.loggerd.deleter import PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE
|
|
from openpilot.system.loggerd.uploader import listdir_by_creation
|
|
from openpilot.tools.lib.route import SegmentName
|
|
|
|
from openpilot.starpilot.assets.model_manager import canonical_model_key
|
|
from openpilot.starpilot.common.starpilot_variables import THEME_SAVE_PATH, VIDEO_CACHE_PATH
|
|
from openpilot.starpilot.assets.theme_manager import HOLIDAY_THEME_PATH
|
|
|
|
LOG_CANDIDATES = [
|
|
"qlog",
|
|
"qlog.zst",
|
|
"qlog.bz2",
|
|
"rlog",
|
|
"rlog.zst",
|
|
"rlog.bz2",
|
|
"raw_log.zst",
|
|
"raw_log.bz2",
|
|
]
|
|
|
|
ROUTE_TIME_LOG_CANDIDATES = [
|
|
"qlog.zst",
|
|
"qlog.bz2",
|
|
"qlog",
|
|
"rlog.zst",
|
|
"rlog.bz2",
|
|
"rlog",
|
|
"raw_log.zst",
|
|
"raw_log.bz2",
|
|
]
|
|
|
|
SEGMENT_RE = re.compile(r"^[0-9a-fA-F]{8}--[0-9a-fA-F]{10}--\d+$")
|
|
ROUTE_RE = re.compile(r"^[0-9a-fA-F]{8}--[0-9a-fA-F]{10}$")
|
|
|
|
TARGET_LOUDNESS = -15.0
|
|
METER_TO_MILE = 1.0 / 1609.344
|
|
METER_TO_KILOMETER = 0.001
|
|
MILE_TO_KILOMETER = CV.MPH_TO_KPH
|
|
METER_PER_SECOND_TO_MPH = CV.MS_TO_KPH * CV.KPH_TO_MPH
|
|
|
|
DASHBOARD_CACHE_TTL_SECONDS = 5.0
|
|
DASHBOARD_ROUTE_SCAN_LIMIT = 96
|
|
DASHBOARD_ROUTE_ANALYSIS_LIMIT = 0
|
|
DASHBOARD_ANALYSIS_TIME_BUDGET_SECONDS = 0.0
|
|
DASHBOARD_BACKGROUND_ROUTE_ANALYSIS_LIMIT = 5
|
|
DASHBOARD_RECENT_DRIVE_LIMIT = 5
|
|
DASHBOARD_ROUTE_SEGMENT_SAMPLE_LIMIT = 2
|
|
DASHBOARD_PERSISTED_ROUTE_LIMIT = 5000
|
|
DASHBOARD_PERSIST_MIN_ROUTE_AGE_SECONDS = 120
|
|
DASHBOARD_PERSISTENT_STATS_PARAM = "GalaxyDashboardStats"
|
|
DASHBOARD_ROUTE_ANALYSIS_VERSION = 3
|
|
DASHBOARD_PARAMS_DIR = Path("/data/params/d")
|
|
DASHBOARD_ANALYZER_LOG_PATH = "/tmp/galaxy_dashboard_analyzer.log"
|
|
DASHBOARD_ANALYZER_STATUS_PATH = Path("/tmp/galaxy_dashboard_analyzer_status.json")
|
|
DASHBOARD_ANALYZER_STATUS_MAX_AGE_SECONDS = 30 * 60
|
|
DASHBOARD_TOP_MODEL_LIMIT = 3
|
|
DASHBOARD_EVENT_DISTRACTED = "promptDriverDistracted"
|
|
DASHBOARD_EVENT_UNRESPONSIVE = "driverUnresponsive"
|
|
DASHBOARD_TIME_SOURCE_LOG = "log"
|
|
DASHBOARD_TIME_SOURCE_FILESYSTEM = "filesystem"
|
|
DASHBOARD_MIN_VALID_ROUTE_TIME = datetime(2026, 1, 1)
|
|
DASHBOARD_ROUTE_FUTURE_GRACE_SECONDS = 6 * 60 * 60
|
|
DASHBOARD_LOCAL_ROUTE_MAX_AGE_SECONDS = 45 * 24 * 60 * 60
|
|
|
|
XOR_KEY = "s8#pL3*Xj!aZ@dWq"
|
|
|
|
MAX_FILE_SIZE = 5 * 1024 * 1024
|
|
|
|
_FILENAME_SANITIZE_RE = re.compile(r"[^A-Za-z0-9_.-]+")
|
|
_GALAXY_DEPS_PATH = "/data/galaxy_deps"
|
|
_LEGACY_GALAXY_DEPS_PATH = "/data/" + "".join(chr(code) for code in (112, 111, 110, 100)) + "_deps"
|
|
for deps_path in (_GALAXY_DEPS_PATH, _LEGACY_GALAXY_DEPS_PATH):
|
|
if os.path.isdir(deps_path) and deps_path not in sys.path:
|
|
sys.path.insert(0, deps_path)
|
|
_REPO_THIRD_PARTY_PATH = Path(__file__).resolve().parents[2] / "third_party"
|
|
if _REPO_THIRD_PARTY_PATH.is_dir() and str(_REPO_THIRD_PARTY_PATH) not in sys.path:
|
|
sys.path.insert(0, str(_REPO_THIRD_PARTY_PATH))
|
|
|
|
_PIL_IMAGE = None
|
|
_PYDUB_AUDIOSEGMENT = None
|
|
_DASHBOARD_CACHE = {
|
|
"key": None,
|
|
"updated_at": 0.0,
|
|
"value": None,
|
|
}
|
|
_DASHBOARD_ANALYZER_LOCK = threading.Lock()
|
|
_DASHBOARD_ANALYZER_PROCESS = None
|
|
params = Params(return_defaults=True)
|
|
|
|
|
|
def secure_filename(filename):
|
|
safe = os.path.basename(str(filename or ""))
|
|
safe = safe.replace(" ", "_").strip()
|
|
safe = _FILENAME_SANITIZE_RE.sub("_", safe)
|
|
safe = safe.strip("._")
|
|
return safe or "file"
|
|
|
|
|
|
def _decode_json_param(value, default):
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, (dict, list)):
|
|
return value
|
|
if isinstance(value, bytes):
|
|
value = value.decode("utf-8", errors="replace")
|
|
if isinstance(value, str):
|
|
text = value.strip()
|
|
if not text:
|
|
return default
|
|
try:
|
|
parsed = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return default
|
|
if isinstance(default, dict) and isinstance(parsed, dict):
|
|
return parsed
|
|
if isinstance(default, list) and isinstance(parsed, list):
|
|
return parsed
|
|
return default
|
|
return default
|
|
|
|
|
|
def _get_pillow_image():
|
|
global _PIL_IMAGE
|
|
if _PIL_IMAGE is None:
|
|
from PIL import Image as pil_image
|
|
_PIL_IMAGE = pil_image
|
|
return _PIL_IMAGE
|
|
|
|
|
|
def _get_pydub_audio_segment():
|
|
global _PYDUB_AUDIOSEGMENT
|
|
if _PYDUB_AUDIOSEGMENT is None:
|
|
from pydub import AudioSegment as pydub_audio_segment
|
|
_PYDUB_AUDIOSEGMENT = pydub_audio_segment
|
|
return _PYDUB_AUDIOSEGMENT
|
|
|
|
|
|
def check_theme_components(theme_path):
|
|
components = {
|
|
"hasColors": False,
|
|
"hasIcons": False,
|
|
"hasSounds": False,
|
|
"hasTurnSignals": False,
|
|
"hasDistanceIcons": False,
|
|
"hasSteeringWheel": False
|
|
}
|
|
|
|
colors_path = theme_path / "colors" / "colors.json"
|
|
if colors_path.exists():
|
|
components["hasColors"] = True
|
|
|
|
icons_path = theme_path / "icons"
|
|
if icons_path.exists() and any(icons_path.iterdir()):
|
|
components["hasIcons"] = True
|
|
|
|
sounds_path = theme_path / "sounds"
|
|
if sounds_path.exists() and any(sounds_path.iterdir()):
|
|
components["hasSounds"] = True
|
|
|
|
signals_path = theme_path / "signals"
|
|
if signals_path.exists() and any(signals_path.iterdir()):
|
|
components["hasTurnSignals"] = True
|
|
|
|
distance_icons_path = theme_path / "distance_icons"
|
|
if distance_icons_path.exists() and any(distance_icons_path.iterdir()):
|
|
components["hasDistanceIcons"] = True
|
|
|
|
is_holiday_theme = str(HOLIDAY_THEME_PATH) in str(theme_path)
|
|
|
|
if is_holiday_theme:
|
|
wheel_path = theme_path / "steering_wheel"
|
|
if wheel_path.exists() and any(f.name.startswith("wheel.") for f in wheel_path.iterdir()):
|
|
components["hasSteeringWheel"] = True
|
|
else:
|
|
wheel_path = THEME_SAVE_PATH / "steering_wheels"
|
|
if wheel_path.exists():
|
|
theme_name = theme_path.name.replace('-user_created', '')
|
|
if any(wheel_path.glob(f"{theme_name}-user_created.*")):
|
|
components["hasSteeringWheel"] = True
|
|
|
|
return components
|
|
|
|
def covert_audio(input_file):
|
|
sound = _get_pydub_audio_segment().from_file(input_file)
|
|
sound = sound.set_frame_rate(48000)
|
|
sound = sound.set_channels(1)
|
|
|
|
output_filename = os.path.splitext(input_file)[0] + ".wav"
|
|
sound.export(output_filename, format="wav", parameters=["-acodec", "pcm_s16le"])
|
|
|
|
if input_file != output_filename:
|
|
os.remove(input_file)
|
|
|
|
def create_theme(form_data, files, temporary=False):
|
|
theme_name = form_data.get("themeName")
|
|
if not theme_name:
|
|
return None, "Theme name is required."
|
|
|
|
sane_theme_name = secure_filename(theme_name.replace(" ", "_"))
|
|
|
|
save_checklist_str = form_data.get("saveChecklist", "{}")
|
|
save_checklist = json.loads(save_checklist_str)
|
|
|
|
needs_theme_pack = any([
|
|
save_checklist.get("colors"),
|
|
save_checklist.get("icons"),
|
|
save_checklist.get("sounds"),
|
|
save_checklist.get("turn_signals"),
|
|
save_checklist.get("distance_icons"),
|
|
])
|
|
|
|
if temporary:
|
|
base_path = Path(f"/tmp/{sane_theme_name}_{secrets.token_hex(8)}")
|
|
else:
|
|
base_path = THEME_SAVE_PATH / "theme_packs" if needs_theme_pack else None
|
|
|
|
theme_path = (base_path / f"{sane_theme_name}-user_created") if base_path else None
|
|
if theme_path:
|
|
theme_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
if save_checklist.get("colors"):
|
|
(theme_path / "colors").mkdir(exist_ok=True)
|
|
colors_str = form_data.get("colors")
|
|
if colors_str:
|
|
color_data = json.loads(colors_str)
|
|
for key, values in color_data.items():
|
|
if "alpha" in values:
|
|
values["alpha"] = values.pop("alpha")
|
|
colors_file = theme_path / "colors" / "colors.json"
|
|
with open(colors_file, "w") as f:
|
|
json.dump(color_data, f, indent=2)
|
|
|
|
if save_checklist.get("turn_signals"):
|
|
signals_path = theme_path / "signals"
|
|
signals_path.mkdir(exist_ok=True)
|
|
|
|
if turn_signal_length := form_data.get("turnSignalLength"):
|
|
style = form_data.get("turnSignalStyle", "Traditional").lower()
|
|
(signals_path / f"{style}_{turn_signal_length}").touch()
|
|
|
|
turn_signal_type = form_data.get("turnSignalType", "Single Image").lower()
|
|
|
|
if turn_signal_type == "single image":
|
|
for f in signals_path.glob("turn_signal.*"):
|
|
f.unlink()
|
|
for f in signals_path.glob("turn_signal_blindspot.*"):
|
|
f.unlink()
|
|
|
|
file = files.get("turnSignal")
|
|
if file and file.filename:
|
|
if file.content_length > MAX_FILE_SIZE:
|
|
return None, f"File {file.filename} exceeds 1MB limit."
|
|
ext = Path(file.filename).suffix
|
|
file.save(signals_path / f"turn_signal{ext}")
|
|
|
|
file = files.get("turnSignalBlindspot")
|
|
if file and file.filename:
|
|
if file.content_length > MAX_FILE_SIZE:
|
|
return None, f"File {file.filename} exceeds 1MB limit."
|
|
ext = Path(file.filename).suffix
|
|
file.save(signals_path / f"turn_signal_blindspot{ext}")
|
|
|
|
elif turn_signal_type == "sequential":
|
|
for f in signals_path.glob("turn_signal_*"):
|
|
f.unlink()
|
|
|
|
signal_map = {
|
|
"turnSignal": "turn_signal",
|
|
"turnSignalBlindspot": "turn_signal_blindspot",
|
|
}
|
|
for field, base_name in signal_map.items():
|
|
file = files.get(field)
|
|
if file and file.filename:
|
|
if file.content_length > MAX_FILE_SIZE:
|
|
return None, f"File {file.filename} exceeds 1MB limit."
|
|
for f in signals_path.glob(f"{base_name}.*"):
|
|
f.unlink()
|
|
ext = Path(file.filename).suffix.lower()
|
|
file.save(signals_path / f"{base_name}{ext}")
|
|
|
|
for f in signals_path.glob("turn_signal.*"):
|
|
f.unlink()
|
|
for f in signals_path.glob("turn_signal_blindspot.*"):
|
|
f.unlink()
|
|
|
|
sequential_keys = sorted(
|
|
[k for k in files if k.startswith("turn_signal_")],
|
|
key=lambda name: int(name.split("_")[-1])
|
|
)
|
|
|
|
for key in sequential_keys:
|
|
file = files.get(key)
|
|
if file and file.filename:
|
|
if file.content_length > MAX_FILE_SIZE:
|
|
return None, f"File {file.filename} exceeds 1MB limit."
|
|
idx = key.split("_")[-1]
|
|
ext = Path(file.filename).suffix
|
|
file.save(signals_path / f"turn_signal_{idx}{ext}")
|
|
|
|
if save_checklist.get("icons"):
|
|
(theme_path / "icons").mkdir(exist_ok=True)
|
|
|
|
icon_map = {
|
|
"settingsButton": (theme_path / "icons", "button_settings", (169, 104)),
|
|
"homeButton": (theme_path / "icons", "button_home", (250, 250)),
|
|
}
|
|
|
|
for field, (dest_path, base_name, resize_dims) in icon_map.items():
|
|
file = files.get(field)
|
|
if file and file.filename:
|
|
if file.content_length > MAX_FILE_SIZE:
|
|
return None, f"File {file.filename} exceeds 1MB limit."
|
|
|
|
for f in dest_path.glob(f"{base_name}.*"):
|
|
f.unlink()
|
|
|
|
ext = Path(file.filename).suffix.lower()
|
|
save_path = dest_path / f"{base_name}{ext}"
|
|
file.save(save_path)
|
|
|
|
if resize_dims:
|
|
if ext == ".gif":
|
|
width, height = resize_dims
|
|
palette_path = save_path.with_suffix(".palette.png")
|
|
temp_output_path = save_path.with_suffix(".resized.gif")
|
|
subprocess.run(["ffmpeg", "-i", str(save_path), "-vf", "palettegen", "-y", str(palette_path)], check=True)
|
|
subprocess.run(["ffmpeg", "-i", str(save_path), "-i", str(palette_path), "-lavfi", f"fps=20,scale={width}:{height}:flags=lanczos[x];[x][1:v]paletteuse", "-y", str(temp_output_path)], check=True)
|
|
palette_path.unlink()
|
|
temp_output_path.rename(save_path)
|
|
else:
|
|
pil_image = _get_pillow_image()
|
|
img = pil_image.open(save_path).resize(resize_dims, pil_image.Resampling.LANCZOS)
|
|
if ext != ".png":
|
|
save_path.unlink()
|
|
save_path = save_path.with_suffix(".png")
|
|
img.save(save_path, "PNG")
|
|
|
|
if save_checklist.get("steering_wheel"):
|
|
wheels_dir = THEME_SAVE_PATH / "steering_wheels"
|
|
wheels_dir.mkdir(parents=True, exist_ok=True)
|
|
file = files.get("steeringWheel")
|
|
saved_wheel_path = None
|
|
if file and file.filename:
|
|
if file.content_length > MAX_FILE_SIZE:
|
|
return None, f"File {file.filename} exceeds 1MB limit."
|
|
for f in wheels_dir.glob(f"{sane_theme_name}-user_created.*"):
|
|
f.unlink()
|
|
ext = Path(file.filename).suffix.lower()
|
|
saved_wheel_path = wheels_dir / f"{sane_theme_name}-user_created{ext}"
|
|
file.save(saved_wheel_path)
|
|
if ext == ".gif":
|
|
width, height = (250, 250)
|
|
palette_path = saved_wheel_path.with_suffix(".palette.png")
|
|
temp_output_path = saved_wheel_path.with_suffix(".resized.gif")
|
|
subprocess.run(["ffmpeg", "-i", str(saved_wheel_path), "-vf", "palettegen", "-y", str(palette_path)], check=True)
|
|
subprocess.run(["ffmpeg", "-i", str(saved_wheel_path), "-i", str(palette_path), "-lavfi", f"fps=20,scale={width}:{height}:flags=lanczos[x];[x][1:v]paletteuse", "-y", str(temp_output_path)], check=True)
|
|
palette_path.unlink()
|
|
temp_output_path.rename(saved_wheel_path)
|
|
else:
|
|
pil_image = _get_pillow_image()
|
|
img = pil_image.open(saved_wheel_path).resize((250, 250), pil_image.Resampling.LANCZOS)
|
|
if ext != ".png":
|
|
saved_wheel_path.unlink()
|
|
saved_wheel_path = saved_wheel_path.with_suffix(".png")
|
|
img.save(saved_wheel_path, "PNG")
|
|
if temporary and (theme_path is not None):
|
|
existing = saved_wheel_path if saved_wheel_path is not None else next(wheels_dir.glob(f"{sane_theme_name}-user_created.*"), None)
|
|
if existing:
|
|
wheel_icon_dir = theme_path / "WheelIcon"
|
|
wheel_icon_dir.mkdir(parents=True, exist_ok=True)
|
|
dest = wheel_icon_dir / f"wheel{existing.suffix.lower()}"
|
|
if dest.exists():
|
|
dest.unlink()
|
|
dest.symlink_to(existing)
|
|
|
|
if save_checklist.get("distance_icons"):
|
|
dist_path = theme_path / "distance_icons"
|
|
dist_path.mkdir(exist_ok=True)
|
|
for name in ["traffic", "aggressive", "standard", "relaxed"]:
|
|
file = files.get(f"distanceIcons_{name}")
|
|
if file and file.filename:
|
|
if file.content_length > MAX_FILE_SIZE:
|
|
return None, f"File {file.filename} exceeds 1MB limit."
|
|
|
|
for f in dist_path.glob(f"{name}.*"):
|
|
f.unlink()
|
|
|
|
ext = Path(file.filename).suffix.lower()
|
|
save_path = dist_path / f"{name}{ext}"
|
|
file.save(save_path)
|
|
if ext == ".gif":
|
|
width, height = (250, 250)
|
|
palette_path = save_path.with_suffix(".palette.png")
|
|
temp_output_path = save_path.with_suffix(".resized.gif")
|
|
subprocess.run(["ffmpeg", "-i", str(save_path), "-vf", "palettegen", "-y", str(palette_path)], check=True)
|
|
subprocess.run(["ffmpeg", "-i", str(save_path), "-i", str(palette_path), "-lavfi", f"fps=20,scale={width}:{height}:flags=lanczos[x];[x][1:v]paletteuse", "-y", str(temp_output_path)], check=True)
|
|
palette_path.unlink()
|
|
temp_output_path.rename(save_path)
|
|
else:
|
|
pil_image = _get_pillow_image()
|
|
img = pil_image.open(save_path).resize((250, 250), pil_image.Resampling.LANCZOS)
|
|
if ext != ".png":
|
|
save_path.unlink()
|
|
save_path = save_path.with_suffix(".png")
|
|
img.save(save_path, "PNG")
|
|
|
|
if save_checklist.get("sounds"):
|
|
sounds_path = theme_path / "sounds"
|
|
sounds_path.mkdir(exist_ok=True)
|
|
for name in ["engage", "disengage", "prompt", "startup"]:
|
|
file = files.get(name)
|
|
if file and file.filename:
|
|
if file.content_length > MAX_FILE_SIZE:
|
|
return None, f"File {file.filename} exceeds 1MB limit."
|
|
|
|
save_path = sounds_path / f"{name}{Path(file.filename).suffix}"
|
|
file.save(save_path)
|
|
covert_audio(str(save_path))
|
|
|
|
return theme_path, None
|
|
|
|
def decode_parameters(encoded_string):
|
|
obfuscated_data = base64.b64decode(encoded_string.encode("utf-8")).decode("utf-8")
|
|
decrypted_data = xor_encrypt_decrypt(obfuscated_data, XOR_KEY)
|
|
return json.loads(decrypted_data)
|
|
|
|
def encode_parameters(params_dict):
|
|
serialized_data = json.dumps(params_dict)
|
|
obfuscated_data = xor_encrypt_decrypt(serialized_data, XOR_KEY)
|
|
encoded_data = base64.b64encode(obfuscated_data.encode("utf-8")).decode("utf-8")
|
|
return encoded_data
|
|
|
|
def ffmpeg_concat_segments_to_mp4(input_files, cache_key=None):
|
|
if not input_files:
|
|
raise ValueError("No input files provided for concatenation")
|
|
|
|
VIDEO_CACHE_PATH.mkdir(exist_ok=True)
|
|
|
|
key_str = "|".join(str(p) for p in input_files)
|
|
if cache_key:
|
|
key_str = f"{cache_key}|{key_str}"
|
|
file_hash = hashlib.md5(key_str.encode()).hexdigest()
|
|
cache_path = VIDEO_CACHE_PATH / f"{file_hash}.mp4"
|
|
|
|
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")
|
|
|
|
list_file = VIDEO_CACHE_PATH / f"{file_hash}.txt"
|
|
with open(list_file, "w") as f:
|
|
for seg in input_files:
|
|
f.write(f"file '{Path(seg)}'\n")
|
|
|
|
try:
|
|
subprocess.run(
|
|
["ffmpeg", "-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",
|
|
"-i", str(list_file), "-c:v", "libx264", "-movflags", "faststart", "-y", str(cache_path)],
|
|
check=True
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
if cache_path.exists():
|
|
cache_path.unlink()
|
|
raise ValueError(f"Cannot process concatenated video segments: {input_files}")
|
|
finally:
|
|
if list_file.exists():
|
|
list_file.unlink()
|
|
|
|
return open(cache_path, "rb")
|
|
|
|
def ffmpeg_mp4_wrap_process_builder(filename):
|
|
input_path = Path(filename)
|
|
|
|
if not input_path.exists():
|
|
raise FileNotFoundError(f"Input file does not exist: {input_path}")
|
|
|
|
if input_path.stat().st_size == 0:
|
|
raise ValueError(f"Input file is empty: {input_path}")
|
|
|
|
lock_file = input_path.parent / "rlog.lock"
|
|
if lock_file.exists():
|
|
raise ValueError(f"File is still being recorded: {input_path}")
|
|
|
|
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")
|
|
|
|
try:
|
|
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(input_path), "-c", "copy", "-movflags", "faststart", "-y", str(cache_path)], check=True)
|
|
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()
|
|
raise ValueError(f"Cannot process video file: {input_path}")
|
|
|
|
return open(cache_path, "rb")
|
|
|
|
def format_git_date(raw_date: str):
|
|
date_object = datetime.strptime(raw_date.split()[1], "%Y-%m-%d")
|
|
|
|
day = date_object.day
|
|
suffix = "th" if 11 <= day <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(day % 10, "th")
|
|
|
|
return date_object.strftime(f"%B {day}{suffix}, %Y")
|
|
|
|
def get_all_segment_names(footage_path):
|
|
entries = listdir_by_creation(footage_path)
|
|
|
|
segment_names = []
|
|
for entry in entries:
|
|
if not SEGMENT_RE.fullmatch(entry):
|
|
continue
|
|
|
|
segment_names.append(segment_to_segment_name(footage_path, entry))
|
|
return segment_names
|
|
|
|
def get_available_cameras(segment_path):
|
|
segment_path = Path(segment_path)
|
|
return [
|
|
name for name, file in {
|
|
"driver": "dcamera.hevc",
|
|
"forward": "fcamera.hevc",
|
|
"wide": "ecamera.hevc"
|
|
}.items() if (segment_path / file).exists()
|
|
]
|
|
|
|
def get_disk_usage():
|
|
free = get_available_bytes(default=0)
|
|
used = get_used_bytes(default=0)
|
|
total = used + free
|
|
|
|
def to_gb(b):
|
|
return f"{b // (2**30)} GB"
|
|
|
|
return [{
|
|
"free": to_gb(free),
|
|
"size": to_gb(total),
|
|
"used": to_gb(used),
|
|
"usedPercentage": f"{(used / total) * 100:.2f}%" if total > 0 else "0.00%"
|
|
}]
|
|
|
|
def get_drive_stats():
|
|
stats = _decode_json_param(params.get("ApiCache_DriveStats"), {})
|
|
starpilot_stats = _decode_json_param(params.get("StarPilotStats"), {})
|
|
|
|
is_metric = params.get_bool("IsMetric")
|
|
unit = "kilometers" if is_metric else "miles"
|
|
|
|
def numeric(value, default=0.0):
|
|
try:
|
|
parsed = float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return parsed if parsed == parsed else default
|
|
|
|
def process(timeframe):
|
|
data = stats.get(timeframe, {})
|
|
distance_miles = numeric(data.get("distance", 0))
|
|
return {
|
|
"distance": distance_miles * (MILE_TO_KILOMETER if is_metric else 1),
|
|
"drives": numeric(data.get("routes", 0)),
|
|
"hours": numeric(data.get("minutes", 0)) / 60,
|
|
"unit": unit
|
|
}
|
|
|
|
stats["all"] = process("all")
|
|
stats["week"] = process("week")
|
|
stats["starpilot"] = {
|
|
"distance": numeric(starpilot_stats.get("StarPilotMeters", 0)) * (METER_TO_KILOMETER if is_metric else METER_TO_MILE),
|
|
"hours": numeric(starpilot_stats.get("StarPilotSeconds", 0)) / (60 * 60),
|
|
"drives": numeric(starpilot_stats.get("StarPilotDrives", 0)),
|
|
"unit": unit
|
|
}
|
|
|
|
return stats
|
|
|
|
|
|
def _params_get_value(params_obj, key, default=None):
|
|
if params_obj is None:
|
|
return default
|
|
try:
|
|
value = params_obj.get(key, encoding="utf-8")
|
|
except TypeError:
|
|
try:
|
|
value = params_obj.get(key)
|
|
except Exception:
|
|
return default
|
|
except Exception:
|
|
return default
|
|
|
|
if value is None:
|
|
return default
|
|
return value
|
|
|
|
|
|
def _params_get_text(params_obj, key, default=""):
|
|
value = _params_get_value(params_obj, key, default)
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bytes):
|
|
return value.decode("utf-8", errors="replace")
|
|
if isinstance(value, (dict, list)):
|
|
return json.dumps(value, separators=(",", ":"))
|
|
return str(value)
|
|
|
|
|
|
def _params_get_bool(params_obj, key):
|
|
if params_obj is None:
|
|
return False
|
|
try:
|
|
return bool(params_obj.get_bool(key))
|
|
except Exception:
|
|
value = _params_get_text(params_obj, key, "")
|
|
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
|
|
|
|
def _dashboard_param_file_path(key):
|
|
if key != DASHBOARD_PERSISTENT_STATS_PARAM:
|
|
return None
|
|
return DASHBOARD_PARAMS_DIR / key
|
|
|
|
|
|
def _read_dashboard_param_file(key):
|
|
path = _dashboard_param_file_path(key)
|
|
if path is None or not path.is_file():
|
|
return None
|
|
try:
|
|
return path.read_text(encoding="utf-8")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _write_dashboard_param_file(key, value):
|
|
path = _dashboard_param_file_path(key)
|
|
if path is None:
|
|
return False
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_path = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
tmp_path.write_text(str(value), encoding="utf-8")
|
|
os.replace(tmp_path, path)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _params_put_text(params_obj, key, value):
|
|
if params_obj is None:
|
|
return _write_dashboard_param_file(key, value)
|
|
try:
|
|
params_obj.put(key, value)
|
|
return True
|
|
except Exception:
|
|
return _write_dashboard_param_file(key, value)
|
|
|
|
|
|
def _split_csv(value):
|
|
return [entry.strip() for entry in str(value or "").split(",") if entry.strip()]
|
|
|
|
|
|
def _safe_int(value, default=0):
|
|
try:
|
|
return int(float(value))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _safe_float(value, default=0.0):
|
|
try:
|
|
parsed = float(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return parsed if parsed == parsed else default
|
|
|
|
|
|
def _clean_model_label(value):
|
|
clean = re.sub(r"[\U0001f5fa\ufe0f\U0001f440\U0001f4e1]", "", str(value or ""))
|
|
return clean.replace("(Default)", "").strip()
|
|
|
|
|
|
def _jsonable_time(value):
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
return ""
|
|
|
|
|
|
def _coerce_dashboard_time(value):
|
|
if isinstance(value, datetime):
|
|
return value
|
|
if isinstance(value, str):
|
|
try:
|
|
return datetime.fromisoformat(value)
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _dashboard_time_is_valid(value, now=None, require_recent=False):
|
|
parsed = _coerce_dashboard_time(value)
|
|
if parsed is None:
|
|
return False
|
|
if parsed < DASHBOARD_MIN_VALID_ROUTE_TIME:
|
|
return False
|
|
|
|
now = now or datetime.now()
|
|
if now < DASHBOARD_MIN_VALID_ROUTE_TIME:
|
|
return True
|
|
|
|
if parsed > now + timedelta(seconds=DASHBOARD_ROUTE_FUTURE_GRACE_SECONDS):
|
|
return False
|
|
if require_recent and parsed < now - timedelta(seconds=DASHBOARD_LOCAL_ROUTE_MAX_AGE_SECONDS):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _timestamp_to_dashboard_time(timestamp, require_recent=False):
|
|
timestamp = _safe_float(timestamp, 0.0)
|
|
if timestamp <= 0.0:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromtimestamp(timestamp)
|
|
except (OSError, OverflowError, ValueError):
|
|
return None
|
|
return parsed if _dashboard_time_is_valid(parsed, require_recent=require_recent) else None
|
|
|
|
|
|
def _parse_segment_dir_name(name):
|
|
if not SEGMENT_RE.fullmatch(name):
|
|
return None
|
|
route_name, segment_num_text = name.rsplit("--", 1)
|
|
try:
|
|
segment_num = int(segment_num_text)
|
|
except ValueError:
|
|
return None
|
|
return route_name, segment_num
|
|
|
|
|
|
def _segment_mtime(segment_path):
|
|
try:
|
|
return Path(segment_path).stat().st_mtime
|
|
except OSError:
|
|
return 0.0
|
|
|
|
|
|
def _segment_has_dashboard_log(segment_path):
|
|
path = Path(segment_path)
|
|
if not path.is_dir():
|
|
return False
|
|
return any((path / candidate).is_file() for candidate in ROUTE_TIME_LOG_CANDIDATES)
|
|
|
|
|
|
def _select_dashboard_segment_candidate(candidates):
|
|
if not candidates:
|
|
return None
|
|
return next((candidate for candidate in candidates if _segment_has_dashboard_log(candidate)), candidates[0])
|
|
|
|
|
|
def _list_dashboard_routes(footage_paths, limit=DASHBOARD_ROUTE_SCAN_LIMIT):
|
|
routes = {}
|
|
|
|
for footage_path in footage_paths or []:
|
|
root = Path(footage_path)
|
|
if not root.is_dir():
|
|
continue
|
|
|
|
try:
|
|
entries = list(root.iterdir())
|
|
except OSError:
|
|
continue
|
|
|
|
for entry in entries:
|
|
if not entry.is_dir():
|
|
continue
|
|
|
|
parsed = _parse_segment_dir_name(entry.name)
|
|
if parsed is None:
|
|
continue
|
|
|
|
route_name, segment_num = parsed
|
|
route = routes.setdefault(route_name, {
|
|
"name": route_name,
|
|
"segments_by_num": {},
|
|
"modified_at": 0.0,
|
|
"started_at": None,
|
|
})
|
|
route["segments_by_num"].setdefault(segment_num, []).append(entry)
|
|
route["modified_at"] = max(route["modified_at"], _segment_mtime(entry))
|
|
|
|
route_infos = []
|
|
for route in routes.values():
|
|
segments = []
|
|
for segment_num, candidates in sorted(route["segments_by_num"].items()):
|
|
selected = _select_dashboard_segment_candidate(candidates)
|
|
if selected is not None:
|
|
segments.append({"num": segment_num, "path": selected})
|
|
|
|
if not segments:
|
|
continue
|
|
|
|
first_segment = next((segment for segment in segments if segment["num"] == 0), segments[0])
|
|
try:
|
|
started_at = _timestamp_to_dashboard_time(first_segment["path"].stat().st_mtime, require_recent=True)
|
|
except OSError:
|
|
started_at = None
|
|
|
|
route_infos.append({
|
|
"name": route["name"],
|
|
"segments": segments,
|
|
"segmentCount": len(segments),
|
|
"startedAt": started_at,
|
|
"modifiedAt": route["modified_at"],
|
|
"timeSource": DASHBOARD_TIME_SOURCE_FILESYSTEM if started_at is not None else "",
|
|
})
|
|
|
|
route_infos.sort(key=lambda route: (
|
|
route["startedAt"].timestamp() if isinstance(route["startedAt"], datetime) else 0.0,
|
|
route["modifiedAt"],
|
|
route["name"],
|
|
), reverse=True)
|
|
return route_infos[:limit]
|
|
|
|
|
|
def _dashboard_cache_key(route_infos, params_obj):
|
|
param_keys = (
|
|
"IsMetric",
|
|
"Model",
|
|
"DrivingModel",
|
|
"DrivingModelName",
|
|
)
|
|
route_sig = tuple(
|
|
(route["name"], route["segmentCount"], round(route["modifiedAt"], 3))
|
|
for route in route_infos
|
|
)
|
|
param_sig = tuple(_params_get_text(params_obj, key, "") for key in param_keys)
|
|
return route_sig, param_sig
|
|
|
|
|
|
def _model_lookup(params_obj):
|
|
model_keys = _split_csv(_params_get_text(params_obj, "AvailableModels", ""))
|
|
model_names = _split_csv(_params_get_text(params_obj, "AvailableModelNames", ""))
|
|
model_series = _split_csv(_params_get_text(params_obj, "AvailableModelSeries", ""))
|
|
|
|
lookup = {}
|
|
for idx, key in enumerate(model_keys):
|
|
canonical_key = canonical_model_key(key)
|
|
if not canonical_key:
|
|
continue
|
|
name = model_names[idx] if idx < len(model_names) else key
|
|
series = model_series[idx] if idx < len(model_series) else ""
|
|
lookup[canonical_key] = {
|
|
"key": canonical_key,
|
|
"name": _clean_model_label(name) or canonical_key,
|
|
"series": series or "Custom Series",
|
|
}
|
|
return lookup
|
|
|
|
|
|
def _decode_init_param_value(value):
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, bytes):
|
|
return value.decode("utf-8", errors="replace").strip()
|
|
if isinstance(value, bytearray):
|
|
return bytes(value).decode("utf-8", errors="replace").strip()
|
|
try:
|
|
if not isinstance(value, str):
|
|
value = bytes(value)
|
|
return value.decode("utf-8", errors="replace").strip()
|
|
except Exception:
|
|
pass
|
|
return str(value).strip()
|
|
|
|
|
|
def _init_params_items(init_params):
|
|
if init_params is None:
|
|
return []
|
|
if hasattr(init_params, "items"):
|
|
return list(init_params.items())
|
|
|
|
entries = getattr(init_params, "entries", None)
|
|
if entries is not None:
|
|
items = []
|
|
try:
|
|
for entry in entries:
|
|
key = getattr(entry, "key", None)
|
|
value = getattr(entry, "value", None)
|
|
if key is not None:
|
|
items.append((key, value))
|
|
except Exception:
|
|
return []
|
|
return items
|
|
|
|
items = []
|
|
try:
|
|
for item in init_params:
|
|
key = getattr(item, "key", None)
|
|
value = getattr(item, "value", None)
|
|
if key is not None:
|
|
items.append((key, value))
|
|
except Exception:
|
|
return []
|
|
return items
|
|
|
|
|
|
def _route_model_from_init_data(init_data, model_names):
|
|
values = {}
|
|
for key, value in _init_params_items(getattr(init_data, "params", None)):
|
|
values[str(key)] = _decode_init_param_value(value)
|
|
|
|
display_name = _clean_model_label(values.get("DrivingModelName", ""))
|
|
if display_name:
|
|
return display_name
|
|
|
|
for key in ("DrivingModel", "Model"):
|
|
model_key = canonical_model_key(values.get(key, ""))
|
|
if model_key:
|
|
return model_names.get(model_key, {}).get("name", model_key)
|
|
|
|
return ""
|
|
|
|
|
|
def _event_name_text(value):
|
|
return str(value or "").split(".")[-1]
|
|
|
|
|
|
def _message_type(message):
|
|
try:
|
|
return message.which()
|
|
except Exception:
|
|
return getattr(message, "type", "")
|
|
|
|
|
|
def _message_payload(message, message_type):
|
|
return getattr(message, message_type, None)
|
|
|
|
|
|
def _deadline_reached(deadline):
|
|
return deadline is not None and time.monotonic() >= deadline
|
|
|
|
|
|
def _numeric_attr(value, attr):
|
|
try:
|
|
return getattr(value, attr)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _wall_time_seconds_from_payload(payload):
|
|
if payload is None:
|
|
return None
|
|
|
|
for attr in ("wallTimeNanos", "wallTimeNs", "unixTimestampNanos"):
|
|
value = _safe_float(_numeric_attr(payload, attr), 0.0)
|
|
if value > 1e12:
|
|
return value / 1e9
|
|
|
|
for attr in ("wallTimeMillis", "unixTimestampMillis"):
|
|
value = _safe_float(_numeric_attr(payload, attr), 0.0)
|
|
if value > 1e9:
|
|
return value / 1000.0
|
|
|
|
for attr in ("wallTime", "unixTimestamp"):
|
|
value = _safe_float(_numeric_attr(payload, attr), 0.0)
|
|
if value > 1e8:
|
|
return value
|
|
|
|
return None
|
|
|
|
|
|
def _log_wall_time_range(first_time, last_time, wall_time_offset, duration_seconds):
|
|
if first_time is None or wall_time_offset is None:
|
|
return None
|
|
|
|
start_seconds = first_time + wall_time_offset
|
|
end_seconds = (last_time + wall_time_offset) if last_time is not None else start_seconds
|
|
if end_seconds <= start_seconds and duration_seconds > 0:
|
|
end_seconds = start_seconds + duration_seconds
|
|
|
|
try:
|
|
start_time = datetime.fromtimestamp(start_seconds)
|
|
end_time = datetime.fromtimestamp(end_seconds)
|
|
except (OSError, OverflowError, ValueError):
|
|
return None
|
|
if not _dashboard_time_is_valid(start_time) or not _dashboard_time_is_valid(end_time):
|
|
return None
|
|
return _jsonable_time(start_time), _jsonable_time(end_time)
|
|
|
|
|
|
def _sample_route_info(route_info, limit=DASHBOARD_ROUTE_SEGMENT_SAMPLE_LIMIT):
|
|
segments = route_info.get("segments", [])
|
|
segment_count = len(segments)
|
|
if segment_count <= limit:
|
|
sampled = dict(route_info)
|
|
sampled["analysisSegmentCount"] = segment_count
|
|
return sampled
|
|
|
|
indices = []
|
|
if limit <= 1:
|
|
indices = [segment_count - 1]
|
|
else:
|
|
for idx in range(limit):
|
|
candidate = round(idx * (segment_count - 1) / (limit - 1))
|
|
if candidate not in indices:
|
|
indices.append(candidate)
|
|
|
|
if len(indices) < limit:
|
|
for candidate in range(segment_count - 1, -1, -1):
|
|
if candidate not in indices:
|
|
indices.append(candidate)
|
|
if len(indices) >= limit:
|
|
break
|
|
|
|
sampled = dict(route_info)
|
|
sampled["segments"] = [segments[idx] for idx in sorted(indices[:limit])]
|
|
sampled["analysisSegmentCount"] = len(sampled["segments"])
|
|
return sampled
|
|
|
|
|
|
def _analyze_route_messages(messages, route_info, model_names, is_metric, deadline=None):
|
|
first_time = None
|
|
last_time = None
|
|
previous_car_time = None
|
|
previous_speed = 0.0
|
|
previous_state_time = None
|
|
previous_enabled = False
|
|
previous_events = set()
|
|
|
|
distance_m = 0.0
|
|
engaged_seconds = 0.0
|
|
distracted_moments = 0
|
|
unresponsive_moments = 0
|
|
model = ""
|
|
wall_time_offset = None
|
|
|
|
for message in messages:
|
|
if _deadline_reached(deadline):
|
|
break
|
|
|
|
mono_time = getattr(message, "logMonoTime", None)
|
|
seconds = (mono_time / 1e9) if isinstance(mono_time, (int, float)) else None
|
|
if seconds is not None:
|
|
first_time = seconds if first_time is None else min(first_time, seconds)
|
|
last_time = seconds if last_time is None else max(last_time, seconds)
|
|
|
|
message_type = _message_type(message)
|
|
payload = _message_payload(message, message_type)
|
|
if seconds is not None and wall_time_offset is None:
|
|
wall_seconds = _wall_time_seconds_from_payload(payload)
|
|
if wall_seconds is None:
|
|
wall_seconds = _wall_time_seconds_from_payload(message)
|
|
if wall_seconds is not None:
|
|
wall_time_offset = wall_seconds - seconds
|
|
|
|
if message_type == "initData" and payload is not None and not model:
|
|
model = _route_model_from_init_data(payload, model_names)
|
|
|
|
elif message_type == "carState" and payload is not None and seconds is not None:
|
|
if previous_car_time is not None and seconds > previous_car_time:
|
|
distance_m += max(previous_speed, 0.0) * min(seconds - previous_car_time, 10.0)
|
|
previous_speed = _safe_float(getattr(payload, "vEgo", 0.0), 0.0)
|
|
previous_car_time = seconds
|
|
|
|
elif message_type == "selfdriveState" and payload is not None and seconds is not None:
|
|
if previous_state_time is not None and seconds > previous_state_time and previous_enabled:
|
|
engaged_seconds += min(seconds - previous_state_time, 10.0)
|
|
previous_enabled = bool(getattr(payload, "enabled", False))
|
|
previous_state_time = seconds
|
|
|
|
elif message_type == "onroadEvents":
|
|
current_events = {
|
|
_event_name_text(getattr(event, "name", ""))
|
|
for event in (payload or [])
|
|
}
|
|
if DASHBOARD_EVENT_DISTRACTED in current_events and DASHBOARD_EVENT_DISTRACTED not in previous_events:
|
|
distracted_moments += 1
|
|
if DASHBOARD_EVENT_UNRESPONSIVE in current_events and DASHBOARD_EVENT_UNRESPONSIVE not in previous_events:
|
|
unresponsive_moments += 1
|
|
previous_events = current_events
|
|
|
|
if previous_state_time is not None and last_time is not None and last_time > previous_state_time and previous_enabled:
|
|
engaged_seconds += min(last_time - previous_state_time, 10.0)
|
|
|
|
segment_count = max(0, int(route_info.get("segmentCount", 0)))
|
|
analysis_segment_count = max(0, int(route_info.get("analysisSegmentCount", segment_count)))
|
|
scale = (segment_count / analysis_segment_count) if analysis_segment_count > 0 and analysis_segment_count < segment_count else 1.0
|
|
fallback_duration = segment_count * 60
|
|
log_duration = max(0.0, (last_time - first_time) if first_time is not None and last_time is not None else 0.0)
|
|
if first_time is None or last_time is None:
|
|
duration_seconds = fallback_duration
|
|
elif scale > 1.0:
|
|
duration_seconds = max(log_duration, fallback_duration)
|
|
else:
|
|
duration_seconds = log_duration
|
|
|
|
if scale > 1.0:
|
|
distance_m *= scale
|
|
engaged_seconds = min(engaged_seconds * scale, duration_seconds)
|
|
|
|
engaged_percent = round((engaged_seconds / duration_seconds) * 100) if duration_seconds > 0 else 0
|
|
distance = distance_m * (METER_TO_KILOMETER if is_metric else METER_TO_MILE)
|
|
avg_speed = (distance_m / duration_seconds) * (CV.MS_TO_KPH if is_metric else METER_PER_SECOND_TO_MPH) if duration_seconds > 0 else 0.0
|
|
time_range = _log_wall_time_range(first_time, last_time, wall_time_offset, duration_seconds)
|
|
start_date, end_date = time_range if time_range is not None else _route_time_range(route_info, duration_seconds)
|
|
time_source = DASHBOARD_TIME_SOURCE_LOG if time_range is not None else (DASHBOARD_TIME_SOURCE_FILESYSTEM if start_date else "")
|
|
|
|
return {
|
|
"name": route_info.get("name", ""),
|
|
"routeNames": [route_info.get("name", "")],
|
|
"date": start_date,
|
|
"endDate": end_date,
|
|
"distance": round(distance, 1),
|
|
"distanceMeters": round(distance_m, 1),
|
|
"duration": int(round(duration_seconds)),
|
|
"avgSpeed": int(round(avg_speed)),
|
|
"engagedPercent": max(0, min(100, engaged_percent)),
|
|
"engagedSeconds": round(engaged_seconds, 1),
|
|
"model": model or "Unknown model",
|
|
"segmentCount": int(route_info.get("segmentCount", 0)),
|
|
"distractedMoments": distracted_moments,
|
|
"unresponsiveMoments": unresponsive_moments,
|
|
"routeModifiedAt": _safe_float(route_info.get("modifiedAt", 0.0), 0.0),
|
|
"timeSource": time_source,
|
|
"attentionKnown": True,
|
|
"analysisComplete": analysis_segment_count >= segment_count,
|
|
"analysisVersion": DASHBOARD_ROUTE_ANALYSIS_VERSION,
|
|
}
|
|
|
|
|
|
def _iter_route_log_messages(route_info, deadline=None):
|
|
try:
|
|
from openpilot.tools.lib.logreader import LogReader
|
|
except Exception:
|
|
return
|
|
|
|
for segment in route_info.get("segments", []):
|
|
if _deadline_reached(deadline):
|
|
return
|
|
|
|
log_path = get_route_log_path(segment.get("path"))
|
|
if log_path is None:
|
|
continue
|
|
try:
|
|
for message in LogReader(str(log_path), sort_by_time=False):
|
|
if _deadline_reached(deadline):
|
|
return
|
|
yield message
|
|
except Exception:
|
|
continue
|
|
|
|
|
|
def _empty_drive(is_metric):
|
|
return {
|
|
"name": "",
|
|
"routeNames": [],
|
|
"ignored": False,
|
|
"date": "",
|
|
"endDate": "",
|
|
"distance": 0,
|
|
"duration": 0,
|
|
"avgSpeed": 0,
|
|
"engagedPercent": 0,
|
|
"model": "Unknown model",
|
|
"segmentCount": 0,
|
|
"distractedMoments": 0,
|
|
"unresponsiveMoments": 0,
|
|
"attentionKnown": True,
|
|
"analysisComplete": False,
|
|
"distanceUnit": "kilometers" if is_metric else "miles",
|
|
"speedUnit": "kph" if is_metric else "mph",
|
|
}
|
|
|
|
|
|
def _public_drive(drive, is_metric):
|
|
public = _empty_drive(is_metric)
|
|
for key in public:
|
|
if key in drive:
|
|
public[key] = drive[key]
|
|
return public
|
|
|
|
|
|
def _route_time_range(route_info, duration_seconds):
|
|
modified_at = _safe_float(route_info.get("modifiedAt", 0.0), 0.0)
|
|
duration_seconds = max(0.0, _safe_float(duration_seconds, 0.0))
|
|
modified_time = _timestamp_to_dashboard_time(modified_at, require_recent=True)
|
|
if modified_time is not None and duration_seconds > 0.0:
|
|
end_time = modified_time
|
|
start_time = end_time - timedelta(seconds=duration_seconds)
|
|
return _jsonable_time(start_time), _jsonable_time(end_time)
|
|
started_at = route_info.get("startedAt")
|
|
if _dashboard_time_is_valid(started_at, require_recent=True):
|
|
return _jsonable_time(started_at), _jsonable_time(modified_time) if modified_time is not None else ""
|
|
return "", ""
|
|
|
|
|
|
def _distance_from_meters(distance_m, is_metric):
|
|
return distance_m * (METER_TO_KILOMETER if is_metric else METER_TO_MILE)
|
|
|
|
|
|
def _current_model_name(params_obj, model_names):
|
|
for key in ("DrivingModelName", "DrivingModel", "Model"):
|
|
value = _params_get_text(params_obj, key, "")
|
|
model_key = canonical_model_key(value)
|
|
if model_key and model_key in model_names:
|
|
return model_names[model_key]["name"]
|
|
label = _clean_model_label(value)
|
|
if label:
|
|
return label
|
|
return ""
|
|
|
|
|
|
def _route_shell_drive(route_info, params_obj, model_names, is_metric):
|
|
segment_count = max(0, _safe_int(route_info.get("segmentCount", 0), 0))
|
|
duration_seconds = segment_count * 60
|
|
start_date, end_date = _route_time_range(route_info, duration_seconds)
|
|
return {
|
|
"name": route_info.get("name", ""),
|
|
"routeNames": [route_info.get("name", "")],
|
|
"ignored": False,
|
|
"date": start_date,
|
|
"endDate": end_date,
|
|
"distance": 0,
|
|
"distanceMeters": 0.0,
|
|
"duration": duration_seconds,
|
|
"avgSpeed": 0,
|
|
"engagedPercent": 0,
|
|
"engagedSeconds": 0.0,
|
|
"model": _current_model_name(params_obj, model_names) or "Unknown model",
|
|
"segmentCount": segment_count,
|
|
"distractedMoments": 0,
|
|
"unresponsiveMoments": 0,
|
|
"routeModifiedAt": _safe_float(route_info.get("modifiedAt", 0.0), 0.0),
|
|
"timeSource": DASHBOARD_TIME_SOURCE_FILESYSTEM if start_date else "",
|
|
"attentionKnown": False,
|
|
"analysisComplete": False,
|
|
"analysisVersion": 0,
|
|
}
|
|
|
|
|
|
def _drive_from_persistent_route(route_name, entry, is_metric):
|
|
duration = max(0, _safe_int(entry.get("duration", 0), 0))
|
|
distance_m = max(0.0, _safe_float(entry.get("distanceMeters", 0.0), 0.0))
|
|
engaged_seconds = max(0.0, _safe_float(entry.get("engagedSeconds", 0.0), 0.0))
|
|
engaged_percent = round((engaged_seconds / duration) * 100) if duration > 0 else 0
|
|
avg_speed = (distance_m / duration) * (CV.MS_TO_KPH if is_metric else METER_PER_SECOND_TO_MPH) if duration > 0 else 0.0
|
|
return {
|
|
"name": route_name,
|
|
"routeNames": [route_name],
|
|
"ignored": False,
|
|
"date": entry.get("date", ""),
|
|
"endDate": entry.get("endDate", ""),
|
|
"distance": round(_distance_from_meters(distance_m, is_metric), 1),
|
|
"distanceMeters": round(distance_m, 1),
|
|
"duration": duration,
|
|
"avgSpeed": int(round(avg_speed)),
|
|
"engagedPercent": max(0, min(100, engaged_percent)),
|
|
"engagedSeconds": round(engaged_seconds, 1),
|
|
"model": _clean_model_label(entry.get("model", "")) or "Unknown model",
|
|
"segmentCount": max(0, _safe_int(entry.get("segmentCount", 0), 0)),
|
|
"distractedMoments": max(0, _safe_int(entry.get("distractedMoments", 0), 0)),
|
|
"unresponsiveMoments": max(0, _safe_int(entry.get("unresponsiveMoments", 0), 0)),
|
|
"routeModifiedAt": _safe_float(entry.get("modifiedAt", 0.0), 0.0),
|
|
"timeSource": str(entry.get("timeSource", "") or ""),
|
|
"attentionKnown": bool(entry.get("attentionKnown", True)),
|
|
"analysisComplete": bool(entry.get("analysisComplete", False)),
|
|
"analysisVersion": max(0, _safe_int(entry.get("analysisVersion", 0), 0)),
|
|
}
|
|
|
|
|
|
def _persistent_drives(stats, is_metric):
|
|
routes = stats.get("routes", {}) if isinstance(stats, dict) else {}
|
|
if not isinstance(routes, dict):
|
|
return []
|
|
ignored_routes = set(stats.get("ignoredRoutes", [])) if isinstance(stats.get("ignoredRoutes", []), list) else set()
|
|
drives = [
|
|
_drive_from_persistent_route(route_name, entry, is_metric)
|
|
for route_name, entry in routes.items()
|
|
if isinstance(entry, dict) and _dashboard_time_is_valid(entry.get("date", ""))
|
|
]
|
|
for drive in drives:
|
|
drive["ignored"] = drive["name"] in ignored_routes
|
|
return drives
|
|
|
|
|
|
def _merge_dashboard_drives(*drive_lists):
|
|
merged = {}
|
|
for drives in drive_lists:
|
|
for drive in drives or []:
|
|
route_name = str(drive.get("name", "")).strip()
|
|
if not route_name:
|
|
continue
|
|
existing = merged.get(route_name)
|
|
if existing is None:
|
|
merged[route_name] = dict(drive)
|
|
continue
|
|
|
|
existing_distance = _safe_float(existing.get("distanceMeters", existing.get("distance", 0.0)), 0.0)
|
|
drive_distance = _safe_float(drive.get("distanceMeters", drive.get("distance", 0.0)), 0.0)
|
|
existing_attention = bool(existing.get("attentionKnown", False))
|
|
drive_attention = bool(drive.get("attentionKnown", False))
|
|
if drive_distance > existing_distance or (drive_attention and not existing_attention):
|
|
merged[route_name] = dict(drive)
|
|
|
|
return sorted(merged.values(), key=_drive_sort_time, reverse=True)
|
|
|
|
|
|
def _drive_display_group_key(drive):
|
|
start_text = str(drive.get("date", "")).strip()
|
|
end_text = str(drive.get("endDate", "")).strip()
|
|
model_key = _model_usage_key(drive.get("model", ""))
|
|
if not start_text or not end_text or not model_key:
|
|
return None
|
|
return start_text, end_text, model_key
|
|
|
|
|
|
def _coalesced_drive_group(group, is_metric):
|
|
if len(group) == 1:
|
|
return dict(group[0])
|
|
|
|
ordered = sorted(group, key=_drive_sort_time, reverse=True)
|
|
primary = dict(ordered[0])
|
|
total_distance_m = sum(max(0.0, _safe_float(drive.get("distanceMeters", 0.0), 0.0)) for drive in ordered)
|
|
total_duration = sum(max(0, _safe_int(drive.get("duration", 0), 0)) for drive in ordered)
|
|
total_engaged = sum(max(0.0, _safe_float(drive.get("engagedSeconds", 0.0), 0.0)) for drive in ordered)
|
|
|
|
if total_distance_m > 0.0:
|
|
primary["distanceMeters"] = round(total_distance_m, 1)
|
|
primary["distance"] = round(_distance_from_meters(total_distance_m, is_metric), 1)
|
|
else:
|
|
primary["distance"] = round(sum(max(0.0, _safe_float(drive.get("distance", 0.0), 0.0)) for drive in ordered), 1)
|
|
primary["duration"] = total_duration
|
|
primary["engagedSeconds"] = round(total_engaged, 1)
|
|
primary["engagedPercent"] = max(0, min(100, round((total_engaged / total_duration) * 100))) if total_duration > 0 else 0
|
|
primary["avgSpeed"] = int(round((total_distance_m / total_duration) * (CV.MS_TO_KPH if is_metric else METER_PER_SECOND_TO_MPH))) if total_distance_m > 0.0 and total_duration > 0 else 0
|
|
primary["segmentCount"] = sum(max(0, _safe_int(drive.get("segmentCount", 0), 0)) for drive in ordered)
|
|
primary["distractedMoments"] = sum(max(0, _safe_int(drive.get("distractedMoments", 0), 0)) for drive in ordered)
|
|
primary["unresponsiveMoments"] = sum(max(0, _safe_int(drive.get("unresponsiveMoments", 0), 0)) for drive in ordered)
|
|
primary["routeModifiedAt"] = max(_safe_float(drive.get("routeModifiedAt", 0.0), 0.0) for drive in ordered)
|
|
primary["attentionKnown"] = any(bool(drive.get("attentionKnown", True)) for drive in ordered)
|
|
primary["analysisComplete"] = all(bool(drive.get("analysisComplete", False)) for drive in ordered)
|
|
route_names = []
|
|
for drive in ordered:
|
|
for route_name in drive.get("routeNames", [drive.get("name", "")]):
|
|
route_name = str(route_name or "").strip()
|
|
if route_name and route_name not in route_names:
|
|
route_names.append(route_name)
|
|
primary["routeNames"] = route_names
|
|
primary["name"] = ",".join(route_names)
|
|
primary["ignored"] = bool(route_names) and all(bool(drive.get("ignored", False)) for drive in ordered)
|
|
return primary
|
|
|
|
|
|
def _coalesce_display_drives(drives, is_metric):
|
|
groups = {}
|
|
fallback = []
|
|
for drive in drives or []:
|
|
group_key = _drive_display_group_key(drive)
|
|
if group_key is None:
|
|
fallback.append(dict(drive))
|
|
continue
|
|
groups.setdefault(group_key, []).append(drive)
|
|
|
|
coalesced = [_coalesced_drive_group(group, is_metric) for group in groups.values()]
|
|
coalesced.extend(fallback)
|
|
return sorted(coalesced, key=_drive_sort_time, reverse=True)
|
|
|
|
|
|
def _drive_has_stale_analysis(drive):
|
|
if not bool(drive.get("attentionKnown", True)) or not bool(drive.get("analysisComplete", False)):
|
|
return False
|
|
return _safe_int(drive.get("analysisVersion", 0), 0) < DASHBOARD_ROUTE_ANALYSIS_VERSION
|
|
|
|
|
|
def _week_summary_drives(drives, pending_route_names=None):
|
|
pending_route_names = pending_route_names or set()
|
|
return [
|
|
drive for drive in drives or []
|
|
if not (_drive_has_stale_analysis(drive) and str(drive.get("name", "")).strip() in pending_route_names)
|
|
]
|
|
|
|
|
|
def _persistent_route_needs_time_refresh(entry):
|
|
source = str(entry.get("timeSource", "") or "")
|
|
version = _safe_int(entry.get("analysisVersion", 0), 0)
|
|
if not source:
|
|
return version < DASHBOARD_ROUTE_ANALYSIS_VERSION
|
|
if source == DASHBOARD_TIME_SOURCE_FILESYSTEM:
|
|
return not _dashboard_time_is_valid(entry.get("date", ""), require_recent=True)
|
|
if source == DASHBOARD_TIME_SOURCE_LOG:
|
|
return not _dashboard_time_is_valid(entry.get("date", ""))
|
|
return True
|
|
|
|
|
|
def _analysis_candidates(route_infos, persistent_stats):
|
|
routes = persistent_stats.get("routes", {}) if isinstance(persistent_stats, dict) else {}
|
|
routes = routes if isinstance(routes, dict) else {}
|
|
ignored_routes = set(persistent_stats.get("ignoredRoutes", [])) if isinstance(persistent_stats, dict) else set()
|
|
|
|
def needs_analysis(route_info):
|
|
route_name = route_info.get("name", "")
|
|
entry = routes.get(route_name, {})
|
|
if not isinstance(entry, dict):
|
|
return True
|
|
if _safe_float(entry.get("modifiedAt", 0.0), 0.0) < _safe_float(route_info.get("modifiedAt", 0.0), 0.0):
|
|
return True
|
|
if _persistent_route_needs_time_refresh(entry):
|
|
return True
|
|
if _safe_int(entry.get("analysisVersion", 0), 0) < DASHBOARD_ROUTE_ANALYSIS_VERSION:
|
|
return True
|
|
return not bool(entry.get("attentionKnown", True)) or not bool(entry.get("analysisComplete", False))
|
|
|
|
missing = [
|
|
route_info for route_info in route_infos
|
|
if route_info.get("name", "") not in ignored_routes and needs_analysis(route_info)
|
|
]
|
|
return missing
|
|
|
|
|
|
def _mark_ignored_drives(drives, persistent_stats):
|
|
ignored_routes = set(persistent_stats.get("ignoredRoutes", [])) if isinstance(persistent_stats, dict) else set()
|
|
for drive in drives or []:
|
|
route_names = drive.get("routeNames", [drive.get("name", "")])
|
|
route_names = [str(route_name or "").strip() for route_name in route_names if str(route_name or "").strip()]
|
|
drive["routeNames"] = route_names
|
|
drive["ignored"] = bool(route_names) and all(route_name in ignored_routes for route_name in route_names)
|
|
return drives
|
|
|
|
|
|
def _invalidate_dashboard_cache():
|
|
_DASHBOARD_CACHE.update({
|
|
"key": None,
|
|
"updated_at": 0.0,
|
|
"value": None,
|
|
})
|
|
|
|
|
|
def warm_dashboard_stats(footage_paths=None):
|
|
params_obj = params
|
|
route_infos = _list_dashboard_routes(footage_paths or [])
|
|
|
|
if not route_infos:
|
|
return
|
|
|
|
is_metric = _params_get_bool(params_obj, "IsMetric")
|
|
model_names = _model_lookup(params_obj)
|
|
shell_drives = [
|
|
_route_shell_drive(route_info, params_obj, model_names, is_metric)
|
|
for route_info in route_infos
|
|
]
|
|
if shell_drives:
|
|
_update_dashboard_persistent_stats(params_obj, shell_drives, time.time())
|
|
|
|
persistent_stats = _load_dashboard_persistent_stats(params_obj)
|
|
candidates = _analysis_candidates(route_infos, persistent_stats)[:DASHBOARD_BACKGROUND_ROUTE_ANALYSIS_LIMIT]
|
|
for route_info in candidates:
|
|
full_route_info = dict(route_info)
|
|
full_route_info["analysisSegmentCount"] = max(0, _safe_int(route_info.get("segmentCount", 0), 0))
|
|
messages = _iter_route_log_messages(full_route_info)
|
|
drive = _analyze_route_messages(messages, full_route_info, model_names, is_metric)
|
|
_update_dashboard_persistent_stats(params_obj, [drive], time.time())
|
|
|
|
|
|
def _dashboard_worker_env(repo_root):
|
|
env = os.environ.copy()
|
|
pythonpath = [
|
|
"/usr/local/venv/lib/python3.12/site-packages",
|
|
str(repo_root / "starpilot" / "third_party"),
|
|
str(repo_root),
|
|
]
|
|
if env.get("PYTHONPATH"):
|
|
pythonpath.append(env["PYTHONPATH"])
|
|
env["PYTHONPATH"] = os.pathsep.join(pythonpath)
|
|
env.setdefault("OPENBLAS_NUM_THREADS", "1")
|
|
env.setdefault("OMP_NUM_THREADS", "1")
|
|
env.setdefault("MKL_NUM_THREADS", "1")
|
|
env.setdefault("NUMEXPR_NUM_THREADS", "1")
|
|
return env
|
|
|
|
|
|
def _dashboard_analyzer_running():
|
|
process = _DASHBOARD_ANALYZER_PROCESS
|
|
if process is not None and process.poll() is None:
|
|
return True
|
|
|
|
status = _read_dashboard_analyzer_status()
|
|
pid = _safe_int(status.get("pid", 0), 0)
|
|
started_at = _safe_float(status.get("startedAt", 0.0), 0.0)
|
|
if pid <= 0 or started_at <= 0:
|
|
return False
|
|
if (time.time() - started_at) > DASHBOARD_ANALYZER_STATUS_MAX_AGE_SECONDS:
|
|
_clear_dashboard_analyzer_status()
|
|
return False
|
|
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
_clear_dashboard_analyzer_status()
|
|
return False
|
|
except PermissionError:
|
|
return True
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _read_dashboard_analyzer_status():
|
|
try:
|
|
status = json.loads(DASHBOARD_ANALYZER_STATUS_PATH.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
return status if isinstance(status, dict) else {}
|
|
|
|
|
|
def _write_dashboard_analyzer_status(process, pending_count):
|
|
status = {
|
|
"pid": process.pid,
|
|
"startedAt": time.time(),
|
|
"pendingRoutes": max(0, _safe_int(pending_count, 0)),
|
|
"batchSize": min(DASHBOARD_BACKGROUND_ROUTE_ANALYSIS_LIMIT, max(0, _safe_int(pending_count, 0))),
|
|
}
|
|
tmp_path = DASHBOARD_ANALYZER_STATUS_PATH.with_suffix(".tmp")
|
|
try:
|
|
tmp_path.write_text(json.dumps(status, separators=(",", ":")), encoding="utf-8")
|
|
tmp_path.replace(DASHBOARD_ANALYZER_STATUS_PATH)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _clear_dashboard_analyzer_status():
|
|
try:
|
|
DASHBOARD_ANALYZER_STATUS_PATH.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _dashboard_analysis_status(candidates):
|
|
pending_count = len(candidates or [])
|
|
return {
|
|
"pendingRoutes": pending_count,
|
|
"running": _dashboard_analyzer_running(),
|
|
"batchSize": min(DASHBOARD_BACKGROUND_ROUTE_ANALYSIS_LIMIT, pending_count),
|
|
}
|
|
|
|
|
|
def _start_dashboard_background_analysis(footage_paths, route_infos, persistent_stats, candidates=None):
|
|
global _DASHBOARD_ANALYZER_PROCESS
|
|
|
|
candidates = candidates if candidates is not None else _analysis_candidates(route_infos, persistent_stats)
|
|
if not route_infos or not candidates:
|
|
return False
|
|
|
|
with _DASHBOARD_ANALYZER_LOCK:
|
|
if _dashboard_analyzer_running():
|
|
return True
|
|
repo_root = Path(__file__).resolve().parents[3]
|
|
worker_code = (
|
|
"import json, sys;"
|
|
"from openpilot.starpilot.system.the_galaxy import utilities;"
|
|
"utilities.warm_dashboard_stats(json.loads(sys.argv[1]))"
|
|
)
|
|
command = [
|
|
"nice",
|
|
"-n",
|
|
"19",
|
|
sys.executable or "python3",
|
|
"-c",
|
|
worker_code,
|
|
json.dumps([str(path) for path in (footage_paths or [])]),
|
|
]
|
|
log_file = None
|
|
try:
|
|
log_file = open(DASHBOARD_ANALYZER_LOG_PATH, "ab")
|
|
_DASHBOARD_ANALYZER_PROCESS = subprocess.Popen(
|
|
command,
|
|
cwd=str(repo_root),
|
|
env=_dashboard_worker_env(repo_root),
|
|
stdout=log_file,
|
|
stderr=log_file,
|
|
start_new_session=True,
|
|
)
|
|
_write_dashboard_analyzer_status(_DASHBOARD_ANALYZER_PROCESS, len(candidates))
|
|
except Exception:
|
|
_DASHBOARD_ANALYZER_PROCESS = None
|
|
finally:
|
|
if log_file is not None:
|
|
log_file.close()
|
|
|
|
return _dashboard_analyzer_running()
|
|
|
|
|
|
def _start_of_week(now):
|
|
return datetime(now.year, now.month, now.day) - timedelta(days=now.weekday())
|
|
|
|
|
|
def _build_week_summary(drives, now, is_metric):
|
|
week_start = _start_of_week(now)
|
|
week_end = week_start + timedelta(days=7)
|
|
day_buckets = [
|
|
{
|
|
"date": (week_start + timedelta(days=idx)).date().isoformat(),
|
|
"label": (week_start + timedelta(days=idx)).strftime("%a"),
|
|
"distance": 0.0,
|
|
}
|
|
for idx in range(7)
|
|
]
|
|
|
|
total_distance = 0.0
|
|
total_duration = 0
|
|
total_engaged = 0.0
|
|
total_drives = 0
|
|
|
|
for drive in drives:
|
|
try:
|
|
drive_date = datetime.fromisoformat(drive.get("date", ""))
|
|
except ValueError:
|
|
continue
|
|
if not (week_start <= drive_date < week_end):
|
|
continue
|
|
|
|
day_index = (drive_date.date() - week_start.date()).days
|
|
if 0 <= day_index < len(day_buckets):
|
|
day_buckets[day_index]["distance"] += _safe_float(drive.get("distance", 0.0), 0.0)
|
|
total_distance += _safe_float(drive.get("distance", 0.0), 0.0)
|
|
total_duration += _safe_int(drive.get("duration", 0), 0)
|
|
total_engaged += _safe_float(drive.get("engagedSeconds", 0.0), 0.0)
|
|
total_drives += 1
|
|
|
|
for bucket in day_buckets:
|
|
bucket["distance"] = round(bucket["distance"], 1)
|
|
|
|
return {
|
|
"distance": round(total_distance, 1),
|
|
"duration": total_duration,
|
|
"hours": round(total_duration / 3600, 1),
|
|
"drives": total_drives,
|
|
"engagedPercent": round((total_engaged / total_duration) * 100) if total_duration > 0 else 0,
|
|
"dailyDistance": day_buckets,
|
|
"distanceUnit": "kilometers" if is_metric else "miles",
|
|
}
|
|
|
|
|
|
def _format_record_date(date_text):
|
|
try:
|
|
parsed = datetime.fromisoformat(date_text)
|
|
if datetime.now().year == parsed.year:
|
|
return parsed.strftime("%b %-d")
|
|
return parsed.strftime("%b %-d, %Y")
|
|
except ValueError:
|
|
return "Unknown date"
|
|
|
|
|
|
def _format_record_hours(seconds):
|
|
hours = max(0.0, _safe_float(seconds, 0.0) / 3600.0)
|
|
return f"{hours:.1f} hour" if round(hours, 1) == 1.0 else f"{hours:.1f} hours"
|
|
|
|
|
|
def _drive_is_clean(drive):
|
|
return _safe_int(drive.get("unresponsiveMoments", 0), 0) == 0
|
|
|
|
|
|
def _drive_is_undistracted(drive):
|
|
return _safe_int(drive.get("distractedMoments", 0), 0) == 0
|
|
|
|
|
|
def _drive_sort_time(drive):
|
|
try:
|
|
return datetime.fromisoformat(drive.get("date", ""))
|
|
except ValueError:
|
|
return datetime.min
|
|
|
|
|
|
def _display_attention_records(stats):
|
|
records = {}
|
|
attention = stats.get("attentionRecords", {}) if isinstance(stats, dict) else {}
|
|
longest = attention.get("longestUndistractedDrive", {}) if isinstance(attention, dict) else {}
|
|
clean_streak = attention.get("cleanDriveStreak", {}) if isinstance(attention, dict) else {}
|
|
|
|
longest_duration = _safe_int(longest.get("duration", 0), 0) if isinstance(longest, dict) else 0
|
|
if longest_duration > 0:
|
|
records["longestUndistractedDrive"] = {
|
|
"value": _format_record_hours(longest_duration),
|
|
"detail": _format_record_date(longest.get("date", "")),
|
|
}
|
|
|
|
streak_drives = _safe_int(clean_streak.get("drives", 0), 0) if isinstance(clean_streak, dict) else 0
|
|
if streak_drives > 0:
|
|
start = _format_record_date(clean_streak.get("startDate", ""))
|
|
end = _format_record_date(clean_streak.get("endDate", ""))
|
|
detail = start if start == end else f"{start} - {end}"
|
|
records["cleanDriveStreak"] = {
|
|
"value": f"{streak_drives} drive" if streak_drives == 1 else f"{streak_drives} drives",
|
|
"detail": detail,
|
|
}
|
|
|
|
return records
|
|
|
|
|
|
def _display_distance_record_value(distance_m, is_metric):
|
|
distance = max(0.0, _safe_float(distance_m, 0.0)) * (METER_TO_KILOMETER if is_metric else METER_TO_MILE)
|
|
return f"{distance:.1f}"
|
|
|
|
|
|
def _display_personal_records(stats, is_metric):
|
|
records = _build_records([], is_metric)
|
|
if not isinstance(stats, dict):
|
|
return records
|
|
|
|
raw_records = stats.get("personalRecords", {})
|
|
if not isinstance(raw_records, dict) or not raw_records:
|
|
raw_records = _merge_personal_records(_build_personal_records_raw(stats.get("routes", {})), {}, stats.get("attentionRecords", {}))
|
|
|
|
unit = "kilometers" if is_metric else "miles"
|
|
|
|
longest_drive = raw_records.get("longestDrive", {}) if isinstance(raw_records, dict) else {}
|
|
longest_distance_m = _safe_float(longest_drive.get("distanceMeters", 0.0), 0.0) if isinstance(longest_drive, dict) else 0.0
|
|
if longest_distance_m > 0.0:
|
|
records["longestDrive"] = {
|
|
"value": _display_distance_record_value(longest_distance_m, is_metric),
|
|
"detail": f"{unit} - {_format_record_date(longest_drive.get('date', ''))}",
|
|
}
|
|
|
|
most_engaged = raw_records.get("mostEngagedDay", {}) if isinstance(raw_records, dict) else {}
|
|
most_engaged_percent = _safe_int(most_engaged.get("percent", 0), 0) if isinstance(most_engaged, dict) else 0
|
|
if most_engaged_percent > 0:
|
|
records["mostEngagedDay"] = {
|
|
"value": f"{most_engaged_percent}%",
|
|
"detail": _format_record_date(most_engaged.get("date", "")),
|
|
}
|
|
|
|
best_week = raw_records.get("bestWeek", {}) if isinstance(raw_records, dict) else {}
|
|
best_week_distance_m = _safe_float(best_week.get("distanceMeters", 0.0), 0.0) if isinstance(best_week, dict) else 0.0
|
|
if best_week_distance_m > 0.0:
|
|
records["bestWeek"] = {
|
|
"value": _display_distance_record_value(best_week_distance_m, is_metric),
|
|
"detail": f"{unit} - week of {_format_record_date(best_week.get('weekDate', ''))}",
|
|
}
|
|
|
|
highest_streak = raw_records.get("highestStreak", {}) if isinstance(raw_records, dict) else {}
|
|
highest_days = _safe_int(highest_streak.get("days", 0), 0) if isinstance(highest_streak, dict) else 0
|
|
if highest_days > 0:
|
|
records["highestStreak"] = {
|
|
"value": f"{highest_days} day" if highest_days == 1 else f"{highest_days} days",
|
|
"detail": "Consecutive drive days",
|
|
}
|
|
|
|
longest_undistracted = raw_records.get("longestUndistractedDrive", {}) if isinstance(raw_records, dict) else {}
|
|
longest_undistracted_duration = _safe_int(longest_undistracted.get("duration", 0), 0) if isinstance(longest_undistracted, dict) else 0
|
|
if longest_undistracted_duration > 0:
|
|
records["longestUndistractedDrive"] = {
|
|
"value": _format_record_hours(longest_undistracted_duration),
|
|
"detail": _format_record_date(longest_undistracted.get("date", "")),
|
|
}
|
|
|
|
clean_streak = raw_records.get("cleanDriveStreak", {}) if isinstance(raw_records, dict) else {}
|
|
clean_drives = _safe_int(clean_streak.get("drives", 0), 0) if isinstance(clean_streak, dict) else 0
|
|
if clean_drives > 0:
|
|
start = _format_record_date(clean_streak.get("startDate", ""))
|
|
end = _format_record_date(clean_streak.get("endDate", ""))
|
|
detail = start if start == end else f"{start} - {end}"
|
|
records["cleanDriveStreak"] = {
|
|
"value": f"{clean_drives} drive" if clean_drives == 1 else f"{clean_drives} drives",
|
|
"detail": detail,
|
|
}
|
|
|
|
return records
|
|
|
|
|
|
def _build_records(drives, is_metric):
|
|
unit = "kilometers" if is_metric else "miles"
|
|
empty = {"value": "0", "detail": unit}
|
|
if not drives:
|
|
return {
|
|
"longestDrive": empty,
|
|
"mostEngagedDay": {"value": "0%", "detail": "No drives"},
|
|
"bestWeek": empty,
|
|
"highestStreak": {"value": "0 days", "detail": "No drives"},
|
|
"longestUndistractedDrive": {"value": "0.0 hours", "detail": "No clean drives"},
|
|
"cleanDriveStreak": {"value": "0 drives", "detail": "No clean drives"},
|
|
}
|
|
|
|
longest = max(drives, key=lambda drive: _safe_float(drive.get("distance", 0), 0))
|
|
|
|
days = {}
|
|
weeks = {}
|
|
for drive in drives:
|
|
try:
|
|
drive_date = datetime.fromisoformat(drive.get("date", ""))
|
|
except ValueError:
|
|
continue
|
|
day_key = drive_date.date()
|
|
week_key = _start_of_week(drive_date).date()
|
|
days.setdefault(day_key, {"duration": 0, "engaged": 0.0})
|
|
days[day_key]["duration"] += _safe_int(drive.get("duration", 0), 0)
|
|
days[day_key]["engaged"] += _safe_float(drive.get("engagedSeconds", 0.0), 0.0)
|
|
weeks[week_key] = weeks.get(week_key, 0.0) + _safe_float(drive.get("distance", 0.0), 0.0)
|
|
|
|
if days:
|
|
most_engaged_date, most_engaged_data = max(
|
|
days.items(),
|
|
key=lambda item: (item[1]["engaged"] / item[1]["duration"]) if item[1]["duration"] > 0 else 0,
|
|
)
|
|
most_engaged_percent = round((most_engaged_data["engaged"] / most_engaged_data["duration"]) * 100) if most_engaged_data["duration"] > 0 else 0
|
|
else:
|
|
most_engaged_date = None
|
|
most_engaged_percent = 0
|
|
|
|
if weeks:
|
|
best_week_date, best_week_distance = max(weeks.items(), key=lambda item: item[1])
|
|
else:
|
|
best_week_date, best_week_distance = None, 0.0
|
|
|
|
longest_streak = 0
|
|
current_streak = 0
|
|
previous_day = None
|
|
for day in sorted(days):
|
|
if previous_day is not None and (day - previous_day).days == 1:
|
|
current_streak += 1
|
|
else:
|
|
current_streak = 1
|
|
longest_streak = max(longest_streak, current_streak)
|
|
previous_day = day
|
|
|
|
undistracted_drives = [drive for drive in drives if _drive_is_undistracted(drive)]
|
|
longest_undistracted = max(undistracted_drives, key=lambda drive: _safe_int(drive.get("duration", 0), 0)) if undistracted_drives else None
|
|
longest_undistracted_duration = _safe_int(longest_undistracted.get("duration", 0), 0) if longest_undistracted else 0
|
|
|
|
longest_clean_streak = 0
|
|
current_clean_streak = 0
|
|
for drive in sorted(drives, key=_drive_sort_time):
|
|
if _drive_is_clean(drive):
|
|
current_clean_streak += 1
|
|
else:
|
|
current_clean_streak = 0
|
|
longest_clean_streak = max(longest_clean_streak, current_clean_streak)
|
|
|
|
return {
|
|
"longestDrive": {
|
|
"value": f"{_safe_float(longest.get('distance'), 0):.1f}",
|
|
"detail": f"{unit} - {_format_record_date(longest.get('date', ''))}",
|
|
},
|
|
"mostEngagedDay": {
|
|
"value": f"{most_engaged_percent}%",
|
|
"detail": most_engaged_date.strftime("%b %-d") if most_engaged_date else "No drives",
|
|
},
|
|
"bestWeek": {
|
|
"value": f"{best_week_distance:.1f}",
|
|
"detail": f"{unit} - week of {best_week_date.strftime('%b %-d')}" if best_week_date else unit,
|
|
},
|
|
"highestStreak": {
|
|
"value": f"{longest_streak} day" if longest_streak == 1 else f"{longest_streak} days",
|
|
"detail": "Consecutive drive days",
|
|
},
|
|
"longestUndistractedDrive": {
|
|
"value": _format_record_hours(longest_undistracted_duration),
|
|
"detail": _format_record_date(longest_undistracted.get("date", "")) if longest_undistracted else "No undistracted drives",
|
|
},
|
|
"cleanDriveStreak": {
|
|
"value": f"{longest_clean_streak} drive" if longest_clean_streak == 1 else f"{longest_clean_streak} drives",
|
|
"detail": "No attention warnings",
|
|
},
|
|
}
|
|
|
|
|
|
def _normalize_persistent_routes(raw_routes):
|
|
if not isinstance(raw_routes, dict):
|
|
return {}
|
|
|
|
routes = {}
|
|
for route_name, entry in raw_routes.items():
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
name = str(route_name or entry.get("name", "")).strip()
|
|
date = str(entry.get("date", "")).strip()
|
|
if not name or not date or not _dashboard_time_is_valid(date):
|
|
continue
|
|
routes[name] = {
|
|
"date": date,
|
|
"endDate": str(entry.get("endDate", "")).strip(),
|
|
"distanceMeters": max(0.0, _safe_float(entry.get("distanceMeters", 0.0), 0.0)),
|
|
"duration": max(0, _safe_int(entry.get("duration", 0), 0)),
|
|
"clean": bool(entry.get("clean", False)),
|
|
"undistracted": bool(entry.get("undistracted", entry.get("clean", False))),
|
|
"engagedSeconds": max(0.0, _safe_float(entry.get("engagedSeconds", 0.0), 0.0)),
|
|
"distractedMoments": max(0, _safe_int(entry.get("distractedMoments", 0), 0)),
|
|
"unresponsiveMoments": max(0, _safe_int(entry.get("unresponsiveMoments", 0), 0)),
|
|
"model": _clean_model_label(entry.get("model", "")),
|
|
"modelKey": canonical_model_key(entry.get("modelKey", "")),
|
|
"segmentCount": max(0, _safe_int(entry.get("segmentCount", 0), 0)),
|
|
"modifiedAt": _safe_float(entry.get("modifiedAt", 0.0), 0.0),
|
|
"timeSource": str(entry.get("timeSource", "") or ""),
|
|
"attentionKnown": bool(entry.get("attentionKnown", True)),
|
|
"analysisComplete": bool(entry.get("analysisComplete", False)),
|
|
"analysisVersion": max(0, _safe_int(entry.get("analysisVersion", 0), 0)),
|
|
}
|
|
return routes
|
|
|
|
|
|
def _load_dashboard_persistent_stats(params_obj):
|
|
raw_data = _read_dashboard_param_file(DASHBOARD_PERSISTENT_STATS_PARAM)
|
|
if raw_data is None:
|
|
raw_data = _params_get_value(params_obj, DASHBOARD_PERSISTENT_STATS_PARAM, None)
|
|
data = _decode_json_param(raw_data, {})
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
data["version"] = 1
|
|
data["routes"] = _normalize_persistent_routes(data.get("routes", {}))
|
|
ignored_routes = data.get("ignoredRoutes", [])
|
|
data["ignoredRoutes"] = sorted({
|
|
str(route_name).strip()
|
|
for route_name in ignored_routes
|
|
if ROUTE_RE.fullmatch(str(route_name).strip())
|
|
}) if isinstance(ignored_routes, list) else []
|
|
attention = data.get("attentionRecords", {})
|
|
data["attentionRecords"] = attention if isinstance(attention, dict) else {}
|
|
personal_records = data.get("personalRecords", {})
|
|
data["personalRecords"] = personal_records if isinstance(personal_records, dict) else {}
|
|
model_usage = data.get("modelUsage", {})
|
|
data["modelUsage"] = model_usage if isinstance(model_usage, dict) else {}
|
|
return data
|
|
|
|
|
|
def _route_entry_sort_key(item):
|
|
route_name, entry = item
|
|
try:
|
|
route_time = datetime.fromisoformat(entry.get("date", ""))
|
|
except ValueError:
|
|
route_time = datetime.min
|
|
return route_time, route_name
|
|
|
|
|
|
def _model_usage_key(model_name):
|
|
clean_name = _clean_model_label(model_name)
|
|
if not clean_name or clean_name == "Unknown model":
|
|
return ""
|
|
return canonical_model_key(clean_name) or clean_name.lower()
|
|
|
|
|
|
def _better_record(current, previous, metric_key):
|
|
if not isinstance(previous, dict):
|
|
return current
|
|
if _safe_float(previous.get(metric_key, 0.0), 0.0) > _safe_float(current.get(metric_key, 0.0), 0.0):
|
|
return dict(previous)
|
|
return current
|
|
|
|
|
|
def _record_with_valid_dates(record, metric_key, *date_keys):
|
|
if not isinstance(record, dict):
|
|
return None
|
|
if _safe_float(record.get(metric_key, 0.0), 0.0) <= 0.0:
|
|
return record
|
|
for key in date_keys:
|
|
if not _dashboard_time_is_valid(record.get(key, "")):
|
|
return None
|
|
return record
|
|
|
|
|
|
def _build_personal_records_raw(routes):
|
|
ordered_routes = sorted((routes or {}).items(), key=_route_entry_sort_key)
|
|
records = {
|
|
"longestDrive": {"distanceMeters": 0.0, "date": ""},
|
|
"mostEngagedDay": {"percent": 0, "date": ""},
|
|
"bestWeek": {"distanceMeters": 0.0, "weekDate": ""},
|
|
"highestStreak": {"days": 0},
|
|
"longestUndistractedDrive": {"duration": 0, "date": ""},
|
|
"cleanDriveStreak": {"drives": 0, "startDate": "", "endDate": ""},
|
|
}
|
|
if not ordered_routes:
|
|
return records
|
|
|
|
days = {}
|
|
weeks = {}
|
|
current_clean_streak = 0
|
|
current_clean_start = ""
|
|
|
|
for _, entry in ordered_routes:
|
|
date_text = str(entry.get("date", "")).strip()
|
|
if not _dashboard_time_is_valid(date_text):
|
|
continue
|
|
try:
|
|
drive_date = datetime.fromisoformat(date_text)
|
|
except ValueError:
|
|
drive_date = None
|
|
|
|
distance_m = max(0.0, _safe_float(entry.get("distanceMeters", 0.0), 0.0))
|
|
duration = max(0, _safe_int(entry.get("duration", 0), 0))
|
|
engaged_seconds = max(0.0, _safe_float(entry.get("engagedSeconds", 0.0), 0.0))
|
|
|
|
if distance_m > _safe_float(records["longestDrive"].get("distanceMeters", 0.0), 0.0):
|
|
records["longestDrive"] = {"distanceMeters": distance_m, "date": date_text}
|
|
|
|
if drive_date is not None:
|
|
day_key = drive_date.date()
|
|
week_key = _start_of_week(drive_date).date()
|
|
days.setdefault(day_key, {"duration": 0, "engaged": 0.0})
|
|
days[day_key]["duration"] += duration
|
|
days[day_key]["engaged"] += engaged_seconds
|
|
weeks[week_key] = weeks.get(week_key, 0.0) + distance_m
|
|
|
|
attention_known = bool(entry.get("attentionKnown", True))
|
|
if attention_known and bool(entry.get("undistracted", False)) and duration > _safe_int(records["longestUndistractedDrive"].get("duration", 0), 0):
|
|
records["longestUndistractedDrive"] = {"duration": duration, "date": date_text}
|
|
|
|
if bool(entry.get("clean", False)) and attention_known:
|
|
if current_clean_streak == 0:
|
|
current_clean_start = date_text
|
|
current_clean_streak += 1
|
|
if current_clean_streak > _safe_int(records["cleanDriveStreak"].get("drives", 0), 0):
|
|
records["cleanDriveStreak"] = {
|
|
"drives": current_clean_streak,
|
|
"startDate": current_clean_start,
|
|
"endDate": date_text,
|
|
}
|
|
elif attention_known:
|
|
current_clean_streak = 0
|
|
current_clean_start = ""
|
|
|
|
for day, data in days.items():
|
|
duration = _safe_int(data.get("duration", 0), 0)
|
|
if duration <= 0:
|
|
continue
|
|
percent = round((_safe_float(data.get("engaged", 0.0), 0.0) / duration) * 100)
|
|
if percent > _safe_int(records["mostEngagedDay"].get("percent", 0), 0):
|
|
records["mostEngagedDay"] = {"percent": percent, "date": day.isoformat()}
|
|
|
|
if weeks:
|
|
best_week_date, best_week_distance = max(weeks.items(), key=lambda item: item[1])
|
|
records["bestWeek"] = {
|
|
"distanceMeters": max(0.0, _safe_float(best_week_distance, 0.0)),
|
|
"weekDate": best_week_date.isoformat(),
|
|
}
|
|
|
|
current_day_streak = 0
|
|
previous_day = None
|
|
for day in sorted(days):
|
|
if previous_day is not None and (day - previous_day).days == 1:
|
|
current_day_streak += 1
|
|
else:
|
|
current_day_streak = 1
|
|
records["highestStreak"]["days"] = max(_safe_int(records["highestStreak"].get("days", 0), 0), current_day_streak)
|
|
previous_day = day
|
|
|
|
return records
|
|
|
|
|
|
def _merge_personal_records(current, previous, legacy_attention=None):
|
|
previous = previous if isinstance(previous, dict) else {}
|
|
merged = {
|
|
"longestDrive": _better_record(
|
|
current.get("longestDrive", {}),
|
|
_record_with_valid_dates(previous.get("longestDrive"), "distanceMeters", "date"),
|
|
"distanceMeters",
|
|
),
|
|
"mostEngagedDay": _better_record(
|
|
current.get("mostEngagedDay", {}),
|
|
_record_with_valid_dates(previous.get("mostEngagedDay"), "percent", "date"),
|
|
"percent",
|
|
),
|
|
"bestWeek": _better_record(
|
|
current.get("bestWeek", {}),
|
|
_record_with_valid_dates(previous.get("bestWeek"), "distanceMeters", "weekDate"),
|
|
"distanceMeters",
|
|
),
|
|
"highestStreak": _better_record(current.get("highestStreak", {}), previous.get("highestStreak"), "days"),
|
|
"longestUndistractedDrive": _better_record(
|
|
current.get("longestUndistractedDrive", {}),
|
|
_record_with_valid_dates(previous.get("longestUndistractedDrive"), "duration", "date"),
|
|
"duration",
|
|
),
|
|
"cleanDriveStreak": _better_record(
|
|
current.get("cleanDriveStreak", {}),
|
|
_record_with_valid_dates(previous.get("cleanDriveStreak"), "drives", "startDate", "endDate"),
|
|
"drives",
|
|
),
|
|
}
|
|
|
|
if isinstance(legacy_attention, dict):
|
|
merged["longestUndistractedDrive"] = _better_record(
|
|
merged["longestUndistractedDrive"],
|
|
_record_with_valid_dates(legacy_attention.get("longestUndistractedDrive"), "duration", "date"),
|
|
"duration",
|
|
)
|
|
merged["cleanDriveStreak"] = _better_record(
|
|
merged["cleanDriveStreak"],
|
|
_record_with_valid_dates(legacy_attention.get("cleanDriveStreak"), "drives", "startDate", "endDate"),
|
|
"drives",
|
|
)
|
|
|
|
return merged
|
|
|
|
|
|
def _recalculate_persistent_stats(stats, reset_personal_records=False):
|
|
routes = stats.get("routes", {})
|
|
ordered_routes = sorted(routes.items(), key=_route_entry_sort_key)
|
|
if len(ordered_routes) > DASHBOARD_PERSISTED_ROUTE_LIMIT:
|
|
ordered_routes = ordered_routes[-DASHBOARD_PERSISTED_ROUTE_LIMIT:]
|
|
routes = dict(ordered_routes)
|
|
stats["routes"] = routes
|
|
|
|
ignored_routes = set(stats.get("ignoredRoutes", []))
|
|
included_routes = {
|
|
route_name: entry
|
|
for route_name, entry in routes.items()
|
|
if route_name not in ignored_routes
|
|
}
|
|
included_ordered_routes = [
|
|
(route_name, entry)
|
|
for route_name, entry in ordered_routes
|
|
if route_name not in ignored_routes
|
|
]
|
|
model_usage = {}
|
|
|
|
for _, entry in included_ordered_routes:
|
|
if not _dashboard_time_is_valid(entry.get("date", "")):
|
|
continue
|
|
if not bool(entry.get("analysisComplete", False)):
|
|
continue
|
|
model_name = _clean_model_label(entry.get("model", ""))
|
|
model_key = canonical_model_key(entry.get("modelKey", "")) or _model_usage_key(model_name)
|
|
if model_key:
|
|
usage = model_usage.setdefault(model_key, {
|
|
"key": model_key,
|
|
"name": model_name or model_key,
|
|
"drives": 0,
|
|
"lastUsed": "",
|
|
})
|
|
usage["drives"] += 1
|
|
usage["lastUsed"] = entry.get("date", "") or usage["lastUsed"]
|
|
if model_name:
|
|
usage["name"] = model_name
|
|
|
|
previous_attention = stats.get("attentionRecords", {}) if isinstance(stats.get("attentionRecords", {}), dict) else {}
|
|
current_records = _build_personal_records_raw(included_routes)
|
|
if reset_personal_records:
|
|
stats["personalRecords"] = current_records
|
|
else:
|
|
stats["personalRecords"] = _merge_personal_records(current_records, stats.get("personalRecords", {}), previous_attention)
|
|
|
|
stats["attentionRecords"] = {
|
|
"longestUndistractedDrive": stats["personalRecords"]["longestUndistractedDrive"],
|
|
"cleanDriveStreak": stats["personalRecords"]["cleanDriveStreak"],
|
|
}
|
|
stats["modelUsage"] = model_usage
|
|
return stats
|
|
|
|
|
|
def set_dashboard_routes_ignored(params_obj, route_names, ignored):
|
|
normalized_names = {
|
|
str(route_name or "").strip()
|
|
for route_name in (route_names or [])
|
|
if ROUTE_RE.fullmatch(str(route_name or "").strip())
|
|
}
|
|
if not normalized_names:
|
|
raise ValueError("No valid dashboard routes were provided.")
|
|
|
|
stats = _load_dashboard_persistent_stats(params_obj)
|
|
ignored_routes = set(stats.get("ignoredRoutes", []))
|
|
if ignored:
|
|
ignored_routes.update(normalized_names)
|
|
else:
|
|
ignored_routes.difference_update(normalized_names)
|
|
stats["ignoredRoutes"] = sorted(ignored_routes)
|
|
stats = _recalculate_persistent_stats(stats, reset_personal_records=True)
|
|
_params_put_text(params_obj, DASHBOARD_PERSISTENT_STATS_PARAM, json.dumps(stats, separators=(",", ":")))
|
|
_invalidate_dashboard_cache()
|
|
return sorted(normalized_names)
|
|
|
|
|
|
def ignore_dashboard_routes(params_obj, route_names):
|
|
return set_dashboard_routes_ignored(params_obj, route_names, True)
|
|
|
|
|
|
def include_dashboard_routes(params_obj, route_names):
|
|
return set_dashboard_routes_ignored(params_obj, route_names, False)
|
|
|
|
|
|
def _drive_stable_for_persistence(drive, wall_now):
|
|
modified_at = _safe_float(drive.get("routeModifiedAt", 0.0), 0.0)
|
|
return modified_at <= 0.0 or wall_now - modified_at >= DASHBOARD_PERSIST_MIN_ROUTE_AGE_SECONDS
|
|
|
|
|
|
def _drive_time_reliable_for_persistence(drive):
|
|
source = str(drive.get("timeSource", "") or "")
|
|
date_text = str(drive.get("date", "")).strip()
|
|
if source == DASHBOARD_TIME_SOURCE_FILESYSTEM:
|
|
return _dashboard_time_is_valid(date_text, require_recent=True)
|
|
if source == DASHBOARD_TIME_SOURCE_LOG:
|
|
return _dashboard_time_is_valid(date_text)
|
|
return bool(date_text)
|
|
|
|
|
|
def _update_dashboard_persistent_stats(params_obj, drives, wall_now):
|
|
stats = _load_dashboard_persistent_stats(params_obj)
|
|
before = json.dumps(stats, sort_keys=True, separators=(",", ":"))
|
|
routes = stats.setdefault("routes", {})
|
|
changed = False
|
|
|
|
for drive in sorted(drives, key=_drive_sort_time):
|
|
route_name = str(drive.get("name", "")).strip()
|
|
route_date = str(drive.get("date", "")).strip()
|
|
if not route_name or not route_date or not _drive_stable_for_persistence(drive, wall_now) or not _drive_time_reliable_for_persistence(drive):
|
|
continue
|
|
|
|
model_name = _clean_model_label(drive.get("model", ""))
|
|
model_key = _model_usage_key(model_name)
|
|
attention_known = bool(drive.get("attentionKnown", True))
|
|
time_source = str(drive.get("timeSource", "") or "")
|
|
next_entry = {
|
|
"date": route_date,
|
|
"endDate": str(drive.get("endDate", "")).strip(),
|
|
"distanceMeters": max(0.0, _safe_float(drive.get("distanceMeters", 0.0), 0.0)),
|
|
"duration": max(0, _safe_int(drive.get("duration", 0), 0)),
|
|
"clean": attention_known and _drive_is_clean(drive),
|
|
"undistracted": attention_known and _drive_is_undistracted(drive),
|
|
"engagedSeconds": max(0.0, _safe_float(drive.get("engagedSeconds", 0.0), 0.0)),
|
|
"distractedMoments": max(0, _safe_int(drive.get("distractedMoments", 0), 0)),
|
|
"unresponsiveMoments": max(0, _safe_int(drive.get("unresponsiveMoments", 0), 0)),
|
|
"model": model_name,
|
|
"modelKey": model_key,
|
|
"segmentCount": max(0, _safe_int(drive.get("segmentCount", 0), 0)),
|
|
"modifiedAt": _safe_float(drive.get("routeModifiedAt", 0.0), 0.0),
|
|
"timeSource": time_source,
|
|
"attentionKnown": attention_known,
|
|
"analysisComplete": bool(drive.get("analysisComplete", False)),
|
|
"analysisVersion": _safe_int(drive.get("analysisVersion", 0), 0),
|
|
}
|
|
if attention_known and next_entry["analysisComplete"]:
|
|
next_entry["analysisVersion"] = DASHBOARD_ROUTE_ANALYSIS_VERSION
|
|
existing_entry = routes.get(route_name)
|
|
if isinstance(existing_entry, dict):
|
|
existing_distance = max(0.0, _safe_float(existing_entry.get("distanceMeters", 0.0), 0.0))
|
|
next_distance = max(0.0, _safe_float(next_entry.get("distanceMeters", 0.0), 0.0))
|
|
existing_current = _safe_float(existing_entry.get("modifiedAt", 0.0), 0.0) >= _safe_float(next_entry.get("modifiedAt", 0.0), 0.0)
|
|
existing_attention_known = bool(existing_entry.get("attentionKnown", True))
|
|
if not attention_known and existing_current and existing_attention_known:
|
|
next_entry["clean"] = bool(existing_entry.get("clean", False))
|
|
next_entry["undistracted"] = bool(existing_entry.get("undistracted", existing_entry.get("clean", False)))
|
|
next_entry["attentionKnown"] = True
|
|
next_entry["analysisComplete"] = bool(existing_entry.get("analysisComplete", False))
|
|
next_entry["analysisVersion"] = max(0, _safe_int(existing_entry.get("analysisVersion", 0), 0))
|
|
if not attention_known and existing_current and existing_distance >= next_distance:
|
|
next_entry["distanceMeters"] = existing_distance
|
|
existing_duration = _safe_int(existing_entry.get("duration", 0), 0)
|
|
next_entry["duration"] = existing_duration if existing_attention_known else max(existing_duration, next_entry["duration"])
|
|
if existing_attention_known and str(existing_entry.get("date", "")).strip():
|
|
next_entry["date"] = str(existing_entry.get("date", "")).strip()
|
|
next_entry["timeSource"] = str(existing_entry.get("timeSource", "") or next_entry["timeSource"])
|
|
if str(existing_entry.get("endDate", "")).strip():
|
|
next_entry["endDate"] = str(existing_entry.get("endDate", "")).strip()
|
|
next_entry["engagedSeconds"] = max(0.0, _safe_float(existing_entry.get("engagedSeconds", 0.0), 0.0))
|
|
next_entry["distractedMoments"] = max(0, _safe_int(existing_entry.get("distractedMoments", 0), 0))
|
|
next_entry["unresponsiveMoments"] = max(0, _safe_int(existing_entry.get("unresponsiveMoments", 0), 0))
|
|
next_entry["analysisComplete"] = bool(existing_entry.get("analysisComplete", False))
|
|
next_entry["analysisVersion"] = max(0, _safe_int(existing_entry.get("analysisVersion", 0), 0))
|
|
if (not model_name or model_name == "Unknown model") and _clean_model_label(existing_entry.get("model", "")):
|
|
next_entry["model"] = _clean_model_label(existing_entry.get("model", ""))
|
|
next_entry["modelKey"] = canonical_model_key(existing_entry.get("modelKey", "")) or _model_usage_key(next_entry["model"])
|
|
if routes.get(route_name) != next_entry:
|
|
routes[route_name] = next_entry
|
|
changed = True
|
|
|
|
stats = _recalculate_persistent_stats(stats)
|
|
after = json.dumps(stats, sort_keys=True, separators=(",", ":"))
|
|
if changed or after != before:
|
|
_params_put_text(params_obj, DASHBOARD_PERSISTENT_STATS_PARAM, json.dumps(stats, separators=(",", ":")))
|
|
return stats
|
|
|
|
|
|
def _storage_category(path):
|
|
path_text = str(path)
|
|
if "realdata_HD" in path_text:
|
|
return "highResolution"
|
|
if "realdata_konik" in path_text:
|
|
return "alternate"
|
|
return "standard"
|
|
|
|
|
|
def _build_storage_summary(footage_paths):
|
|
free = get_available_bytes(default=0)
|
|
used = get_used_bytes(default=0)
|
|
total = free + used
|
|
counts = {"standard": 0, "highResolution": 0, "alternate": 0}
|
|
|
|
for footage_path in footage_paths or []:
|
|
root = Path(footage_path)
|
|
if not root.is_dir():
|
|
continue
|
|
category = _storage_category(root)
|
|
try:
|
|
counts[category] += sum(1 for entry in root.iterdir() if entry.is_dir() and _parse_segment_dir_name(entry.name))
|
|
except OSError:
|
|
continue
|
|
|
|
return {
|
|
"freeBytes": free,
|
|
"usedBytes": used,
|
|
"totalBytes": total,
|
|
"usedPercent": round((used / total) * 100) if total > 0 else 0,
|
|
"segmentCounts": counts,
|
|
}
|
|
|
|
|
|
def _read_uptime_seconds():
|
|
try:
|
|
with open("/proc/uptime", encoding="utf-8") as f:
|
|
return int(float(f.read().split()[0]))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _normalize_temp_c(value):
|
|
try:
|
|
raw = float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if raw > 1000:
|
|
raw /= 1000.0
|
|
return raw if 0 < raw < 150 else None
|
|
|
|
|
|
def _read_hardware_cpu_temps():
|
|
try:
|
|
from openpilot.system.hardware import HARDWARE
|
|
thermal_config = HARDWARE.get_thermal_config()
|
|
thermal_msg = thermal_config.get_msg()
|
|
except Exception:
|
|
return []
|
|
|
|
cpu_temps = thermal_msg.get("cpuTempC", [])
|
|
if not isinstance(cpu_temps, (list, tuple)):
|
|
cpu_temps = [cpu_temps]
|
|
return [
|
|
temp for temp in (_normalize_temp_c(value) for value in cpu_temps)
|
|
if temp is not None
|
|
]
|
|
|
|
|
|
def _read_cpu_temp_c(thermal_root=None):
|
|
if thermal_root is None:
|
|
hardware_temps = _read_hardware_cpu_temps()
|
|
if hardware_temps:
|
|
return round(max(hardware_temps))
|
|
thermal_root = Path("/sys/class/thermal")
|
|
|
|
try:
|
|
zones = sorted(thermal_root.glob("thermal_zone*/temp"))
|
|
except Exception:
|
|
return None
|
|
|
|
values = []
|
|
for temp_path in zones:
|
|
try:
|
|
zone_type = temp_path.with_name("type").read_text(encoding="utf-8").strip().lower()
|
|
except Exception:
|
|
zone_type = ""
|
|
if "cpu" not in zone_type:
|
|
continue
|
|
try:
|
|
raw = temp_path.read_text().strip()
|
|
except Exception:
|
|
continue
|
|
temp = _normalize_temp_c(raw)
|
|
if temp is not None:
|
|
values.append(temp)
|
|
|
|
return round(max(values)) if values else None
|
|
|
|
|
|
def _build_device_summary(params_obj):
|
|
is_onroad = _params_get_bool(params_obj, "IsOnroad")
|
|
uptime_seconds = _read_uptime_seconds()
|
|
cpu_temp_c = _read_cpu_temp_c()
|
|
return {
|
|
"status": "Driving" if is_onroad else "Parked",
|
|
"online": True,
|
|
"uptimeSeconds": uptime_seconds,
|
|
"cpuTempC": cpu_temp_c,
|
|
}
|
|
|
|
|
|
def _build_favorite_models(params_obj, persistent_stats=None):
|
|
lookup = _model_lookup(params_obj)
|
|
user_favorites = {canonical_model_key(entry) for entry in _split_csv(_params_get_text(params_obj, "UserFavorites", ""))}
|
|
listed_favorites = {canonical_model_key(entry) for entry in _split_csv(_params_get_text(params_obj, "CommunityFavorites", ""))}
|
|
usage = (persistent_stats or {}).get("modelUsage", {})
|
|
usage = usage if isinstance(usage, dict) else {}
|
|
|
|
top_models = []
|
|
for raw_key, usage_entry in usage.items():
|
|
if not isinstance(usage_entry, dict):
|
|
continue
|
|
|
|
drives = _safe_int(usage_entry.get("drives", 0), 0)
|
|
if drives <= 0:
|
|
continue
|
|
|
|
key = canonical_model_key(usage_entry.get("key", "")) or canonical_model_key(raw_key)
|
|
name = _clean_model_label(usage_entry.get("name", ""))
|
|
if not key:
|
|
key = _model_usage_key(name)
|
|
if not key:
|
|
continue
|
|
|
|
model = lookup.get(key, {"key": key, "name": name or key, "series": "Custom Series"})
|
|
top_models.append({
|
|
"key": key,
|
|
"name": model["name"],
|
|
"series": model["series"],
|
|
"userFavorite": key in user_favorites,
|
|
"listedFavorite": key in listed_favorites,
|
|
"drives": drives,
|
|
"weight": drives,
|
|
})
|
|
|
|
top_models.sort(key=lambda model: (
|
|
-model["drives"],
|
|
model["name"].lower(),
|
|
))
|
|
return top_models[:DASHBOARD_TOP_MODEL_LIMIT]
|
|
|
|
|
|
def _dashboard_empty(is_metric, now, footage_paths, params_obj, persistent_stats=None):
|
|
records = _display_personal_records(persistent_stats or {}, is_metric)
|
|
return {
|
|
"lastDrive": _empty_drive(is_metric),
|
|
"recentDrives": [],
|
|
"week": _build_week_summary([], now, is_metric),
|
|
"records": records,
|
|
"device": _build_device_summary(params_obj),
|
|
"storage": _build_storage_summary(footage_paths),
|
|
"favoriteModels": _build_favorite_models(params_obj, persistent_stats),
|
|
}
|
|
|
|
|
|
def get_dashboard_stats(footage_paths, params_obj=None, now=None):
|
|
params_obj = params_obj or params
|
|
now = now or datetime.now()
|
|
route_infos = _list_dashboard_routes(footage_paths)
|
|
cache_key = _dashboard_cache_key(route_infos, params_obj)
|
|
cache_now = time.monotonic()
|
|
|
|
if (
|
|
_DASHBOARD_CACHE["key"] == cache_key
|
|
and _DASHBOARD_CACHE["value"] is not None
|
|
and cache_now - _DASHBOARD_CACHE["updated_at"] < DASHBOARD_CACHE_TTL_SECONDS
|
|
):
|
|
cached_dashboard = copy.deepcopy(_DASHBOARD_CACHE["value"])
|
|
cached_analysis = cached_dashboard.get("analysis", {}) if isinstance(cached_dashboard, dict) else {}
|
|
if isinstance(cached_analysis, dict):
|
|
cached_analysis["running"] = _dashboard_analyzer_running()
|
|
cached_dashboard["analysis"] = cached_analysis
|
|
return cached_dashboard
|
|
|
|
is_metric = _params_get_bool(params_obj, "IsMetric")
|
|
model_names = _model_lookup(params_obj)
|
|
analysis_deadline = cache_now + DASHBOARD_ANALYSIS_TIME_BUDGET_SECONDS
|
|
|
|
persistent_stats = _load_dashboard_persistent_stats(params_obj)
|
|
shell_drives = [
|
|
_route_shell_drive(route_info, params_obj, model_names, is_metric)
|
|
for route_info in route_infos
|
|
]
|
|
if shell_drives:
|
|
persistent_stats = _update_dashboard_persistent_stats(params_obj, shell_drives, time.time())
|
|
|
|
analyzed_drives = []
|
|
if DASHBOARD_ROUTE_ANALYSIS_LIMIT > 0 and DASHBOARD_ANALYSIS_TIME_BUDGET_SECONDS > 0:
|
|
for route_info in _analysis_candidates(route_infos, persistent_stats)[:DASHBOARD_ROUTE_ANALYSIS_LIMIT]:
|
|
if _deadline_reached(analysis_deadline):
|
|
break
|
|
sampled_route_info = _sample_route_info(route_info)
|
|
messages = _iter_route_log_messages(sampled_route_info, analysis_deadline)
|
|
analyzed_drives.append(_analyze_route_messages(messages, sampled_route_info, model_names, is_metric, analysis_deadline))
|
|
if analyzed_drives:
|
|
persistent_stats = _update_dashboard_persistent_stats(params_obj, analyzed_drives, time.time())
|
|
|
|
persisted_drives = _persistent_drives(persistent_stats, is_metric)
|
|
combined_drives = _merge_dashboard_drives(shell_drives, persisted_drives, analyzed_drives)
|
|
_mark_ignored_drives(combined_drives, persistent_stats)
|
|
display_drives = _coalesce_display_drives(combined_drives, is_metric)
|
|
included_display_drives = [drive for drive in display_drives if not bool(drive.get("ignored", False))]
|
|
pending_candidates = _analysis_candidates(route_infos, persistent_stats)
|
|
pending_route_names = {str(route.get("name", "")).strip() for route in pending_candidates}
|
|
included_drives = [drive for drive in combined_drives if not bool(drive.get("ignored", False))]
|
|
week_drives = _week_summary_drives(included_drives, pending_route_names)
|
|
_start_dashboard_background_analysis(footage_paths, route_infos, persistent_stats, pending_candidates)
|
|
analysis_status = _dashboard_analysis_status(pending_candidates)
|
|
|
|
if not display_drives:
|
|
dashboard = _dashboard_empty(is_metric, now, footage_paths, params_obj, persistent_stats)
|
|
else:
|
|
records = _display_personal_records(persistent_stats, is_metric)
|
|
dashboard = {
|
|
"lastDrive": _public_drive(included_display_drives[0], is_metric) if included_display_drives else _empty_drive(is_metric),
|
|
"recentDrives": [_public_drive(drive, is_metric) for drive in display_drives[:DASHBOARD_RECENT_DRIVE_LIMIT]],
|
|
"week": _build_week_summary(week_drives, now, is_metric),
|
|
"records": records,
|
|
"device": _build_device_summary(params_obj),
|
|
"storage": _build_storage_summary(footage_paths),
|
|
"favoriteModels": _build_favorite_models(params_obj, persistent_stats),
|
|
}
|
|
dashboard["analysis"] = analysis_status
|
|
|
|
_DASHBOARD_CACHE.update({
|
|
"key": cache_key,
|
|
"updated_at": cache_now,
|
|
"value": copy.deepcopy(dashboard),
|
|
})
|
|
return dashboard
|
|
|
|
|
|
def get_repo_owner(git_normalized_origin):
|
|
parts = git_normalized_origin.split("/")
|
|
return parts[1] if len(parts) >= 2 else "unknown"
|
|
|
|
|
|
def normalize_github_remote(remote):
|
|
remote = str(remote or "").strip()
|
|
if remote.startswith("git@github.com:"):
|
|
remote = "https://github.com/" + remote.split(":", 1)[1]
|
|
elif remote.startswith("ssh://git@github.com/"):
|
|
remote = "https://github.com/" + remote.split("ssh://git@github.com/", 1)[1]
|
|
elif remote.startswith("github.com/"):
|
|
remote = "https://" + remote
|
|
elif remote.startswith("http://github.com/"):
|
|
remote = "https://github.com/" + remote.split("http://github.com/", 1)[1]
|
|
elif re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", remote):
|
|
remote = f"https://github.com/{remote}"
|
|
|
|
if not remote.startswith("https://github.com/"):
|
|
return ""
|
|
remote = remote.rstrip("/")
|
|
if remote.endswith(".git"):
|
|
remote = remote[:-4]
|
|
return remote
|
|
|
|
|
|
def get_github_changelog_url(git_normalized_origin, branch):
|
|
remote = normalize_github_remote(git_normalized_origin)
|
|
if not remote or not branch:
|
|
return ""
|
|
return f"{remote}/commits/{quote(str(branch), safe='')}/"
|
|
|
|
|
|
def get_github_commit_url(git_normalized_origin, commit):
|
|
remote = normalize_github_remote(git_normalized_origin)
|
|
if not remote or not commit:
|
|
return ""
|
|
return f"{remote}/commit/{quote(str(commit), safe='')}"
|
|
|
|
def get_route_log_path(path):
|
|
target = Path(path)
|
|
if target.is_dir():
|
|
for candidate in ROUTE_TIME_LOG_CANDIDATES:
|
|
candidate_path = target / candidate
|
|
if candidate_path.exists():
|
|
return candidate_path
|
|
return None
|
|
|
|
if target.exists():
|
|
return target
|
|
|
|
if target.parent.is_dir():
|
|
for candidate in ROUTE_TIME_LOG_CANDIDATES:
|
|
candidate_path = target.parent / candidate
|
|
if candidate_path.exists():
|
|
return candidate_path
|
|
|
|
return None
|
|
|
|
|
|
def get_route_start_time(path):
|
|
log_path = get_route_log_path(path)
|
|
if log_path is None:
|
|
target = Path(path)
|
|
if not target.exists():
|
|
return None
|
|
log_path = target
|
|
|
|
try:
|
|
modified_time = log_path.stat().st_mtime
|
|
except OSError:
|
|
return None
|
|
|
|
if modified_time <= 0:
|
|
return None
|
|
|
|
return datetime.fromtimestamp(modified_time)
|
|
|
|
def get_routes_names(footage_path):
|
|
segments = get_all_segment_names(footage_path)
|
|
route_times = {segment.route_name.time_str for segment in segments}
|
|
return sorted(route_times, reverse=True)
|
|
|
|
def get_segments_in_route(route_time_str, footage_path):
|
|
return [
|
|
f"{segment.time_str}--{segment.segment_num}"
|
|
for segment in get_all_segment_names(footage_path)
|
|
if segment.time_str == route_time_str
|
|
]
|
|
|
|
def get_video_duration(input_path):
|
|
try:
|
|
result = subprocess.run([
|
|
"ffprobe", "-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)
|
|
except (ValueError, subprocess.CalledProcessError):
|
|
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
|
|
|
|
def list_file(path):
|
|
return sorted(os.listdir(path), reverse=True)
|
|
|
|
def normalize_theme_name(name, for_path=False):
|
|
name = name.replace("-user_created", "")
|
|
if for_path:
|
|
return name.lower().replace(" (", "-").replace(")", "").replace(" ", "-").replace("'", "").replace(".", "")
|
|
|
|
parts = re.split(r'[-_]', name)
|
|
normalized_parts = [part.capitalize() for part in parts]
|
|
|
|
if '-' in name and len(normalized_parts) > 1:
|
|
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_path = f"{footage_path}{route_name}--0"
|
|
qcamera_path = f"{segment_path}/qcamera.ts"
|
|
|
|
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)
|
|
|
|
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:
|
|
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
|
|
|
|
return {
|
|
"name": route_name,
|
|
"png": f"/thumbnails/{route_name}--0/preview.png",
|
|
"timestamp": route_timestamp_str,
|
|
"is_preserved": has_preserve_attr(segment_path)
|
|
}
|
|
|
|
def process_screen_recording(mp4):
|
|
stem = mp4.with_suffix("")
|
|
png_path = stem.with_suffix(".png")
|
|
|
|
if not png_path.exists():
|
|
video_to_png(mp4, png_path)
|
|
|
|
is_custom_name = False
|
|
try:
|
|
datetime.strptime(stem.name, "%B_%d_%Y-%I-%M%p")
|
|
except ValueError:
|
|
is_custom_name = True
|
|
|
|
return {
|
|
"filename": mp4.name,
|
|
"png": f"/screen_recordings/{png_path.name}",
|
|
"timestamp": datetime.fromtimestamp(mp4.stat().st_mtime).isoformat(),
|
|
"is_custom_name": is_custom_name
|
|
}
|
|
|
|
def segment_to_segment_name(data_dir, segment):
|
|
full_path = os.path.join(data_dir, f"FakeDongleID1337|{segment}")
|
|
return SegmentName(full_path)
|
|
|
|
def video_to_png(input_path, output_path):
|
|
try:
|
|
subprocess.run([
|
|
"ffmpeg", "-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:
|
|
print(f"Failed to generate PNG for {input_path}")
|
|
if e.stderr:
|
|
print(e.stderr)
|
|
|
|
def xor_encrypt_decrypt(data, key):
|
|
return "".join(chr(ord(c) ^ ord(key[i % len(key)])) for i, c in enumerate(data))
|