mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
a82935f326
Integrate onroad views, configurable widgets, local settings, steering-profile contracts, Bluetooth and Android Auto with their device and runtime services. Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com> Co-authored-by: Daniel Koepping <elkoled@gmail.com> Co-authored-by: Danny <dannyirwin31@yahoo.com> Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com> Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com> Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com> Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu> Co-authored-by: Shane Smiskol <shane@smiskol.com> Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com> Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Co-authored-by: stef <19478336+stefpi@users.noreply.github.com> Co-authored-by: whoisdomi <dcantu83@gmail.com> Co-authored-by: Intelli <contact@intelli.software> Co-authored-by: pharmacomaniac <blittle65@gmail.com> Co-authored-by: prabhaavp <prabhaav@msu.edu>
271 lines
13 KiB
Python
271 lines
13 KiB
Python
"""Parked, exact-source editor for the optional onroad side-camera preview."""
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, replace
|
|
import fcntl
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
|
|
from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsRequest, FeatureSettingsState
|
|
from openpilot.starpilot.ui.pip_preferences import (
|
|
BLINKER, BOOL_KEYS, BSM, CAMERA_FORMATS, ENABLED, INVERT, KEYS, MASK, MAX_MASK_BYTES,
|
|
encode_mask, read_pip, starting_mask,
|
|
)
|
|
from openpilot.starpilot.ui.pip_sidecam import Mask
|
|
|
|
|
|
RESET = "pip:reset"
|
|
EDITOR = "pip:mask:editor"
|
|
FORMAT_PREFIX = "pip:format:"
|
|
MASK_PREFIX = "pip:mask:"
|
|
LABELS = {ENABLED: "Use blind spot camera", BLINKER: "Show on turn signal",
|
|
BSM: "Show on blind spot warning", INVERT: "Mirror camera crop"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WriteResult:
|
|
committed: bool
|
|
verified: bool
|
|
|
|
|
|
def _source_tuple(saved) -> tuple[tuple[str, bytes | None], ...]:
|
|
return tuple((key, saved.source(key)[0]) for key in KEYS)
|
|
|
|
|
|
def _integer_mask(mask: Mask) -> bool:
|
|
return (mask.crop_size.is_integer() and
|
|
all(center is None or all(axis.is_integer() for axis in center)
|
|
for center in (mask.center_left, mask.center_right)))
|
|
|
|
|
|
def _visual_editor_supported(mask: Mask) -> bool:
|
|
return (mask.width, mask.height) in CAMERA_FORMATS and mask.crop_size >= 20 and _integer_mask(mask)
|
|
|
|
|
|
def validated_editor_draft(raw: str, current: Mask | None) -> Mask | None:
|
|
"""Accept one native-pixel crop edit; format replacement has its own confirmation."""
|
|
if type(raw) is not str or current is None or not _visual_editor_supported(current):
|
|
return None
|
|
try:
|
|
if len(raw.encode("utf-8")) > MAX_MASK_BYTES:
|
|
return None
|
|
def unique_pairs(pairs):
|
|
result = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
raise ValueError("duplicate crop field")
|
|
result[key] = value
|
|
return result
|
|
document = json.loads(raw, object_pairs_hook=unique_pairs)
|
|
if not isinstance(document, dict) or set(document) != {"width", "height", "crop_size", "center_left", "center_right"}:
|
|
return None
|
|
if (type(document["width"]) is not int or type(document["height"]) is not int or
|
|
(document["width"], document["height"]) != (current.width, current.height) or
|
|
type(document["crop_size"]) is not int or document["crop_size"] < 20):
|
|
return None
|
|
centers = (document["center_left"], document["center_right"])
|
|
if all(center is None for center in centers):
|
|
return None
|
|
if any(center is not None and (not isinstance(center, list) or len(center) != 2 or
|
|
any(type(axis) is not int for axis in center)) for center in centers):
|
|
return None
|
|
return Mask.parse(document)
|
|
except (UnicodeError, ValueError, TypeError, OverflowError, RecursionError):
|
|
return None
|
|
|
|
|
|
def _commit(params, key: str, raw: bytes, expected: tuple[tuple[str, bytes | None], ...],
|
|
parked: Callable[[], bool]) -> WriteResult:
|
|
if key not in KEYS or len(raw) > (MAX_MASK_BYTES if key == MASK else 1) or not parked():
|
|
return WriteResult(False, False)
|
|
first = read_pip(params)
|
|
if _source_tuple(first) != expected or not all(first.source(name)[1] for name in KEYS):
|
|
return WriteResult(False, False)
|
|
temporary: str | None = None
|
|
lock_fd: int | None = None
|
|
committed = False
|
|
try:
|
|
destination = Path(params.get_param_path(key))
|
|
root = destination.parent.parent
|
|
with tempfile.NamedTemporaryFile(prefix=".tmp_pip_", dir=root, delete=False) as stage:
|
|
temporary = stage.name
|
|
stage.write(raw)
|
|
stage.flush()
|
|
os.fsync(stage.fileno())
|
|
lock_fd = os.open(root / ".lock", os.O_CREAT | os.O_RDONLY, 0o775)
|
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
if not parked():
|
|
return WriteResult(False, False)
|
|
final = read_pip(params)
|
|
if _source_tuple(final) != expected or not all(final.source(name)[1] for name in KEYS) or not parked():
|
|
return WriteResult(False, False)
|
|
os.replace(temporary, destination)
|
|
temporary = None
|
|
committed = True
|
|
directory_fd = os.open(destination.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
os.close(directory_fd)
|
|
observed = read_pip(params)
|
|
return WriteResult(True, observed.source(key) == (raw, True))
|
|
except (OSError, ValueError):
|
|
return WriteResult(committed, False)
|
|
finally:
|
|
if lock_fd is not None:
|
|
os.close(lock_fd)
|
|
if temporary is not None:
|
|
try:
|
|
os.unlink(temporary)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class PiPOwner:
|
|
def __init__(self, params, parked: Callable[[], bool], camera_available: Callable[[], bool]):
|
|
self.params = params
|
|
self.parked = parked
|
|
self.camera_available = camera_available
|
|
self.last_write = WriteResult(False, False)
|
|
|
|
def snapshot(self, *, include_editor: bool = False) -> FeatureSettingsState:
|
|
saved = read_pip(self.params)
|
|
parked = self.parked()
|
|
dependencies = _source_tuple(saved)
|
|
rows: list[FeatureRow] = []
|
|
for key, value in ((ENABLED, saved.enabled), (BLINKER, saved.on_blinker),
|
|
(BSM, saved.on_bsm), (INVERT, saved.invert)):
|
|
raw, readable = saved.source(key)
|
|
available = parked and readable and (key != ENABLED or saved.mask is not None or value is not False)
|
|
rows.append(FeatureRow(key, LABELS[key], "Invalid saved choice" if value is None else "On" if value else "Off",
|
|
raw, ("Off", "On") if value is not None else (), available=available,
|
|
reason="Saved source unreadable" if not readable else
|
|
"Reset the invalid crop below before enabling" if key == ENABLED and saved.mask is None else
|
|
"Explicitly set Off to repair" if value is None else
|
|
"",
|
|
repair_value="Off" if readable and value is None else "", dependencies=dependencies))
|
|
mask_raw, readable = saved.source(MASK)
|
|
if saved.mask is None:
|
|
rows.append(FeatureRow(RESET, "Restore default camera crop", "Invalid saved crop", mask_raw,
|
|
available=parked and readable, reason="Requires confirmation; old bytes stay until you reset",
|
|
dependencies=dependencies))
|
|
else:
|
|
mask = saved.mask
|
|
centers = (("right", mask.center_left), ("left", mask.center_right))
|
|
for side, center in centers:
|
|
if center is None:
|
|
rows.append(FeatureRow("", f"Vehicle {side} crop", "Not configured",
|
|
reason="Restore default crop to add this view"))
|
|
continue
|
|
for axis, index, bound in (("x", 0, mask.width), ("y", 1, mask.height)):
|
|
rows.append(FeatureRow(f"{MASK_PREFIX}{side}:{axis}", f"Vehicle {side} crop {axis.upper()}", str(center[index]),
|
|
mask_raw, step=10, minimum=math.ceil(mask.crop_size / 2), maximum=math.floor(bound - mask.crop_size / 2),
|
|
unit="px", available=parked and readable,
|
|
reason="Image crop; view remains off without a live camera", dependencies=dependencies))
|
|
max_size = min(mask.width, mask.height, *(2 * min(x, mask.width - x, y, mask.height - y)
|
|
for _, center in centers if center is not None for x, y in (center,)))
|
|
rows.append(FeatureRow(f"{MASK_PREFIX}size", "Camera crop size", str(mask.crop_size), mask_raw,
|
|
step=20, minimum=20, maximum=max_size, unit="px", available=parked and readable,
|
|
dependencies=dependencies))
|
|
if include_editor:
|
|
editor_ready = saved.invert is not None and _visual_editor_supported(mask)
|
|
rows.append(FeatureRow(EDITOR, "Visual camera crop editor", "Edit in Galaxy", mask_raw,
|
|
available=parked and readable and editor_ready,
|
|
reason="" if editor_ready else
|
|
"Repair mirror choice, use numeric controls for fractional/small crops, or reset the crop",
|
|
dependencies=dependencies))
|
|
rows.append(FeatureRow(RESET, "Restore default camera crop", "Reset to Default", mask_raw,
|
|
available=parked and readable, reason="Replaces only the saved crop",
|
|
dependencies=dependencies))
|
|
from openpilot.starpilot.galaxy.camera_request import frame_available
|
|
live = bool(self.camera_available()) or frame_available()
|
|
rows.append(FeatureRow("", "Camera availability", "Live cabin frames" if live else "No fresh cabin frame",
|
|
reason="" if live else "Open the live crop preview while parked to check the camera."))
|
|
defaults = dict.fromkeys(BOOL_KEYS, 'Off')
|
|
rows = [replace(row, default_value=defaults[row.key]) if row.key in defaults else row for row in rows]
|
|
return FeatureSettingsState(page="pip", title="Blind Spot Camera and Preview",
|
|
subtitle="Choose when to show the camera and adjust its crop from the live preview. " +
|
|
"Resolution follows the camera automatically.",
|
|
rows=tuple(rows), parked=parked)
|
|
|
|
def editor_with_source(self) -> tuple[dict | None, tuple[tuple[str, bytes | None], ...]]:
|
|
saved = read_pip(self.params)
|
|
mask = saved.mask
|
|
_, readable = saved.source(MASK)
|
|
dependencies = _source_tuple(saved)
|
|
if not readable or mask is None or saved.invert is None or not _visual_editor_supported(mask):
|
|
return None, dependencies
|
|
return ({"width": mask.width, "height": mask.height,
|
|
"centerLeft": [int(axis) for axis in mask.center_left] if mask.center_left is not None else None,
|
|
"centerRight": [int(axis) for axis in mask.center_right] if mask.center_right is not None else None,
|
|
"cropSize": int(mask.crop_size), "invert": saved.invert}, dependencies)
|
|
|
|
def apply(self, request: FeatureSettingsRequest) -> bool:
|
|
self.last_write = WriteResult(False, False)
|
|
key = request.key
|
|
if key not in (*BOOL_KEYS, RESET, EDITOR) and not key.startswith((MASK_PREFIX, FORMAT_PREFIX)):
|
|
return False
|
|
saved = read_pip(self.params)
|
|
expected = _source_tuple(saved)
|
|
if (request.dependencies != expected or not all(saved.source(name)[1] for name in KEYS) or
|
|
not self.parked() or (request.expected != saved.source(MASK if key.startswith((MASK_PREFIX, FORMAT_PREFIX)) or key in (RESET, EDITOR) else key)[0])):
|
|
return False
|
|
if key in BOOL_KEYS:
|
|
if request.value not in ("Off", "On") or (key == ENABLED and request.value == "On" and saved.mask is None):
|
|
return False
|
|
target, raw = key, b"1" if request.value == "On" else b"0"
|
|
elif key == RESET:
|
|
if not request.confirmation or request.value != "confirm":
|
|
return False
|
|
target, raw = MASK, encode_mask(starting_mask(1928, 1208))
|
|
elif key.startswith(FORMAT_PREFIX):
|
|
if not request.confirmation or request.value != "confirm":
|
|
return False
|
|
suffix = key.removeprefix(FORMAT_PREFIX)
|
|
if suffix not in {f"{width}x{height}" for width, height in CAMERA_FORMATS}:
|
|
return False
|
|
width, height = (int(part) for part in suffix.split("x"))
|
|
target, raw = MASK, encode_mask(starting_mask(width, height))
|
|
elif key == EDITOR:
|
|
if not request.confirmation or saved.invert is None or saved.mask is None or \
|
|
not _visual_editor_supported(saved.mask):
|
|
return False
|
|
changed = validated_editor_draft(request.value, saved.mask)
|
|
if changed is None:
|
|
return False
|
|
target, raw = MASK, encode_mask(changed)
|
|
else:
|
|
mask = saved.mask
|
|
if mask is None:
|
|
return False
|
|
try:
|
|
value = float(request.value)
|
|
except ValueError:
|
|
return False
|
|
if not math.isfinite(value) or not 0 <= value <= 8192 or value != round(value):
|
|
return False
|
|
document = {"width": mask.width, "height": mask.height, "crop_size": mask.crop_size,
|
|
"center_left": list(mask.center_left) if mask.center_left is not None else None,
|
|
"center_right": list(mask.center_right) if mask.center_right is not None else None}
|
|
tail = key.removeprefix(MASK_PREFIX)
|
|
if tail == "size":
|
|
document["crop_size"] = value
|
|
elif tail in ("left:x", "left:y", "right:x", "right:y"):
|
|
side, axis = tail.split(":")
|
|
image_side = "right" if side == "left" else "left"
|
|
center = document[f"center_{image_side}"]
|
|
if not isinstance(center, list):
|
|
return False
|
|
center[0 if axis == "x" else 1] = value
|
|
else:
|
|
return False
|
|
changed = Mask.parse(document)
|
|
if changed is None:
|
|
return False
|
|
target, raw = MASK, encode_mask(changed)
|
|
self.last_write = _commit(self.params, target, raw, expected, self.parked)
|
|
return self.last_write.verified
|