mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-20 15:54:13 +08:00
Sentry and Tunes
This commit is contained in:
Binary file not shown.
@@ -128,6 +128,12 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"RecordAudioFeedback", {PERSISTENT, BOOL, "0"}},
|
||||
{"RecordFront", {PERSISTENT, BOOL}},
|
||||
{"RecordFrontLock", {PERSISTENT, BOOL}}, // for the internal fleet
|
||||
{"SentryModeEnabled", {PERSISTENT, BOOL, "0", "0", 0, SETTINGS_SIMPLE}},
|
||||
{"SentryModeCapture", {CLEAR_ON_MANAGER_START, BOOL}},
|
||||
{"SentryModeLastEvent", {PERSISTENT, JSON, "{}", "{}"}},
|
||||
{"SentryModeNtfyUrl", {PERSISTENT, STRING}},
|
||||
{"SentryModeStatus", {CLEAR_ON_MANAGER_START | DONT_LOG, JSON}},
|
||||
{"SentryModeWebhook", {PERSISTENT, STRING}},
|
||||
{"SecOCKey", {PERSISTENT | DONT_LOG, STRING}},
|
||||
{"ShowDebugInfo", {PERSISTENT, BOOL}},
|
||||
{"ShowAllToggles", {PERSISTENT, BOOL, "0", "0", 3}},
|
||||
|
||||
Binary file not shown.
@@ -404,6 +404,8 @@ class LatControlTorque(LatControl):
|
||||
get_ioniq_6_directional_taper_scale(setpoint, desired_lateral_jerk, CS.vEgo))
|
||||
ff *= get_ioniq_6_ff_scale(setpoint, desired_lateral_jerk, CS.vEgo,
|
||||
directional_taper_scale=ioniq_6_directional_taper) * ioniq_6_center_taper
|
||||
if not self.is_ioniq_6_2025:
|
||||
ff *= get_ioniq_6_2023_unwind_ff_scale(setpoint, measurement, desired_lateral_jerk, CS.vEgo)
|
||||
friction_threshold = get_ioniq_6_friction_threshold(CS.vEgo, setpoint, desired_lateral_jerk) / max(ioniq_6_center_taper, 1e-3)
|
||||
friction_scale = get_ioniq_6_friction_scale(CS.vEgo, setpoint, desired_lateral_jerk)
|
||||
friction_scale = 1.0 + ((friction_scale - 1.0) * ioniq_6_center_taper)
|
||||
|
||||
@@ -836,6 +836,15 @@ IONIQ_6_CURVY_TURN_IN_TRIM_LAT_START = 1.0
|
||||
IONIQ_6_CURVY_TURN_IN_TRIM_LAT_END = 2.5
|
||||
IONIQ_6_CURVY_TURN_IN_TRIM_LAT_ONSET_WIDTH = 0.18
|
||||
IONIQ_6_CURVY_TURN_IN_TRIM_LAT_CUTOFF_WIDTH = 0.30
|
||||
IONIQ_6_2023_UNWIND_FF_REDUCTION_MAX = 0.18
|
||||
IONIQ_6_2023_UNWIND_FF_OVERSHOOT = 0.15
|
||||
IONIQ_6_2023_UNWIND_FF_OVERSHOOT_WIDTH = 0.18
|
||||
IONIQ_6_2023_UNWIND_FF_JERK = 0.10
|
||||
IONIQ_6_2023_UNWIND_FF_JERK_WIDTH = 0.10
|
||||
IONIQ_6_2023_UNWIND_FF_SPEED_ONSET = 8.0
|
||||
IONIQ_6_2023_UNWIND_FF_SPEED_ONSET_WIDTH = 2.5
|
||||
IONIQ_6_2023_UNWIND_FF_SPEED_CUTOFF = 23.5
|
||||
IONIQ_6_2023_UNWIND_FF_SPEED_CUTOFF_WIDTH = 2.0
|
||||
IONIQ_6_LOW_SPEED_PID_RESET_SPEED = 0.1 * CV.MPH_TO_MS
|
||||
# Friction compensation near zero lateral accel amplifies planner jerk noise into a slow
|
||||
# (~0.5 Hz) weave on straights: the 0.09/0.39 small-signal slope plus the jerk feed acts as
|
||||
@@ -3070,6 +3079,29 @@ def get_ioniq_6_ff_scale(desired_lateral_accel: float, desired_lateral_jerk: flo
|
||||
(extra_scale * turn_in_boost * max(unwind_taper, 0.0))) * directional_taper_scale
|
||||
|
||||
|
||||
def get_ioniq_6_2023_unwind_ff_scale(setpoint: float, measured_lateral_accel: float,
|
||||
desired_lateral_jerk: float, v_ego: float) -> float:
|
||||
"""Trim residual curve feedforward when the 2023 car is already over-rotated."""
|
||||
if setpoint * desired_lateral_jerk >= 0.0 or setpoint * measured_lateral_accel <= 0.0:
|
||||
return 1.0
|
||||
|
||||
overshoot = max(abs(measured_lateral_accel) - abs(setpoint), 0.0)
|
||||
if overshoot <= 0.0:
|
||||
return 1.0
|
||||
|
||||
overshoot_weight = _ioniq_6_sigmoid((overshoot - IONIQ_6_2023_UNWIND_FF_OVERSHOOT) /
|
||||
IONIQ_6_2023_UNWIND_FF_OVERSHOOT_WIDTH)
|
||||
jerk_weight = _ioniq_6_sigmoid((abs(desired_lateral_jerk) - IONIQ_6_2023_UNWIND_FF_JERK) /
|
||||
IONIQ_6_2023_UNWIND_FF_JERK_WIDTH)
|
||||
speed_onset = _ioniq_6_sigmoid((v_ego - IONIQ_6_2023_UNWIND_FF_SPEED_ONSET) /
|
||||
IONIQ_6_2023_UNWIND_FF_SPEED_ONSET_WIDTH)
|
||||
speed_cutoff = _ioniq_6_sigmoid((IONIQ_6_2023_UNWIND_FF_SPEED_CUTOFF - v_ego) /
|
||||
IONIQ_6_2023_UNWIND_FF_SPEED_CUTOFF_WIDTH)
|
||||
reduction = (IONIQ_6_2023_UNWIND_FF_REDUCTION_MAX * overshoot_weight * jerk_weight *
|
||||
speed_onset * speed_cutoff)
|
||||
return 1.0 - reduction
|
||||
|
||||
|
||||
def get_ioniq_6_friction_threshold(v_ego: float, desired_lateral_accel: float = 0.0, desired_lateral_jerk: float = 0.0) -> float:
|
||||
base_threshold = max(get_hkg_canfd_base_friction_threshold(v_ego), IONIQ_6_BASE_FRICTION_THRESHOLD)
|
||||
transition_envelope = _ioniq_6_transition_envelope(v_ego, desired_lateral_accel, desired_lateral_jerk)
|
||||
|
||||
@@ -116,6 +116,7 @@ from openpilot.selfdrive.controls.lib.latcontrol_torque import (
|
||||
get_ioniq_6_directional_taper_scale,
|
||||
get_ioniq_6_output_taper_scale,
|
||||
get_ioniq_6_ff_scale,
|
||||
get_ioniq_6_2023_unwind_ff_scale,
|
||||
get_ioniq_6_friction_center_fade_scale,
|
||||
get_ioniq_6_friction_scale,
|
||||
get_ioniq_6_friction_threshold,
|
||||
@@ -1149,6 +1150,12 @@ class TestLatControl:
|
||||
assert get_ioniq_6_ff_scale(0.30, 0.60, 6.0) > get_ioniq_6_ff_scale(0.30, 0.60, 12.0)
|
||||
assert get_ioniq_6_ff_scale(0.30, -0.60, 3.0) < get_ioniq_6_ff_scale(0.30, 0.60, 3.0)
|
||||
|
||||
def test_ioniq_6_2023_unwind_ff_scale_only_trims_measured_overshoot(self):
|
||||
assert get_ioniq_6_2023_unwind_ff_scale(-1.2, -2.0, 1.0, 18.0) < 1.0
|
||||
assert get_ioniq_6_2023_unwind_ff_scale(-1.2, -1.0, 1.0, 18.0) == 1.0
|
||||
assert get_ioniq_6_2023_unwind_ff_scale(-1.2, -2.0, -1.0, 18.0) == 1.0
|
||||
assert get_ioniq_6_2023_unwind_ff_scale(-1.2, -2.0, 1.0, 30.0) > get_ioniq_6_2023_unwind_ff_scale(-1.2, -2.0, 1.0, 18.0)
|
||||
|
||||
def test_ioniq_6_low_speed_angle_assist_curve(self):
|
||||
base = 0.05
|
||||
boosted = get_ioniq_6_low_speed_angle_assist_torque(22.0, 0.0, base, 1.0)
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ToggleControl } from "/assets/components/tools/toggles.js"
|
||||
import { VASMAnnotations } from "/assets/components/tools/v_asm.js"
|
||||
import { PipSideCamera } from "/assets/components/tools/pip_sidecam.js"
|
||||
import { UpdateManager } from "/assets/components/tools/update_manager.js"
|
||||
import { startSentryNotifications } from "/assets/components/sentry_notifications.js"
|
||||
|
||||
let router, routerState
|
||||
|
||||
@@ -187,3 +188,5 @@ if (document.readyState === "loading") {
|
||||
} else {
|
||||
mountRouterWhenReady()
|
||||
}
|
||||
|
||||
startSentryNotifications()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const STORAGE_KEY = "starpilot.sentry.last-event"
|
||||
const POLL_INTERVAL_MS = 5000
|
||||
|
||||
let started = false
|
||||
let initialized = false
|
||||
|
||||
function lastSeenEventId() {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) || ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function rememberEvent(eventId) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, eventId)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSentryEvent() {
|
||||
try {
|
||||
const response = await fetch("/api/sentry/status", { cache: "no-store" })
|
||||
if (!response.ok) return
|
||||
const payload = await response.json()
|
||||
const event = payload?.lastEvent
|
||||
const eventId = String(event?.eventId || "")
|
||||
if (!eventId) return
|
||||
|
||||
const previous = lastSeenEventId()
|
||||
rememberEvent(eventId)
|
||||
if (!initialized || eventId === previous) return
|
||||
if (typeof Notification === "undefined" || Notification.permission !== "granted") return
|
||||
|
||||
new Notification("StarPilot Sentry Mode", {
|
||||
body: String(event.message || "Movement detected while parked."),
|
||||
tag: `starpilot-sentry-${eventId}`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.debug("Sentry notification poll failed:", error)
|
||||
} finally {
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestSentryNotificationPermission() {
|
||||
if (typeof Notification === "undefined") return "unsupported"
|
||||
return Notification.requestPermission()
|
||||
}
|
||||
|
||||
export function startSentryNotifications() {
|
||||
if (started) return
|
||||
started = true
|
||||
pollSentryEvent()
|
||||
window.setInterval(pollSentryEvent, POLL_INTERVAL_MS)
|
||||
}
|
||||
@@ -272,6 +272,11 @@ function syncInputs() {
|
||||
el.value = resolveColorInputValue(param)
|
||||
}
|
||||
|
||||
for (const el of document.querySelectorAll("input.ds-text-input[id^='ds-']")) {
|
||||
if (document.activeElement === el) continue
|
||||
el.value = toSelectValue(state.values[el.id.slice(3)])
|
||||
}
|
||||
|
||||
// Sync selects — hydrate options + set value
|
||||
for (const el of document.querySelectorAll("select.ds-select[id^='ds-']")) {
|
||||
const key = el.id.slice(3)
|
||||
@@ -1475,6 +1480,7 @@ function renderSettingRow(p) {
|
||||
|
||||
const isNumeric = p.ui_type === "numeric"
|
||||
const isSlider = isNumeric && p.control === "slider"
|
||||
const isText = p.ui_type === "text"
|
||||
const isColor = p.ui_type === "color"
|
||||
const isAction = p.ui_type === "action"
|
||||
const isGroup = isGroupParam(p)
|
||||
@@ -1600,6 +1606,17 @@ function renderSettingRow(p) {
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
`
|
||||
} else if (isText) {
|
||||
rowControl = html`
|
||||
<input
|
||||
type="${p.input_type || "text"}"
|
||||
class="ds-manual-input ds-text-input"
|
||||
id="ds-${p.key}"
|
||||
value="${() => toSelectValue(state.values[p.key])}"
|
||||
placeholder="${p.placeholder || ""}"
|
||||
disabled="${() => isLocked()}"
|
||||
@change="${() => updateParam(p.key, "text")}" />
|
||||
`
|
||||
} else if (p.ui_type === "color") {
|
||||
rowControl = html`
|
||||
<div style="display:flex; align-items:center; gap:0.75rem;">
|
||||
|
||||
@@ -13,6 +13,40 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Sentry Mode",
|
||||
"icon": "bi-shield-exclamation",
|
||||
"params": [
|
||||
{
|
||||
"key": "SentryModeEnabled",
|
||||
"label": "Sentry Mode",
|
||||
"description": "Detect movement while parked and send alerts through Galaxy.",
|
||||
"data_type": "bool",
|
||||
"ui_type": "toggle",
|
||||
"settings_tier": "simple"
|
||||
},
|
||||
{
|
||||
"key": "SentryModeWebhook",
|
||||
"label": "Sentry Webhook",
|
||||
"description": "Optional webhook that Galaxy will POST to when sentry mode detects movement. Discord webhooks are supported.",
|
||||
"data_type": "string",
|
||||
"ui_type": "text",
|
||||
"input_type": "url",
|
||||
"placeholder": "https://…",
|
||||
"settings_tier": "simple"
|
||||
},
|
||||
{
|
||||
"key": "SentryModeNtfyUrl",
|
||||
"label": "Sentry ntfy URL",
|
||||
"description": "Optional ntfy topic URL, for example https://ntfy.sh/my-starpilot-topic.",
|
||||
"data_type": "string",
|
||||
"ui_type": "text",
|
||||
"input_type": "url",
|
||||
"placeholder": "https://ntfy.sh/…",
|
||||
"settings_tier": "simple"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Lateral (Steering)",
|
||||
"icon": "bi-arrows-move",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { isGalaxyTunnel } from "/assets/js/utils.js"
|
||||
import { Modal } from "/assets/components/modal.js"
|
||||
import { requestSentryNotificationPermission } from "/assets/components/sentry_notifications.js"
|
||||
|
||||
const state = reactive({
|
||||
paired: false,
|
||||
@@ -114,6 +115,15 @@ export function GalaxyPairing() {
|
||||
<a class="galaxy-url" href="${state.url}" target="_blank" rel="noopener">
|
||||
${state.url}
|
||||
</a>
|
||||
<button
|
||||
class="galaxy-button"
|
||||
@click="${async () => {
|
||||
const permission = await requestSentryNotificationPermission()
|
||||
showSnackbar(permission === "granted" ? "Browser sentry notifications enabled." : "Browser notifications were not enabled.")
|
||||
}}"
|
||||
>
|
||||
Enable browser alerts
|
||||
</button>
|
||||
<button
|
||||
class="galaxy-button galaxy-button-danger"
|
||||
@click="${() => { state.showUnpairModal = true }}"
|
||||
|
||||
@@ -39,6 +39,7 @@ from opendbc.car.toyota.values import ToyotaStarPilotFlags
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.common.params import ParamKeyFlag, ParamKeyType, Params
|
||||
from openpilot.common.realtime import DT_HW
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.time_helpers import system_time_valid
|
||||
from openpilot.system.hardware import HARDWARE, PC
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
@@ -501,6 +502,85 @@ def _build_default_params():
|
||||
|
||||
starpilot_default_params = _build_default_params()
|
||||
|
||||
|
||||
def _sentry_event_roots() -> tuple[Path, ...]:
|
||||
roots = [Path("/data/media/0/sentryd")]
|
||||
if PC:
|
||||
roots.insert(0, Path(Paths.comma_home()) / "starpilot" / "data" / "sentryd")
|
||||
return tuple(root.resolve() for root in roots)
|
||||
|
||||
|
||||
def _safe_sentry_image_paths(raw_paths) -> list[str]:
|
||||
if not isinstance(raw_paths, list):
|
||||
return []
|
||||
|
||||
roots = _sentry_event_roots()
|
||||
safe_paths = []
|
||||
for raw_path in raw_paths:
|
||||
try:
|
||||
path = Path(str(raw_path)).resolve()
|
||||
if path.is_file() and any(path.is_relative_to(root) for root in roots):
|
||||
safe_paths.append(str(path))
|
||||
except (OSError, TypeError, ValueError):
|
||||
continue
|
||||
return safe_paths
|
||||
|
||||
|
||||
def _normalize_sentry_event(payload) -> dict | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
event_id = str(payload.get("eventId") or "").strip()
|
||||
kind = str(payload.get("kind") or "").strip().lower()
|
||||
if not event_id or kind not in {"warning", "alarm"}:
|
||||
return None
|
||||
|
||||
return {
|
||||
"eventId": event_id[:96],
|
||||
"kind": kind,
|
||||
"detectedAt": str(payload.get("detectedAt") or ""),
|
||||
"message": str(payload.get("message") or "Movement detected while parked.")[:500],
|
||||
"imagePaths": _safe_sentry_image_paths(payload.get("imagePaths")),
|
||||
}
|
||||
|
||||
|
||||
def _dispatch_sentry_event(event: dict) -> None:
|
||||
message = f"🚨 StarPilot Sentry Mode: {event['message']}"
|
||||
webhook = (params.get("SentryModeWebhook", encoding="utf-8") or "").strip()
|
||||
if webhook:
|
||||
files = []
|
||||
handles = []
|
||||
try:
|
||||
for image_path in event.get("imagePaths", []):
|
||||
handle = open(image_path, "rb")
|
||||
handles.append(handle)
|
||||
files.append(("file", (Path(image_path).name, handle, "image/jpeg")))
|
||||
|
||||
body = {"content": message, "event": json.dumps(event, separators=(",", ":"))}
|
||||
response = requests.post(webhook, data=body, files=files or None, timeout=10)
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
cloudlog.exception("Galaxy: sentry webhook notification failed")
|
||||
finally:
|
||||
for handle in handles:
|
||||
try:
|
||||
handle.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
ntfy_url = (params.get("SentryModeNtfyUrl", encoding="utf-8") or "").strip()
|
||||
if ntfy_url:
|
||||
try:
|
||||
response = requests.post(
|
||||
ntfy_url,
|
||||
data=message.encode("utf-8"),
|
||||
headers={"Title": "StarPilot Sentry Mode", "Priority": "urgent", "Tags": "warning,car"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception:
|
||||
cloudlog.exception("Galaxy: ntfy notification failed")
|
||||
|
||||
TOGGLE_BACKUP_FORMAT = "starpilot-toggle-backup"
|
||||
TOGGLE_BACKUP_VERSION = 1
|
||||
TOGGLE_BACKUP_MAX_ENCODED_BYTES = 2_000_000
|
||||
@@ -4059,11 +4139,13 @@ def setup(app):
|
||||
def disable_device_settings_asset_cache(response):
|
||||
if request.path in {
|
||||
"/assets/components/router.js",
|
||||
"/assets/components/sentry_notifications.js",
|
||||
"/assets/components/home/home.js",
|
||||
"/assets/components/home/home.css",
|
||||
"/assets/components/tools/device_settings.js",
|
||||
"/assets/components/tools/device_settings.css",
|
||||
"/assets/components/tools/device_settings_layout.json",
|
||||
"/assets/components/tools/galaxy.js",
|
||||
"/assets/components/tools/v_asm.js",
|
||||
"/assets/components/tools/v_asm.css",
|
||||
"/assets/components/tools/pip_sidecam.js",
|
||||
@@ -6594,6 +6676,38 @@ def setup(app):
|
||||
"warning": "This wipes local params, backups, themes, models, maps, and route data.",
|
||||
}), 202
|
||||
|
||||
@app.route("/api/sentry/status", methods=["GET"])
|
||||
def sentry_status():
|
||||
raw_event = params.get("SentryModeLastEvent", encoding="utf-8") or "{}"
|
||||
raw_status = params.get("SentryModeStatus", encoding="utf-8") or "{}"
|
||||
try:
|
||||
last_event = json.loads(raw_event)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
last_event = {}
|
||||
try:
|
||||
status = json.loads(raw_status)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
status = {}
|
||||
|
||||
return jsonify({
|
||||
"enabled": params.get_bool("SentryModeEnabled"),
|
||||
"status": status if isinstance(status, dict) else {},
|
||||
"lastEvent": last_event if isinstance(last_event, dict) else {},
|
||||
})
|
||||
|
||||
@app.route("/api/sentry/events", methods=["POST"])
|
||||
def sentry_event():
|
||||
if request.remote_addr not in {None, "127.0.0.1", "::1"}:
|
||||
return jsonify({"error": "Sentry events must originate on the device."}), 403
|
||||
|
||||
event = _normalize_sentry_event(request.get_json(silent=True))
|
||||
if event is None:
|
||||
return jsonify({"error": "Invalid sentry event."}), 400
|
||||
|
||||
params.put("SentryModeLastEvent", json.dumps(event, separators=(",", ":")))
|
||||
threading.Thread(target=_dispatch_sentry_event, args=(event,), name="galaxy-sentry-notify", daemon=True).start()
|
||||
return jsonify({"accepted": True, "eventId": event["eventId"]}), 202
|
||||
|
||||
# ── Galaxy pairing (mirrors settings.cc L262-282) ──────────────────
|
||||
GALAXY_DIR = _get_galaxy_dir()
|
||||
GALAXY_AUTH_FILE = GALAXY_DIR / "glxyauth"
|
||||
|
||||
@@ -73,7 +73,7 @@ def get_snapshots(frame="roadCameraState", front_frame="driverCameraState"):
|
||||
return rear, front
|
||||
|
||||
|
||||
def snapshot():
|
||||
def snapshot(allow_existing=False):
|
||||
params = Params()
|
||||
|
||||
if (not params.get_bool("IsOffroad")) or params.get_bool("IsTakingSnapshot"):
|
||||
@@ -86,25 +86,29 @@ def snapshot():
|
||||
time.sleep(2.0) # Give hardwared time to read the param, or if just started give camerad time to start
|
||||
|
||||
# Check if camerad is already started
|
||||
camerad_already_running = False
|
||||
try:
|
||||
subprocess.check_call(["pgrep", "camerad"])
|
||||
print("Camerad already running")
|
||||
params.put_bool("IsTakingSnapshot", False)
|
||||
params.remove("Offroad_IsTakingSnapshot")
|
||||
return None, None
|
||||
camerad_already_running = True
|
||||
if not allow_existing:
|
||||
print("Camerad already running")
|
||||
params.put_bool("IsTakingSnapshot", False)
|
||||
params.remove("Offroad_IsTakingSnapshot")
|
||||
return None, None
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Allow testing on replay on PC
|
||||
if not PC:
|
||||
if not PC and not camerad_already_running:
|
||||
managed_processes['camerad'].start()
|
||||
|
||||
frame = "wideRoadCameraState"
|
||||
front_frame = "driverCameraState" if front_camera_allowed else None
|
||||
rear, front = get_snapshots(frame, front_frame)
|
||||
finally:
|
||||
managed_processes['camerad'].stop()
|
||||
if not camerad_already_running:
|
||||
managed_processes['camerad'].stop()
|
||||
params.put_bool("IsTakingSnapshot", False)
|
||||
set_offroad_alert("Offroad_IsTakingSnapshot", False)
|
||||
|
||||
|
||||
@@ -62,6 +62,15 @@ def only_onroad(started: bool, params: Params, CP: car.CarParams, starpilot_togg
|
||||
def only_offroad(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
|
||||
return not started
|
||||
|
||||
def sentry_mode(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
|
||||
return not started and params.get_bool("SentryModeEnabled")
|
||||
|
||||
def sensord_run(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
|
||||
return started or params.get_bool("SentryModeEnabled")
|
||||
|
||||
def camera_run(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
|
||||
return driverview(started, params, CP, starpilot_toggles) or params.get_bool("SentryModeCapture")
|
||||
|
||||
def livestream(started: bool, params: Params, CP: car.CarParams, starpilot_toggles: SimpleNamespace) -> bool:
|
||||
return params.get_bool("IsLiveStreaming")
|
||||
|
||||
@@ -182,7 +191,7 @@ procs = [
|
||||
NativeProcess("stream_encoderd", "system/loggerd", ["./encoderd", "--stream"], or_(and_(livestream, not_(iscar)), notcar)),
|
||||
PythonProcess("logmessaged", "system.logmessaged", always_run),
|
||||
|
||||
NativeProcess("camerad", "system/camerad", ["./camerad"], or_(driverview, livestream), enabled=not WEBCAM),
|
||||
NativeProcess("camerad", "system/camerad", ["./camerad"], or_(camera_run, livestream), enabled=not WEBCAM),
|
||||
PythonProcess("webcamerad", "tools.webcam.camerad", driverview, enabled=WEBCAM),
|
||||
PythonProcess("proclogd", "system.proclogd", and_(allow_logging, only_onroad), enabled=platform.system() != "Darwin"),
|
||||
PythonProcess("journald", "system.journald", and_(allow_logging, only_onroad), platform.system() != "Darwin"),
|
||||
@@ -192,7 +201,8 @@ procs = [
|
||||
PythonProcess("modeld", "selfdrive.modeld.modeld", only_onroad),
|
||||
PythonProcess("dmonitoringmodeld", "selfdrive.modeld.dmonitoringmodeld", driverview, enabled=(WEBCAM or not PC)),
|
||||
|
||||
PythonProcess("sensord", "system.sensord.sensord", only_onroad, enabled=not PC),
|
||||
PythonProcess("sensord", "system.sensord.sensord", sensord_run, enabled=not PC),
|
||||
PythonProcess("sentryd", "system.sentryd.sentryd", sentry_mode, enabled=not PC),
|
||||
PythonProcess("soundd", "selfdrive.ui.soundd", driverview),
|
||||
PythonProcess("locationd", "selfdrive.locationd.locationd", only_onroad),
|
||||
NativeProcess("_pandad", "selfdrive/pandad", ["./pandad"], always_run, enabled=False),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Offroad sentry-mode daemon."""
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
|
||||
class MotionDetector:
|
||||
"""Detect sustained changes in the accelerometer magnitude.
|
||||
|
||||
The detector deliberately returns edge events instead of owning any I/O. That
|
||||
keeps the movement policy testable and lets the daemon decide how to capture
|
||||
frames or notify Galaxy.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sensitivity: float = 0.04,
|
||||
warning_trigger_count: int = 10,
|
||||
alarm_trigger_count: int = 25,
|
||||
alarm_time: float = 30.0,
|
||||
reset_time: float = 60.0,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
):
|
||||
self.sensitivity = sensitivity
|
||||
self.warning_trigger_count = warning_trigger_count
|
||||
self.alarm_trigger_count = alarm_trigger_count
|
||||
self.alarm_time = alarm_time
|
||||
self.reset_time = reset_time
|
||||
self.clock = clock
|
||||
self.previous_acceleration: tuple[float, float, float] | None = None
|
||||
self.trigger_count = 0
|
||||
self.trigger_started_at: float | None = None
|
||||
self.alarm_triggered = False
|
||||
|
||||
@staticmethod
|
||||
def _magnitude(acceleration: Sequence[float]) -> float:
|
||||
if len(acceleration) < 3:
|
||||
raise ValueError("accelerometer samples must contain x, y, and z")
|
||||
return math.sqrt(sum(float(component) ** 2 for component in acceleration[:3]))
|
||||
|
||||
def reset(self) -> None:
|
||||
self.previous_acceleration = None
|
||||
self.trigger_count = 0
|
||||
self.trigger_started_at = None
|
||||
self.alarm_triggered = False
|
||||
|
||||
def update(self, acceleration: Sequence[float], now: float | None = None) -> str | None:
|
||||
now = self.clock() if now is None else now
|
||||
current = tuple(float(component) for component in acceleration[:3])
|
||||
if len(current) < 3:
|
||||
raise ValueError("accelerometer samples must contain x, y, and z")
|
||||
|
||||
if self.previous_acceleration is None:
|
||||
self.previous_acceleration = current
|
||||
return None
|
||||
|
||||
delta = abs(self._magnitude(current) - self._magnitude(self.previous_acceleration))
|
||||
self.previous_acceleration = current
|
||||
|
||||
if delta > self.sensitivity:
|
||||
self.trigger_count += 1
|
||||
if self.trigger_started_at is None:
|
||||
self.trigger_started_at = now
|
||||
|
||||
if self.trigger_count == self.warning_trigger_count:
|
||||
return "warning"
|
||||
|
||||
if (
|
||||
self.trigger_count > self.alarm_trigger_count
|
||||
and now - self.trigger_started_at >= self.alarm_time
|
||||
and not self.alarm_triggered
|
||||
):
|
||||
self.alarm_triggered = True
|
||||
return "alarm"
|
||||
|
||||
if self.trigger_started_at is not None and now - self.trigger_started_at >= self.reset_time:
|
||||
self.trigger_count = 0
|
||||
self.trigger_started_at = None
|
||||
self.alarm_triggered = False
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import cereal.messaging as messaging
|
||||
import requests
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.camerad.snapshot import jpeg_write, snapshot
|
||||
from openpilot.system.hardware import PC
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
|
||||
from openpilot.system.sentryd.detector import MotionDetector
|
||||
|
||||
|
||||
ARM_DELAY_SECONDS = 90.0
|
||||
LOOP_INTERVAL_SECONDS = 0.1
|
||||
SENSITIVITY = 0.04
|
||||
WARNING_TRIGGER_COUNT = 10
|
||||
ALARM_TRIGGER_COUNT = 25
|
||||
ALARM_TIME_SECONDS = 30.0
|
||||
RESET_TIME_SECONDS = 60.0
|
||||
MAX_EVENT_DIRECTORIES = 100
|
||||
|
||||
|
||||
def event_root() -> Path:
|
||||
if PC:
|
||||
return Path(Paths.comma_home()) / "starpilot" / "data" / "sentryd"
|
||||
return Path("/data/media/0/sentryd")
|
||||
|
||||
|
||||
def galaxy_event_url() -> str:
|
||||
default_port = "8083" if PC else "8082"
|
||||
port = os.environ.get("SP_GALAXY_PORT", default_port)
|
||||
return f"http://127.0.0.1:{port}/api/sentry/events"
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class SentryMode:
|
||||
def __init__(self, params: Params | None = None, sm=None, clock=time.monotonic):
|
||||
self.params = params or Params(return_defaults=True)
|
||||
self.sm = sm or messaging.SubMaster(["accelerometer"])
|
||||
self.clock = clock
|
||||
self.detector = MotionDetector(
|
||||
sensitivity=SENSITIVITY,
|
||||
warning_trigger_count=WARNING_TRIGGER_COUNT,
|
||||
alarm_trigger_count=ALARM_TRIGGER_COUNT,
|
||||
alarm_time=ALARM_TIME_SECONDS,
|
||||
reset_time=RESET_TIME_SECONDS,
|
||||
clock=clock,
|
||||
)
|
||||
self.started_at = clock()
|
||||
self.armed = False
|
||||
self._last_status = None
|
||||
|
||||
def _write_status(self, state: str, **extra) -> None:
|
||||
status_values = {"state": state, **extra}
|
||||
if status_values == self._last_status:
|
||||
return
|
||||
status = {**status_values, "updatedAt": _utc_now()}
|
||||
try:
|
||||
self.params.put("SentryModeStatus", json.dumps(status, separators=(",", ":")))
|
||||
except Exception:
|
||||
cloudlog.exception("sentryd: failed to write status")
|
||||
self._last_status = status_values
|
||||
|
||||
def _capture_images(self, event_id: str) -> list[str]:
|
||||
self.params.put_bool("SentryModeCapture", True)
|
||||
try:
|
||||
rear, front = snapshot(allow_existing=True)
|
||||
except Exception:
|
||||
cloudlog.exception("sentryd: snapshot failed")
|
||||
return []
|
||||
finally:
|
||||
self.params.put_bool("SentryModeCapture", False)
|
||||
|
||||
if rear is None and front is None:
|
||||
return []
|
||||
|
||||
directory = event_root() / event_id
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
paths = []
|
||||
if rear is not None:
|
||||
rear_path = directory / "wide.jpg"
|
||||
jpeg_write(str(rear_path), rear)
|
||||
paths.append(str(rear_path))
|
||||
if front is not None:
|
||||
front_path = directory / "driver.jpg"
|
||||
jpeg_write(str(front_path), front)
|
||||
paths.append(str(front_path))
|
||||
return paths
|
||||
|
||||
def _trim_old_events(self) -> None:
|
||||
root = event_root()
|
||||
if not root.exists():
|
||||
return
|
||||
try:
|
||||
directories = sorted((path for path in root.iterdir() if path.is_dir()), key=lambda path: path.stat().st_mtime)
|
||||
for directory in directories[:-MAX_EVENT_DIRECTORIES]:
|
||||
for child in directory.iterdir():
|
||||
child.unlink(missing_ok=True)
|
||||
directory.rmdir()
|
||||
except OSError:
|
||||
cloudlog.exception("sentryd: failed to trim old events")
|
||||
|
||||
def _publish_event(self, event: dict) -> None:
|
||||
self.params.put("SentryModeLastEvent", json.dumps(event, separators=(",", ":")))
|
||||
self._write_status(event["kind"], eventId=event["eventId"])
|
||||
|
||||
def publish():
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = requests.post(galaxy_event_url(), json=event, timeout=3)
|
||||
response.raise_for_status()
|
||||
return
|
||||
except requests.RequestException as error:
|
||||
if attempt == 2:
|
||||
cloudlog.warning(f"sentryd: Galaxy notification unavailable: {error}")
|
||||
else:
|
||||
time.sleep(1.0)
|
||||
|
||||
threading.Thread(target=publish, name="sentryd-galaxy-publish", daemon=True).start()
|
||||
|
||||
def _handle_detection(self, kind: str) -> None:
|
||||
event_id = f"{int(time.time())}-{uuid4().hex[:8]}"
|
||||
event = {
|
||||
"eventId": event_id,
|
||||
"kind": kind,
|
||||
"detectedAt": _utc_now(),
|
||||
"imagePaths": [],
|
||||
"message": "Movement detected while parked." if kind == "warning" else "Sustained movement detected while parked.",
|
||||
}
|
||||
if kind == "alarm":
|
||||
event["imagePaths"] = self._capture_images(event_id)
|
||||
self._trim_old_events()
|
||||
self._publish_event(event)
|
||||
|
||||
def update(self) -> None:
|
||||
now = self.clock()
|
||||
if now - self.started_at < ARM_DELAY_SECONDS:
|
||||
self._write_status("arming", secondsRemaining=max(0, int(ARM_DELAY_SECONDS - (now - self.started_at))))
|
||||
return
|
||||
|
||||
if not self.armed:
|
||||
self.armed = True
|
||||
self._write_status("armed")
|
||||
|
||||
message = self.sm["accelerometer"]
|
||||
if message is None or message.acceleration is None:
|
||||
self._write_status("sensor_unavailable")
|
||||
return
|
||||
|
||||
try:
|
||||
detection = self.detector.update(message.acceleration.v, now=now)
|
||||
except (TypeError, ValueError):
|
||||
self._write_status("sensor_unavailable")
|
||||
return
|
||||
|
||||
if detection is not None:
|
||||
self._handle_detection(detection)
|
||||
|
||||
def run(self) -> None:
|
||||
self._write_status("starting")
|
||||
while self.params.get_bool("SentryModeEnabled"):
|
||||
self.sm.update(0)
|
||||
self.update()
|
||||
time.sleep(LOOP_INTERVAL_SECONDS)
|
||||
self._write_status("disabled")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
SentryMode().run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
from openpilot.system.sentryd.detector import MotionDetector
|
||||
|
||||
|
||||
def test_motion_detector_ignores_small_changes():
|
||||
detector = MotionDetector(sensitivity=0.1)
|
||||
assert detector.update((0.0, 0.0, 9.8), now=0.0) is None
|
||||
assert detector.update((0.0, 0.0, 9.85), now=0.1) is None
|
||||
assert detector.trigger_count == 0
|
||||
|
||||
|
||||
def test_motion_detector_warns_once_after_sustained_motion():
|
||||
detector = MotionDetector(sensitivity=0.1, warning_trigger_count=3)
|
||||
detector.update((0.0, 0.0, 9.8), now=0.0)
|
||||
|
||||
assert detector.update((0.0, 0.0, 10.0), now=0.1) is None
|
||||
assert detector.update((0.0, 0.0, 9.8), now=0.2) is None
|
||||
assert detector.update((0.0, 0.0, 10.0), now=0.3) == "warning"
|
||||
assert detector.update((0.0, 0.0, 9.8), now=0.4) is None
|
||||
|
||||
|
||||
def test_motion_detector_alarms_after_time_threshold():
|
||||
detector = MotionDetector(
|
||||
sensitivity=0.1,
|
||||
warning_trigger_count=2,
|
||||
alarm_trigger_count=3,
|
||||
alarm_time=1.0,
|
||||
)
|
||||
detector.update((0.0, 0.0, 9.8), now=0.0)
|
||||
detector.update((0.0, 0.0, 10.0), now=0.1)
|
||||
assert detector.update((0.0, 0.0, 9.8), now=0.2) == "warning"
|
||||
|
||||
assert detector.update((0.0, 0.0, 10.0), now=0.5) is None
|
||||
assert detector.update((0.0, 0.0, 9.8), now=1.0) is None
|
||||
assert detector.update((0.0, 0.0, 10.0), now=1.1) == "alarm"
|
||||
assert detector.update((0.0, 0.0, 9.8), now=1.2) is None
|
||||
|
||||
|
||||
def test_motion_detector_resets_after_quiet_period():
|
||||
detector = MotionDetector(sensitivity=0.1, warning_trigger_count=2, reset_time=1.0)
|
||||
detector.update((0.0, 0.0, 9.8), now=0.0)
|
||||
detector.update((0.0, 0.0, 10.0), now=0.1)
|
||||
detector.update((0.0, 0.0, 9.8), now=0.2)
|
||||
detector.update((0.0, 0.0, 9.8), now=1.3)
|
||||
|
||||
assert detector.trigger_count == 0
|
||||
assert detector.trigger_started_at is None
|
||||
Reference in New Issue
Block a user