Sentry and Tunes

This commit is contained in:
firestar5683
2026-08-14 12:05:45 -05:00
parent 8e1caf8fe4
commit cdfd5d1d66
19 changed files with 621 additions and 9 deletions
Binary file not shown.
+6
View File
@@ -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 }}"
+114
View File
@@ -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"
+11 -7
View File
@@ -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)
+12 -2
View File
@@ -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),
+1
View File
@@ -0,0 +1 @@
"""Offroad sentry-mode daemon."""
+83
View File
@@ -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
+186
View File
@@ -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()
View File
+46
View File
@@ -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