Merge pull request #283 from dirwin31:AddSentry

Add Sentry for Apps
This commit is contained in:
prabhaavp
2026-11-09 13:26:20 -05:00
committed by GitHub
35 changed files with 1245 additions and 97 deletions
+1
View File
@@ -159,6 +159,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"PIPPreviewShowOnBlinker", {PERSISTENT, BOOL, "0"}},
{"PIPPreviewShowOnBSM", {PERSISTENT, BOOL, "0"}},
{"PIPPreviewInvert", {PERSISTENT, BOOL, "0"}},
{"CabinWhiteBalance", {PERSISTENT, BOOL, "1"}},
{"StarPilotDisplayPreferencesEnabled", {PERSISTENT, BOOL}},
{"ScreenBrightness", {PERSISTENT, INT}},
{"ScreenBrightnessOnroad", {PERSISTENT, INT}},
+4 -2
View File
@@ -5,7 +5,7 @@ import re
from collections.abc import Callable
from typing import Protocol, cast
from openpilot.starpilot.sentry_mode.storage import EventStore, StorageUnavailable
from openpilot.starpilot.sentry_mode.storage import BATTERY_FIELDS, EventStore, StorageUnavailable, valid_tenths
MAX_RESPONSE_BYTES = 128 * 1024
@@ -39,12 +39,14 @@ class SentryEvents:
for value in source["events"]:
if (type(value) is not dict or type(value.get("eventId")) is not str or
ID.fullmatch(value["eventId"]) is None or type(value.get("kind")) is not str or
value["kind"] not in ("warning", "alarm", "selfie") or
value["kind"] not in ("warning", "alarm", "selfie", "battery") or
(value["kind"] == "battery" and not all(valid_tenths(value.get(k)) for k in BATTERY_FIELDS)) or
type(value.get("wallTimeNs")) is not int or not 0 < value["wallTimeNs"] < 2**63):
raise ValueError("Invalid motion event")
events.append({"eventId": value["eventId"], "kind": value["kind"],
"systemTimeMs": value["wallTimeNs"] // 1_000_000,
**({"simulated": True} if value.get("simulated") else {}),
**({k: value[k] for k in BATTERY_FIELDS} if value["kind"] == "battery" else {}),
"images": cast(Callable[[str], list], self.store.images)(value["eventId"]) if hasattr(self.store, "images") else []})
result = {"schemaVersion": 1, "source": "local", "scanIncomplete": source["incomplete"],
"capacity": source["capacity"], "events": events,
+20 -1
View File
@@ -1671,7 +1671,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
'/api/settings/reset-default', '/api/settings/restore', '/api/recordings/action',
'/api/replay/start', '/api/replay/control', '/api/replay/stop',
'/api/replay/export-start',
'/api/galaxy/pair', '/api/galaxy/unpair', '/api/galaxy/device-name', '/api/galaxy/hotspot', '/api/cameras/snapshot',
'/api/galaxy/pair', '/api/galaxy/unpair', '/api/galaxy/device-name', '/api/galaxy/hotspot', '/api/cameras/snapshot', '/api/cameras/white-balance',
'/api/starport/layout', '/api/starport/enable', '/api/starport/control', '/api/starport/pairing',
'/api/starport/logs/send',
'/api/starport/pairing/response', '/api/starport/pairing/cancel', '/api/starport/pairing/select',
@@ -1989,6 +1989,25 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
else:
self.respond(200, body, 'image/jpeg')
return
if path == '/api/cameras/white-balance':
if type(payload) is not dict or not (payload == {} or (set(payload) == {'enabled'} and type(payload['enabled']) is bool)):
self.json(400, {'error': 'Invalid white balance choice'})
return
identity = self.settings_session()
if identity is None:
self.json(401, {'error': 'Sign in to Galaxy'})
return
from openpilot.common.params import Params
params = Params()
with effect_lock:
if 'enabled' in payload:
params.put_bool('CabinWhiteBalance', payload['enabled'])
enabled = params.get_bool('CabinWhiteBalance')
if self.settings_session() == identity:
self.json(200, {'enabled': enabled})
else:
self.json(401, {'error': 'Sign in to Galaxy'})
return
if path == '/api/galaxy/device-name':
if not self.require_session():
return
@@ -13,11 +13,14 @@ assert.equal(validSentryEvents(inventory), true)
assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], images: ['wide', 'cabin'] }] }), true)
assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], images: ['../../secret'] }] }), false)
assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], kind: 'armed' }] }), false)
assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], kind: 'battery', voltage: 120, cutoff: 118 }] }), true)
assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], kind: 'battery' }] }), false)
assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], voltage: 120, cutoff: 118 }] }), false)
assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], path: '/secret' }] }), false)
assert.equal(validSentryEvents({ ...inventory, events: [inventory.events[0], inventory.events[0]] }), false)
assert.equal(validSentryEvents({ ...inventory, source: 'remote' }), false)
assert.match(SentryEventsPage.template, /device clock and may be inaccurate/)
assert.match(SentryEventsPage.template, /<img[^>]*event.images[^>]*api\/sentry\/image\//)
assert.match(SentryEventsPage.template, /<(?:img|BalancedImage)[^>]*event.images[^>]*api\/sentry\/image\//)
assert.doesNotMatch(SentryEventsPage.template, /v-html|deleteEvent|Download/)
assert.match(CamerasPage.template, /MenuTile v-for="tool in tools"/)
assert.match(cameraTools.find(tool => tool.path === "/cameras/events").description, /View motion events/)
@@ -23,6 +23,17 @@ class SentryEventsTest(unittest.TestCase):
self.assertEqual(result["events"][0]["kind"], "warning")
self.assertEqual(result["events"][0]["images"], [])
def test_battery_event_projects_voltage_and_rejects_missing_reading(self):
with tempfile.TemporaryDirectory() as directory:
store = EventStore(Path(directory).resolve() / "events")
store.record("battery", 1, permitted=lambda: True, details={"voltage": 120, "cutoff": 118})
event = SentryEvents(store).snapshot()["events"][0]
self.assertEqual((event["kind"], event["voltage"], event["cutoff"]), ("battery", 120, 118))
with self.assertRaises(ValueError):
store.record("battery", 2, permitted=lambda: True)
with self.assertRaises(ValueError):
store.record("warning", 3, permitted=lambda: True, details={"voltage": 120, "cutoff": 118})
def test_published_images_project_after_restart_and_use_same_event_id(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve() / "events"
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { SettingsFeed, SettingsPage, SETTINGS_SECTIONS } from "../web/js/settings.js"
import { SettingsFeed, SettingsPage, SETTINGS_SECTIONS, sentryMonitorStatus } from "../web/js/settings.js"
const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve() }
const response = (body, status = 200) => ({ ok: status === 200, status, json: async () => body,
@@ -146,7 +146,7 @@ assert.equal(expired.states.at(-1).status, "idle")
assert.match(SettingsPage.template, /v-if="state.pending"/)
assert.match(SettingsPage.template, /:disabled="!!state\.error \|\| !row\.available"/)
assert.match(SettingsPage.template, /Local settings are unavailable in preview/)
assert.match(SettingsPage.template, /state.data\?\.subtitle/)
assert.match(SettingsPage.template, /sentryStatus.detail/)
// Driving Screen Widgets opens the layout editor on its Display tab; target switches go through the router.
assert.match(SettingsPage.template, /:initial-panel="state\.layoutPanel" @target="openLayoutTarget"/)
assert.doesNotMatch(SettingsPage.template, /Use Colors &amp; Layout to arrange widgets and change colors/)
@@ -435,3 +435,18 @@ assert.equal(category.state.developerOpen, true) // Developer is a direct sectio
SettingsPage.methods.selectSection.call(category, sections[0])
assert.equal(category.state.developerOpen, false)
assert.deepEqual(category.loaded, {page: 'hub', options: {keepData: false, quiet: false}})
for (const subtitle of ["Off: Motion monitoring is disabled.", "Not armed: Battery is low or its charge cannot be checked.", "Not armed: The car is on or the device is shutting down."]) {
assert.equal(sentryMonitorStatus({ subtitle }).label, "Disarmed")
assert.equal(sentryMonitorStatus({ subtitle }).detail, subtitle)
}
const armingStatus = sentryMonitorStatus({ subtitle: "Arming · 82s: Monitoring begins after 90 seconds parked." })
assert.equal(armingStatus.tone, "warning")
assert.equal(armingStatus.label, "Arming")
const armedStatus = { subtitle: "Monitoring motion: Watching for movement while parked." }
assert.equal(sentryMonitorStatus(armedStatus).label, "Armed")
assert.equal(sentryMonitorStatus(armedStatus, "Connection lost").label, "Status unavailable")
assert.equal(sentryMonitorStatus({ subtitle: "Waiting for monitor: The monitor has not responded recently." }).label, "Status unavailable")
assert.equal(sentryMonitorStatus({ subtitle: "Motion monitor status is unavailable. Reconnecting automatically…" }).label, "Status unavailable")
assert.equal(sentryMonitorStatus(null).label, "Status unavailable")
assert.equal(sentryMonitorStatus({ subtitle: 42 }).label, "Status unavailable")
@@ -36,6 +36,10 @@
.gx-map-manager { padding: 16px; }
.gx-map-manager__region { align-items: flex-start; flex-direction: column; }
}
@media (max-width: 680px) {
.gx-pip .gx-settings__header, .gx-vasm .gx-settings__header { flex-direction: column; align-items: stretch; }
.gx-pip .gx-settings__header > .gx-actions, .gx-vasm .gx-settings__header > .gx-actions { flex: 1 1 auto; width: 100%; }
}
@media (max-width: 680px) { .gx-settings__header { align-items: flex-start; } .gx-settings__subhead { align-items: flex-start; flex-direction: column; } }
:root {
color-scheme: dark;
@@ -888,6 +892,8 @@ transition: transform var(--motion-fast), background-color var(--motion-fast);
.gx-menu-tile:disabled { cursor: default; opacity: .65; }
.gx-menu-tile:disabled:hover { background: var(--surface-container); transform: none; }
.gx-note--danger { color: var(--error); }
.gx-note--warning { color: var(--warning); }
.gx-sentry-busy { min-height: 1.5em; }
.gx-empty { padding: var(--sp-4); color: var(--text-muted); }
.gx-chip {
display: inline-flex;
@@ -1157,6 +1163,16 @@ mask: radial-gradient(farthest-side, transparent calc(100% - 22px), #000 calc(10
}
.gx-sentry-channel__fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: var(--sp-3); margin: var(--sp-3) 0; }
.gx-sentry-channel__fields .gx-field { width: 100%; min-width: 0; box-sizing: border-box; }
.gx-sentry-events { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: var(--sp-3); }
.gx-sentry-event { display: flex; flex-direction: column; overflow: hidden; }
.gx-sentry-event .gx-row__info { display: grid; gap: var(--sp-1); }
.gx-sentry-event .gx-row__info small { word-break: break-all; }
.gx-sentry-event__media { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; grid-template-rows: minmax(0, 1fr); gap: 2px; aspect-ratio: 4 / 3; overflow: hidden; }
.gx-sentry-event__media .gx-media { width: 100%; height: 100%; object-fit: cover; }
.gx-sentry-events__bar { justify-content: space-between; }
.gx-sentry-events__bar p { margin: 0; }
.gx-sentry-relay { margin-top: var(--sp-3); }
/* Recording library follows the shared section/row control hierarchy. */
.gx-recordings__library, .gx-recordings__list, .gx-recordings__delete { overflow: hidden; }
.gx-recordings__heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--sp-2) var(--sp-4); }
@@ -743,3 +743,9 @@ html.gx-maker-open, html.gx-maker-open body { touch-action: manipulation; }
@media (forced-colors: active) {
.gx-model-card--polaris .gx-model-card__title { background: none; -webkit-text-fill-color: currentColor; color: CanvasText; }
}
.gx-sentry-status { flex: 1 1 100%; order: 1; display: grid; gap: var(--sp-1); color: var(--on-surface-variant); font-size: var(--fs-sm); }
.gx-sentry-status__label { display: inline-flex; align-items: center; gap: var(--sp-2); width: fit-content; font-weight: var(--fw-medium); }
.gx-sentry-status .gx-status-dot { flex: none; background: currentColor; }
.gx-sentry-status--armed .gx-status-dot { background: var(--success); }
.gx-sentry-status--warning .gx-status-dot { background: var(--warning); }
+6 -3
View File
@@ -2,6 +2,7 @@ import { MenuTile } from "./menu-tile.js"
import { GxNotice } from "./notice.js"
import { GalaxySelect } from "./galaxy-select.js"
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
import { balancedBlobUrl, whiteBalancePref, WhiteBalanceSwitch, BalancedImage } from "./white-balance.js"
export class CameraSnapshotFeed {
constructor({ publish, unauthorized, fetcher = (...args) => fetch(...args),
@@ -65,7 +66,7 @@ export class CameraSnapshotFeed {
export const CamerasPage = {
name: "CamerasPage",
components: { GxNotice, BluetoothNotice, GalaxySelect, MenuTile },
components: { GxNotice, BluetoothNotice, GalaxySelect, MenuTile, WhiteBalanceSwitch, BalancedImage },
props: { mode: { type: String, required: true }, go: { type: Function, required: true },
tools: { type: Array, required: true },
unauthorized: { type: Function, required: true } },
@@ -98,7 +99,8 @@ export const CamerasPage = {
<button class="gx-btn" type="button" :disabled="capturing" @click="snapshots.capture(camera)">{{ capturing ? 'Capturing…' : 'Take snapshot' }}</button>
<button v-if="image || capturing" class="gx-btn gx-btn--tonal" type="button" @click="snapshots.stop()">{{ capturing ? 'Cancel' : 'Clear' }}</button>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<img v-if="image" :src="image" :alt="camera + ' camera snapshot'" class="gx-media gx-media--spaced" />
<WhiteBalanceSwitch v-if="camera === 'cabin'" />
<BalancedImage v-if="image" :src="image" :balance="camera === 'cabin'" :alt="camera + ' camera snapshot'" class="gx-media gx-media--spaced" />
</template>
<p v-else>Camera snapshots are available on the connected device.</p>
<small>Snapshots are not saved.</small></section>
@@ -153,7 +155,8 @@ export class LiveCameraPreview {
image.onerror = failed
this.cancelTimer(this.decodeTimer)
this.decodeTimer = this.later(failed, 4000)
image.src = update.image
if (whiteBalancePref.on) balancedBlobUrl(update.image).then(url => { image.src = url }, () => { image.src = update.image })
else image.src = update.image
}
start() {
if (bluetoothConnection()) return
+4 -1
View File
@@ -3,6 +3,7 @@ import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { LiveCameraPreview } from "./cameras.js"
import { whiteBalancePref } from "./white-balance.js"
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
import { SettingsFeed, isPipCropRow, settingsBusy } from "./settings.js"
import { GalaxySettingRow } from "./galaxy-setting-row.js"
@@ -42,9 +43,10 @@ export const PipPage = {
}
}
} })
return { state, feed, FORMATS, SIDES, bluetooth: bluetoothConnection() }
return { state, feed, FORMATS, SIDES, bluetooth: bluetoothConnection(), pref: whiteBalancePref }
},
mounted() {
this.pref.load()
this._dragIndex = -1
this._dragSide = null
this.liveCamera = new LiveCameraPreview({
@@ -57,6 +59,7 @@ export const PipPage = {
},
updated() { this.$nextTick(() => this.redraw()) },
beforeUnmount() { this.liveCamera.stop(); this.feed.stop() },
watch: { "pref.on"() { this.liveCamera?.refresh() } },
computed: {
controls() { return (this.state.data?.rows || []).map((row, index) => ({ row, index }))
.filter(({ row }) => !isPipCropRow(row) && !/camera availability/i.test(row.label)) },
@@ -30,12 +30,12 @@ export class NotificationClient {
this.controller = new AbortController()
this.pending = true
const foreground = payload !== null || !this.hasStatus
if (foreground) this.publish({ notificationBusy: true, notificationError: "" })
if (foreground) this.publish({ notificationBusy: true, notificationError: "", notificationTesting: payload?.action === "test" })
this.timer = setTimeout(() => {
if (revision !== this.revision) return
this.stop()
this.publish({ notificationBusy: false, notificationError: "Notification request timed out. Refresh status before repeating a test." })
}, 4000)
}, payload?.action === "test" ? 25000 : 4000)
try {
const response = await this.fetcher("./api/sentry/notifications", { credentials: "same-origin", cache: "no-store",
signal: this.controller.signal, ...(payload === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }) })
@@ -61,7 +61,8 @@ export const SentryNotifications = {
components: { GxNotice },
name: "SentryNotifications",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testPoll: null, relayUrl: '', channels, labels, notifications: null, notificationBusy: false, notificationError: "",
data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testingId: '', testPoll: null, relayUrl: '', enrollmentCode: '', relayConfirmed: false, channels, labels, notifications: null, notificationBusy: false, notificationTesting: false, notificationError: "",
channelSetup: {},
drafts: Object.fromEntries(channels.map(name => [name, { url: "", token: "" }])) }),
created() { this.client = new NotificationClient({ publish: update => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
@@ -69,13 +70,18 @@ export const SentryNotifications = {
document.addEventListener("visibilitychange", this.visibility)
this.start()
},
beforeUnmount() { clearInterval(this.testPoll); this.client.stop(); clearInterval(this.poll); document.removeEventListener("visibilitychange", this.visibility) },
beforeUnmount() { clearInterval(this.testPoll); clearTimeout(this.testingTimer); this.client.stop(); clearInterval(this.poll); document.removeEventListener("visibilitychange", this.visibility) },
computed: { relay() { return this.notifications?.relayEnrollment?.supported ? this.notifications.relayEnrollment : null } },
methods: {
reportSave,
otherPhones() { return (this.notifications?.appSubscriptions || []).filter(phone => !this.pushState.id || phone.id !== this.pushState.id) },
refreshPush() { try { this.pushState = JSON.parse(window.StarPilotApp?.sentryPush?.() || '{}') } catch { this.pushState = {} } },
togglePhone() { window.StarPilotApp?.setSentryPush?.(!this.pushState.enabled); this.refreshPush(); setTimeout(() => this.client.run(), 1000) },
phoneStatus() { return this.pushState.status || (this.pushState.enabled ? (this.pushState.registered ? 'Alerts on' : 'Registering this phone…') : 'Alerts off') },
async testPhone(id) {
if (!await this.client.run({ action: 'test', target: 'app', id })) return
this.testingId = id; clearTimeout(this.testingTimer)
this.testingTimer = setTimeout(() => { this.testingId = '' }, 20000)
if (!await this.client.run({ action: 'test', target: 'app', id })) { clearTimeout(this.testingTimer); this.testingId = ''; return }
clearInterval(this.testPoll)
let attempts = 0
this.testPoll = setInterval(() => { if (++attempts >= 15) { clearInterval(this.testPoll); this.testPoll = null } if (!this.client.pending) this.client.run() }, 1000)
@@ -83,6 +89,28 @@ export const SentryNotifications = {
removePhone(id) { return this.client.run({ action: 'appUnsubscribe', id }) },
simulate() { return this.client.run({ action: 'simulate' }) },
canSimulate() { return !!window.StarPilotApp || location.protocol === 'http:' },
async enableHostedAlerts() {
if (await this.client.run({ action: 'relayEnroll', confirmed: true })) {
window.StarPilotApp?.setSentryPush?.(true)
this.refreshPush()
setTimeout(() => this.client.run(), 1000)
}
},
async enrollRelay() {
if (await this.client.run({ action: 'relayEnroll', url: this.relayUrl, invitation: this.enrollmentCode.trim(), confirmed: this.relayConfirmed })) {
this.enrollmentCode = ''; this.relayUrl = ''; this.relayConfirmed = false
}
},
async relayAction(action) {
if (await this.client.run({ action, confirmed: this.relayConfirmed })) this.relayConfirmed = false
},
relayChip() {
return ({ unenrolled: 'Not set up', enrolling: 'Setting up', enrolled: 'On', rotating: 'Updating', confirming: 'Updating', revoking: 'Paused', revoked: 'Off', actionRequired: 'Needs attention' })[this.relay?.state] || 'Unknown'
},
relayStatus() {
const state = this.notifications?.relayEnrollment?.state
return ({ unenrolled: 'Enable alerts to set up this comma and phone automatically.', enrolling: 'Enrolling — waiting for the relay. Retries continue when internet returns.', enrolled: 'Relay enrolled. Enable alerts on each phone, then send a test.', rotating: 'Updating the relay credential.', confirming: 'Confirming the new relay credential.', revoking: 'Sending paused. Revocation is pending until the relay acknowledges it.', revoked: 'Alerts are disabled. Enable alerts to enroll again.', actionRequired: 'Relay authorization needs attention. Ask the operator to check or revoke the installation before enrolling again.' })[state] || 'This comma does not support relay enrollment.'
},
async saveRelay() {
if (await this.client.run({ action: 'configure', channel: 'appPush', enabled: true, url: this.relayUrl, token: '' })) this.relayUrl = ''
},
@@ -91,18 +119,27 @@ export const SentryNotifications = {
this.refreshPush()
if (this.mode !== "local" || document.hidden) return
this.client.run()
this.poll = setInterval(() => { if (!this.notificationBusy && !this.client.pending) this.client.run() }, 5000)
this.poll = setInterval(() => { this.refreshPush(); if (!this.notificationBusy && !this.client.pending) this.client.run() }, 5000)
},
channelExpanded(name) { return this.notifications.channels[name].enabled || !!this.channelSetup[name] },
toggleChannel(name) {
if (!this.notifications.channels[name].configured) {
this.channelSetup[name] = !this.channelSetup[name]
return
}
return this.reportSave(() => this.save(name, !this.notifications.channels[name].enabled))
},
async save(name, enabled) {
const draft = this.drafts[name]
const saved = await this.client.run({ action: "configure", channel: name, enabled, url: draft.url, token: draft.token })
if (saved) {
draft.url = ""; draft.token = ""
this.channelSetup[name] = false
}
return saved
},
test(name) { return this.client.run({ action: "test", channel: name }) },
async forget(name) { if (await this.client.run({ action: "forget", channel: name })) { this.drafts[name].url = ""; this.drafts[name].token = "" } },
async forget(name) { if (await this.client.run({ action: "forget", channel: name })) { this.drafts[name].url = ""; this.drafts[name].token = ""; this.channelSetup[name] = false } },
remove(id) { return this.client.run({ action: "unsubscribe", id }) },
async subscribe() {
if (!isSecureContext || !("serviceWorker" in navigator) || !("PushManager" in window)) {
@@ -131,32 +168,77 @@ export const SentryNotifications = {
<p v-if="mode !== 'local'">Notification configuration is available on your connected device.</p>
<template v-else>
<GxNotice tone="danger" v-if="notificationError">{{ notificationError }}</GxNotice>
<p v-if="notificationBusy" class="gx-note" role="status">Updating notifications…</p>
<p class="gx-note gx-sentry-busy" :class="{'gx-note--warning': notificationTesting}" role="status">{{ notificationBusy ? (notificationTesting ? 'Test notifications may take up to 20 seconds.' : 'Updating notifications…') : '' }}</p>
<GxNotice tone="danger" v-if="notifications?.queueFull">The notification queue is full. Some events could not be queued.</GxNotice>
<template v-if="notifications">
<p class="gx-note">{{ notifications.deliverySemantics }}</p>
<section class="gx-card gx-panel gx-stack">
<h3>Galaxy app phones</h3>
<label>HTTPS push relay URL <input v-model="relayUrl" class="gx-field" type="password" autocomplete="off" placeholder="Leave blank to keep saved URL"></label>
<button type="button" class="gx-btn gx-btn--tonal" @click="saveRelay">Save push relay</button>
<p v-if="!notifications.appPushAvailable" class="gx-note">Configure a push relay to deliver phone alerts.</p>
<button v-if="nativePush" type="button" class="gx-btn" :disabled="!notifications.appPushAvailable" @click="togglePhone">{{ pushState.enabled ? 'Disable phone alerts' : 'Enable phone alerts' }}</button>
<button v-if="notifications.appPushAvailable && pushState.registered && pushState.id" type="button" class="gx-btn" @click="testPhone(pushState.id)">Send test to this phone</button>
<p v-for="phone in (notifications.appSubscriptions || [])" :key="phone.id">{{ phone.label }} · {{ phone.lastResult || 'Not tested' }} · {{ phone.lastAt ? new Date(phone.lastAt * 1000).toLocaleString() : '' }}
<button type="button" class="gx-btn" :disabled="!notifications.appPushAvailable" @click="testPhone(phone.id)">Test</button><button type="button" class="gx-btn" @click="removePhone(phone.id)">Remove</button>
</p>
<p v-for="phone in (notifications.removedAppSubscriptions || [])" :key="phone.id">{{ phone.lastResult }}</p>
<button v-if="canSimulate()" class="gx-btn" @click="simulate">Simulate event</button>
<section class="gx-card" aria-label="Galaxy App Alerts">
<div class="gx-section__header"><i class="bi bi-phone" aria-hidden="true"></i><span class="gx-section__title">Galaxy App Alerts</span><span v-if="relay" class="gx-chip">{{ relayChip() }}</span></div>
<div class="gx-panel gx-stack">
<p v-if="!nativePush" class="gx-note">App alerts can be set up and managed in the Galaxy app.</p>
<template v-else>
<p class="gx-note" role="status">{{ relay ? relayStatus() : notifications.appPushAvailable ? 'Push relay configured.' : 'Configure a push relay to deliver phone alerts.' }}</p>
<div v-if="relay && ['unenrolled', 'revoked'].includes(relay.state)" class="gx-actions">
<button type="button" class="gx-btn" :disabled="!relay.automaticConfigured" @click="enableHostedAlerts">Enable alerts</button>
<span class="gx-note">{{ relay.automaticConfigured ? 'Sends phone destinations and alert details to the hosted relay.' : 'Hosted alerts are not configured in this release yet.' }}</span>
</div>
<p v-if="notifications.appPushAvailable && (nativePush || notifications.appSubscriptions?.length)" class="gx-note">Test alerts may take up to 20 seconds to arrive.</p>
<div v-if="nativePush" class="gx-row gx-row--borderless gx-row--wrap">
<div class="gx-row__info gx-row__info--wide"><span class="gx-row__label">This phone</span><span class="gx-row__desc">{{ phoneStatus() }}</span></div>
<div class="gx-actions">
<button v-if="notifications.appPushAvailable && pushState.registered && pushState.id" type="button" class="gx-btn gx-btn--tonal" :disabled="!!testingId" @click="testPhone(pushState.id)">{{ testingId === pushState.id ? 'Testing…' : 'Send test' }}</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!notifications.appPushAvailable" @click="togglePhone">{{ pushState.enabled ? 'Turn off' : 'Turn on' }}</button>
</div>
</div>
<div v-for="phone in otherPhones()" :key="phone.id" class="gx-row gx-row--wrap">
<div class="gx-row__info gx-row__info--wide"><span class="gx-row__label">{{ phone.label || 'Phone' }}</span>
<span class="gx-row__desc">{{ phone.lastResult || 'Not tested yet' }}<template v-if="phone.lastAt">, {{ new Date(phone.lastAt * 1000).toLocaleString() }}</template></span></div>
<div class="gx-actions">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!notifications.appPushAvailable || !!testingId" @click="testPhone(phone.id)">{{ testingId === phone.id ? 'Testing…' : 'Test' }}</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="removePhone(phone.id)">Remove</button>
</div>
</div>
<p v-if="otherPhones().length" class="gx-note">Remove only clears a phone from this comma. To stop it re-adding itself, also turn Sentry alerts off in the phone's app.</p>
<p v-for="phone in (notifications.removedAppSubscriptions || [])" :key="phone.id" class="gx-note">{{ phone.lastResult }}</p>
<details>
<summary class="gx-link">Relay settings</summary>
<div class="gx-stack gx-sentry-relay">
<template v-if="relay">
<dl v-if="relay.installationId" class="gx-details"><dt>Installation ID</dt><dd>{{ relay.installationId }}</dd></dl>
<template v-if="['unenrolled', 'revoked'].includes(relay.state)">
<div class="gx-sentry-channel__fields">
<label>Custom relay address <input v-model="relayUrl" class="gx-field" type="url" autocomplete="off" placeholder="https://your-relay.workers.dev"></label>
<label>One-time enrollment code <input v-model="enrollmentCode" class="gx-field" type="password" autocomplete="off"></label>
</div>
</template>
<label><input type="checkbox" v-model="relayConfirmed"> Tick to enable the buttons below. Enrolling shares your phone destinations and alert details with the relay operator. Forget locally does not revoke a copied credential.</label>
<div class="gx-actions">
<button v-if="['unenrolled', 'revoked'].includes(relay.state)" type="button" class="gx-btn gx-btn--tonal" :disabled="!relayConfirmed || !relayUrl || !enrollmentCode" @click="enrollRelay">Enroll with custom relay</button>
<button v-if="relay.state === 'enrolled'" type="button" class="gx-btn gx-btn--tonal" :disabled="!relayConfirmed" @click="relayAction('relayRotate')">Rotate credential</button>
<button v-if="!['unenrolled', 'revoked'].includes(relay.state)" type="button" class="gx-btn gx-btn--tonal" :disabled="!relayConfirmed" @click="relayAction('relayRevoke')">Revoke relay access</button>
<button v-if="relay.state !== 'unenrolled'" type="button" class="gx-btn gx-btn--tonal" :disabled="!relayConfirmed" @click="relayAction('relayForget')">Forget locally</button>
</div>
</template>
<div v-if="!relay || relay.state === 'unenrolled'" class="gx-sentry-channel__fields">
<label>{{ relay ? 'Legacy private relay URL' : 'HTTPS push relay URL' }} <input v-model="relayUrl" class="gx-field" type="password" autocomplete="off" placeholder="Leave blank to keep saved URL"></label>
</div>
<div class="gx-actions">
<button v-if="!relay || relay.state === 'unenrolled'" type="button" class="gx-btn gx-btn--tonal" @click="saveRelay">Save push relay</button>
<button v-if="canSimulate()" type="button" class="gx-btn gx-btn--tonal" @click="simulate">Simulate event</button>
</div>
</div>
</details>
</template>
</div>
</section>
<button class="gx-btn" @click="client.run({action: 'test', target: 'all'})">Test all channels</button>
<div class="gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="client.run({action: 'test', target: 'all'})">Test all channels</button></div>
<section v-for="name in channels" :key="name" class="gx-card gx-sentry-channel">
<div class="gx-section__header"><i class="bi" :class="{webPush: 'bi-bell', discord: 'bi-discord', webhook: 'bi-link-45deg', ntfy: 'bi-broadcast'}[name]" aria-hidden="true"></i><span class="gx-section__title">{{ labels[name] }}</span></div>
<div class="gx-panel gx-stack"><div class="gx-row gx-row--borderless"><div class="gx-row__info"><span class="gx-row__label">Enabled</span>
<span class="gx-row__desc">{{ notifications.channels[name].configured ? 'Configured' : 'Not configured' }}</span></div>
<label class="gx-switch"><input type="checkbox" role="switch" :aria-label="labels[name] + ' notifications'" :checked="notifications.channels[name].enabled"
:disabled="!notifications.channels[name].configured || notificationBusy"
@change="$event.target.checked=notifications.channels[name].enabled; reportSave(() => save(name, !notifications.channels[name].enabled))"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
<p role="status">Delivery: {{ notifications.channels[name].lastState }} · Pending: {{ notifications.channels[name].pending }}</p>
<div class="gx-section__header"><i class="bi" :class="{webPush: 'bi-bell', discord: 'bi-discord', webhook: 'bi-link-45deg', ntfy: 'bi-broadcast'}[name]" aria-hidden="true"></i><span class="gx-section__title">{{ labels[name] }}</span>
<label class="gx-switch"><input type="checkbox" role="switch" :aria-label="labels[name] + ' notifications'" :checked="channelExpanded(name)"
:disabled="notificationBusy"
@change="$event.target.checked=channelExpanded(name); toggleChannel(name)"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
<div v-if="channelExpanded(name)" class="gx-panel gx-stack">
<p class="gx-note" role="status">{{ notifications.channels[name].configured ? 'Configured' : 'Not configured' }}<template v-if="notifications.channels[name].configured && notifications.channels[name].lastState !== 'idle'">, last delivery {{ notifications.channels[name].lastState }}<template v-if="notifications.channels[name].pending">, {{ notifications.channels[name].pending }} pending</template></template></p>
<GxNotice tone="danger" v-if="notifications.channels[name].lastError">Delivery status: {{ notifications.channels[name].lastError.replaceAll('_', ' ') }}</GxNotice>
<div v-if="name !== 'webPush'" class="gx-sentry-channel__fields">
<label>HTTPS URL <input v-model="drafts[name].url" class="gx-field" type="password" autocomplete="off" placeholder="Leave blank to keep saved URL"></label>
@@ -169,8 +251,8 @@ export const SentryNotifications = {
<button class="gx-btn gx-btn--tonal" type="button" @click="forget(name)">Forget configuration</button>
</div></div>
</section>
<p v-if="notifications.subscriptionCount">Subscribed browsers: {{ notifications.subscriptionCount }}</p>
<button v-for="(subscription, index) in notifications.subscriptions" :key="subscription.id" class="gx-btn gx-btn--tonal" type="button" @click="remove(subscription.id)">Remove browser {{ index + 1 }}</button>
<div v-if="notifications.subscriptionCount" class="gx-actions"><span class="gx-note">Subscribed browsers: {{ notifications.subscriptionCount }}</span>
<button v-for="(subscription, index) in notifications.subscriptions" :key="subscription.id" class="gx-btn gx-btn--tonal" type="button" @click="remove(subscription.id)">Remove browser {{ index + 1 }}</button></div>
</template>
</template></div>
</section>`,
+18 -12
View File
@@ -4,6 +4,7 @@ import { GxNotice } from "./notice.js"
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
import { SentryNotifications } from "./sentry-notifications.js"
import { SnapshotFeed } from "./snapshot-feed.js"
import { BalancedImage } from "./white-balance.js"
import { deleteRecords, storedSize } from "./record-deletion.js"
// Historical motion records and independently configured notifications.
@@ -16,12 +17,14 @@ export function validSentryEvents(value) {
if (value.storageIncomplete !== undefined && typeof value.storageIncomplete !== "boolean") return false
const seen = new Set()
return value.events.every((event) => {
if (event === null || typeof event !== "object" || Object.keys(event).some((key) => !["eventId", "kind", "systemTimeMs", "images", "simulated"].includes(key)) ||
if (event === null || typeof event !== "object" || Object.keys(event).some((key) => !["eventId", "kind", "systemTimeMs", "images", "simulated", "voltage", "cutoff"].includes(key)) ||
(event.kind === "battery" ? ![event.voltage, event.cutoff].every((tenths) => Number.isSafeInteger(tenths) && tenths > 0 && tenths < 1000) :
event.voltage !== undefined || event.cutoff !== undefined) ||
(event.simulated !== undefined && event.simulated !== true) ||
(event.images !== undefined && (!Array.isArray(event.images) || event.images.length > 2 ||
new Set(event.images).size !== event.images.length || event.images.some((camera) => !["wide", "cabin"].includes(camera)))) ||
typeof event.eventId !== "string" || !ID.test(event.eventId) || seen.has(event.eventId) ||
!["warning", "alarm", "selfie"].includes(event.kind) || !Number.isSafeInteger(event.systemTimeMs) ||
!["warning", "alarm", "selfie", "battery"].includes(event.kind) || !Number.isSafeInteger(event.systemTimeMs) ||
event.systemTimeMs <= 0 || event.systemTimeMs > 8_640_000_000_000_000) return false
seen.add(event.eventId)
return true
@@ -50,7 +53,7 @@ export class SentryEventsFeed extends SnapshotFeed {
export const SentryEventsPage = {
name: "SentryEventsPage",
components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage },
components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage, BalancedImage },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
data: () => ({ bluetooth: bluetoothConnection(), status: "idle", data: null, error: "", deleting: false }),
@@ -62,6 +65,7 @@ export const SentryEventsPage = {
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() },
methods: { systemTime, storedSize },
computed: { kinds: () => ({ alarm: { label: 'Alarm', icon: 'bi-bell' }, warning: { label: 'Motion', icon: 'bi-exclamation-triangle' }, selfie: { label: 'Selfie', icon: 'bi-camera' }, battery: { label: 'Low battery', icon: 'bi-battery-half' } }) },
template: `
<div class="gx-view gx-home" aria-label="Sentry motion events">
<header class="gx-settings__header gx-page-header"><div><h2>Sentry</h2>
@@ -75,17 +79,19 @@ export const SentryEventsPage = {
<GxState v-if="status === 'loading'" loading>Reading local motion events…</GxState>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<template v-if="status === 'ready' && data">
<div class="gx-actions"><p>{{ data.events.length }} events · {{ data.storageIncomplete ? 'At least ' : '' }}{{ storedSize(data.storageBytes) }} stored</p><button type="button" class="gx-btn" :disabled="deleting" @click="feed.remove(null)">Delete All</button></div>
<div class="gx-actions gx-sentry-events__bar"><p class="gx-note">{{ data.events.length }} {{ data.events.length === 1 ? 'event' : 'events' }}, {{ data.storageIncomplete ? 'at least ' : '' }}{{ storedSize(data.storageBytes) }} stored</p>
<button v-if="data.events.length" type="button" class="gx-btn gx-btn--tonal" :disabled="deleting" @click="feed.remove(null)">Delete All</button></div>
<GxNotice v-if="data.scanIncomplete" tone="warn">This scan was incomplete. More local motion events may exist.</GxNotice>
<GxState v-if="!data.events.length">No local motion events found in this scan.</GxState>
<GxState v-if="!data.events.length">No motion events yet. Events appear here when Sentry detects motion while parked.</GxState>
<BluetoothNotice v-if="bluetooth && data.events.some(event => event.images?.length)" feature="Motion event images" />
<section v-for="event in data.events" :key="event.eventId" class="gx-card gx-home__card">
<div class="gx-section__header"><i class="bi" :class="event.kind === 'selfie' ? 'bi-camera' : event.kind === 'alarm' ? 'bi-bell' : 'bi-exclamation-triangle'"></i><h2 class="gx-section__title">{{ event.kind === 'selfie' ? 'Selfie' : event.kind === 'alarm' ? 'Alarm' : 'Warning' }}</h2></div>
<p>System time: {{ systemTime(event.systemTimeMs) }}</p>
<small>Event ID: {{ event.eventId }}</small>
<button type="button" class="gx-btn" :disabled="deleting" @click="feed.remove(event.eventId)">Delete</button>
<template v-if="!bluetooth"><img v-for="camera in (event.images || [])" :key="camera" :src="'./api/sentry/image/' + event.eventId + '/' + camera" :alt="camera + ' camera at motion event'" class="gx-media" /></template>
</section>
<div class="gx-sentry-events">
<article v-for="event in data.events" :key="event.eventId" class="gx-card gx-sentry-event">
<div class="gx-section__header"><i class="bi" :class="kinds[event.kind].icon" aria-hidden="true"></i><span class="gx-section__title">{{ kinds[event.kind].label }}</span></div>
<div v-if="!bluetooth && event.images?.length" class="gx-sentry-event__media"><BalancedImage v-for="camera in event.images" :key="camera" :balance="camera === 'cabin'" :src="'./api/sentry/image/' + event.eventId + '/' + camera" :alt="(camera === 'cabin' ? 'Cabin' : 'Road') + ' camera at motion event'" class="gx-media" /></div>
<div class="gx-panel gx-row gx-row--borderless"><div class="gx-row__info"><span class="gx-row__label">{{ systemTime(event.systemTimeMs) }}</span><small v-if="event.kind === 'battery'" class="gx-note">Battery {{ (event.voltage / 10).toFixed(1) }} V, turns off at {{ (event.cutoff / 10).toFixed(1) }} V</small><small class="gx-note">Event {{ event.eventId }}</small></div>
<button type="button" class="gx-btn gx-btn--tonal" :aria-label="'Delete ' + kinds[event.kind].label.toLowerCase() + ' event'" :disabled="deleting" @click="feed.remove(event.eventId)">Delete</button></div>
</article>
</div>
</template>
</template>
</div>`,
+15 -1
View File
@@ -43,6 +43,16 @@ const activeSettings = new Set()
export const settingsBusy = state => ["updating", "saving"].includes(state.status) || !!state.pending || !!state.reviewing
export function sentryMonitorStatus(data, error = "") {
const subtitle = typeof data?.subtitle === "string" ? data.subtitle : ""
const label = subtitle.split(":", 1)[0]
if (error || !subtitle || label.startsWith("Waiting for monitor") || label === "Unavailable" || subtitle.includes("status is unavailable"))
return { label: "Status unavailable", tone: "warning", detail: error ? "Reconnecting to the motion monitor…" : subtitle || "Checking motion monitor…" }
if (label === "Monitoring motion") return { label: "Armed", tone: "armed", detail: subtitle }
if (label.startsWith("Arming")) return { label: "Arming", tone: "warning", detail: subtitle }
return { label: "Disarmed", tone: "disarmed", detail: subtitle }
}
export class SettingsFeed {
constructor({ publish, unauthorized = () => { }, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
@@ -276,6 +286,7 @@ export const SettingsPage = {
return { state, feed, sections: SETTINGS_SECTIONS, navigation, navigationKey, restoredPage: saved?.page }
},
computed: {
sentryStatus() { return sentryMonitorStatus(this.state.data, this.state.error) },
busy() { return settingsBusy(this.state) || !!this.state.testingSound },
activeSection() {
if (this.state.developerOpen) return this.sections.at(-1)
@@ -464,7 +475,6 @@ export const SettingsPage = {
<p class="gx-page-description" v-else-if="state.data?.page === 'wheel'">Assign what the steering-wheel controls do.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'data'">Choose what uploads when the vehicle uses mobile data.</p>
<p class="gx-page-description" v-else-if="['aggressive','standard','relaxed'].includes(state.data?.page)">Adjust acceleration, braking and following for this driving style.</p>
<p class="gx-page-description" v-else-if="initialPage === 'sentry'">{{ state.data?.subtitle || "Checking motion monitor…" }}</p>
<GxState v-if="mode !== 'local'">Local settings are unavailable in preview.</GxState>
<template v-else>
<GxNotice v-if="state.data && state.data.vehicleIdentified === false" tone="info">Vehicle not identified yet. Turn on the car and wait for fingerprinting; vehicle-specific settings will then appear here.</GxNotice>
@@ -484,6 +494,10 @@ export const SettingsPage = {
<button v-if="card.collapsible" type="button" class="gx-section__header gx-section__header--toggle" :aria-expanded="card.open" @click="toggleCard(card.key)"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-section__title">{{ card.title }}</span><i class="bi" :class="card.open ? 'bi-chevron-up' : 'bi-chevron-down'" aria-hidden="true"></i></button>
<div v-else-if="cardIndex" class="gx-section__header"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-section__title">{{ card.title }}</span></div>
<div v-else class="gx-section__header"><i class="bi" :class="activeSection.icon" aria-hidden="true"></i><span class="gx-section__title">{{ state.data.page === 'hub' ? activeSection.label : state.data.title }}</span>
<div v-if="state.data.page === 'sentry'" class="gx-sentry-status" :class="'gx-sentry-status--' + sentryStatus.tone" role="status">
<strong class="gx-sentry-status__label"><span class="gx-status-dot" aria-hidden="true"></span>{{ sentryStatus.label }}</strong>
<span class="gx-sentry-status__detail">{{ sentryStatus.detail }}</span>
</div>
<button v-if="state.data.page !== 'hub' && state.data.rows.length" type="button" class="gx-icon-btn" :aria-label="state.searchOpen ? 'Close page search' : 'Search within this page'" :aria-pressed="state.searchOpen" @click="togglePageSearch"><i class="bi" :class="state.searchOpen ? 'bi-x-lg' : 'bi-search'" aria-hidden="true"></i></button>
</div>
<div v-if="state.searchOpen && !cardIndex" class="gx-settings__search"><input ref="pageSearch" v-model="state.query" class="gx-field" type="search" aria-label="Search within this page" placeholder="Search within…"></div>
+4 -1
View File
@@ -4,6 +4,7 @@ import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { SettingsFeed, settingsBusy } from "./settings.js"
import { GalaxySettingRow } from "./galaxy-setting-row.js"
import { LiveCameraPreview } from "./cameras.js"
import { whiteBalancePref } from "./white-balance.js"
import { GxNotice } from "./notice.js"
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
import { FORMATS, annotationDraft, displaySide, sourcePoint } from "./vasm-geometry.js"
@@ -34,9 +35,10 @@ export const VasmPage = {
"This saved camera size cannot be edited here. Choose a supported size and redraw the regions."
}
} })
return { state, feed, FORMATS, displaySide, bluetooth: bluetoothConnection() }
return { state, feed, FORMATS, displaySide, bluetooth: bluetoothConnection(), pref: whiteBalancePref }
},
mounted() {
this.pref.load()
this._dragIndex = -1
this._dragSide = null
this.liveCamera = new LiveCameraPreview({
@@ -49,6 +51,7 @@ export const VasmPage = {
},
updated() { this.$nextTick(() => this.redraw()) },
beforeUnmount() { this.liveCamera.stop(); this.feed.stop() },
watch: { "pref.on"() { this.liveCamera?.refresh() } },
computed: {
controls() { return (this.state.data?.rows || []).map((row, index) => ({ row, index }))
.filter(({ index }) => index !== this.state.data?.editorRow && !["Saved spot-monitor settings"].includes(this.state.data.rows[index].label)) },
@@ -0,0 +1,113 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
const GAIN_MIN = 0.6
const GAIN_MAX = 1.6
const SAMPLE_STEP = 8
const DESATURATE = 0.75
export function whiteBalanceGains(data, width, height) {
let r = 0, g = 0, b = 0, count = 0
for (let y = 0; y < height; y += SAMPLE_STEP) {
for (let x = 0; x < width; x += SAMPLE_STEP) {
const i = (y * width + x) * 4
r += data[i]; g += data[i + 1]; b += data[i + 2]; count++
}
}
if (!count) return [1, 1, 1]
const mean = [r / count / 255, g / count / 255, b / count / 255]
const luma = 0.299 * mean[0] + 0.587 * mean[1] + 0.114 * mean[2]
if (luma < 0.02) return [1, 1, 1]
return mean.map(channel => Math.min(GAIN_MAX, Math.max(GAIN_MIN, luma / Math.max(channel, 1e-3))))
}
// Cancels the cabin camera's IR magenta cast, leaving clipped highlights white.
export function whiteBalance(data, width, height) {
const gains = whiteBalanceGains(data, width, height)
for (let i = 0; i < data.length; i += 4) {
const rgb = [data[i] / 255, data[i + 1] / 255, data[i + 2] / 255]
const peak = Math.max(...rgb)
const t = Math.min(1, Math.max(0, (peak - 0.95) / 0.05))
const mixed = rgb.map((v, c) => v * gains[c] * (1 - t) + v * t)
const gray = 0.299 * mixed[0] + 0.587 * mixed[1] + 0.114 * mixed[2]
for (let c = 0; c < 3; c++) {
const out = gray + (mixed[c] - gray) * DESATURATE
data[i + c] = Math.round(Math.min(1, Math.max(0, out)) * 255)
}
}
return data
}
export function balancedBlobUrl(src) {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => {
const canvas = document.createElement("canvas")
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const ctx = canvas.getContext("2d", { willReadFrequently: true })
ctx.drawImage(image, 0, 0)
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height)
whiteBalance(frame.data, canvas.width, canvas.height)
ctx.putImageData(frame, 0, 0)
canvas.toBlob(blob => blob ? resolve(URL.createObjectURL(blob)) : reject(new Error("Balance failed")), "image/jpeg", 0.92)
}
image.onerror = () => reject(new Error("Balance failed"))
image.src = src
})
}
const KEY = "gx-snapshot-white-balance"
const ENDPOINT = "./api/cameras/white-balance"
const send = (body) => fetch(ENDPOINT, { method: "POST", credentials: "same-origin", cache: "no-store",
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
.then(response => response.ok ? response.json() : null).then(result => typeof result?.enabled === "boolean" ? result.enabled : null, () => null)
// The device owns this choice, so the on-screen cameras follow it; the browser copy is the fallback.
export const whiteBalancePref = reactive({
on: (() => { try { return localStorage.getItem(KEY) !== "0" } catch { return true } })(),
loaded: false,
remember(on) { this.on = on; try { localStorage.setItem(KEY, on ? "1" : "0") } catch {} },
async load() {
if (this.loaded) return
this.loaded = true
const saved = await send({})
if (saved !== null) this.remember(saved)
},
async set(on) {
this.remember(on)
const saved = await send({ enabled: on })
if (saved !== null && saved !== on) this.remember(saved)
},
})
export const WhiteBalanceSwitch = {
name: "WhiteBalanceSwitch",
setup: () => ({ pref: whiteBalancePref }),
mounted() { this.pref.load() },
template: `<div class="gx-row gx-row--borderless"><div class="gx-row__info"><span class="gx-row__label">White balance</span><small>Corrects the cabin camera's magenta tint.</small></div>
<label class="gx-switch"><input type="checkbox" role="switch" aria-label="White balance" :checked="pref.on" @change="pref.set($event.target.checked)"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>`,
}
export const BalancedImage = {
name: "BalancedImage",
props: { src: { type: String, required: true }, balance: { type: Boolean, default: true } },
setup: () => ({ pref: whiteBalancePref }),
data: () => ({ shown: "" }),
computed: { active() { return this.balance && this.pref.on } },
watch: { src() { this.refresh() }, active() { this.refresh() } },
mounted() { this.pref.load(); this.refresh() },
beforeUnmount() { this.token++; this.release() },
methods: {
release() { if (this.shown && this.shown !== this.src) URL.revokeObjectURL(this.shown); this.shown = "" },
async refresh() {
const token = this.token = (this.token || 0) + 1
if (!this.active || !this.src) { this.release(); return }
let url = ""
try { url = await balancedBlobUrl(this.src) } catch { url = "" }
if (token !== this.token) { if (url) URL.revokeObjectURL(url); return }
this.release()
this.shown = url
},
},
template: `<img :src="shown || src">`,
}
@@ -31,6 +31,7 @@ threshold and warning-count behavior. An alarm requires more than 25 qualifying
deltas and at least 30 seconds within a 60-second motion window. A stale or
repeated sample, sensor loss, owner-loop gap, system resume, settings change,
ignition or shutdown request disarms the policy. Recovery requires rearming.
A poll with no new sensor message keeps arming while the last sample is fresh.
Hardwared retains battery accounting and shutdown authority; Sentry neither
extends parked runtime nor clears shutdown requests.
+102 -14
View File
@@ -11,6 +11,7 @@ import json
import os
from pathlib import Path
import re
import secrets
import stat
import threading
import time
@@ -20,8 +21,9 @@ import uuid
import requests
from openpilot.starpilot.storage import starpilot_storage_root
from openpilot.starpilot.sentry_mode.storage import EventStore
from openpilot.starpilot.sentry_mode import web_push
from openpilot.starpilot.sentry_mode.storage import BATTERY_FIELDS, EventStore, valid_tenths, volts
from openpilot.starpilot.sentry_mode import web_push, relay_enrollment
from openpilot.starpilot.sentry_mode.relay_config import DEFAULT_RELAY_URL
CHANNELS = ('webPush', 'discord', 'webhook', 'ntfy', 'appPush')
APP_PUSH_RELAY_URL = ''
@@ -32,6 +34,15 @@ MAX_AGE = 86400
MAX_ATTEMPTS = 4
RETRY_SECONDS = (30, 120, 600, 600)
ID = re.compile(r'[0-9a-f]{32}\Z')
JOB_FIELDS = {'eventId', 'kind', 'channel', 'generation', 'subscriptionId', 'created', 'next', 'attempts', 'state', 'error'}
def message_text(job: dict) -> str:
if job['kind'] == 'test':
return 'This is a test StarPilot Sentry notification.'
if job['kind'] == 'battery':
return f"Car battery is low ({volts(job['voltage'])}). The device and Sentry turn off at {volts(job['cutoff'])}."
return f"Parked motion detected ({job['kind']})."
class NotificationUnavailable(Exception):
@@ -54,7 +65,7 @@ def endpoint(value: object, *, allow_http: bool = False) -> str:
def fresh_state() -> dict:
return {'version': 1, 'channels': {channel: {'enabled': False, 'url': '', 'token': '', 'generation': uuid.uuid4().hex,
'since': 0} for channel in CHANNELS},
'appSubscriptions': [], 'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False}
'relayEnrollment': relay_enrollment.fresh(), 'appSubscriptions': [], 'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False}
def wall_seconds() -> float:
@@ -142,6 +153,10 @@ class NotificationOwner:
state['appSubscriptions'] = []
if type(state.get('channels')) is dict:
state['channels']['appPush'] = fresh_state()['channels']['appPush']
if type(state) is dict:
state.setdefault('relayEnrollment', relay_enrollment.fresh())
if type(state['relayEnrollment']) is dict:
state['relayEnrollment'].setdefault('rejected', {})
self._validate_state(state)
self.state = state
self.saved_bytes = json.dumps(state, separators=(',', ':'), allow_nan=False).encode()
@@ -149,6 +164,7 @@ class NotificationOwner:
os.close(directory)
def _validate_state(self, state: dict) -> None:
relay_enrollment.validate(state.get('relayEnrollment') if isinstance(state, dict) else None)
if (type(state) is not dict or set(state) != set(fresh_state()) or type(state['version']) is not int or state['version'] != 1 or
type(state['channels']) is not dict or set(state['channels']) != set(CHANNELS) or
type(state['subscriptions']) is not list or len(state['subscriptions']) > 16 or
@@ -187,10 +203,10 @@ class NotificationOwner:
if type(sub['since']) not in (int, float) or not 0 <= sub['since'] < 2**63:
raise ValueError('Invalid subscription time')
for key, job in state['jobs'].items():
if (type(job) is not dict or set(job) != {'eventId', 'kind', 'channel', 'generation', 'subscriptionId', 'created',
'next', 'attempts', 'state', 'error'} or
if (type(job) is not dict or set(job) != JOB_FIELDS | (BATTERY_FIELDS if job.get('kind') == 'battery' else set()) or
(job['kind'] == 'battery' and not all(valid_tenths(job[k]) for k in BATTERY_FIELDS)) or
type(key) is not str or len(key) != 64 or type(job['eventId']) is not str or ID.fullmatch(job['eventId']) is None or
job['kind'] not in ('warning', 'alarm', 'test') or job['channel'] not in CHANNELS or
job['kind'] not in ('warning', 'alarm', 'battery', 'test') or job['channel'] not in CHANNELS or
type(job['generation']) is not str or ID.fullmatch(job['generation']) is None or
(type(job['subscriptionId']) is not str or (re.fullmatch(r'[0-9a-f]{12}' if job['channel'] == 'appPush' else
r'[0-9a-f]{32}' if job['channel'] == 'webPush' else r'', job['subscriptionId']) is None)) or
@@ -236,12 +252,19 @@ class NotificationOwner:
'subscriptionCount': len(self.state['subscriptions']),
'subscriptions': [{'id': sub['id']} for sub in self.state['subscriptions']],
'deliverySemantics': 'Retries may repeat delivery after an interrupted remote response.'}
result['appSubscriptions'] = [{key: sub[key] for key in ('id', 'label', 'appId', 'addedAt', 'lastResult', 'lastAt')}
rejected = self.state['relayEnrollment']['rejected']
result['appSubscriptions'] = [{key: sub[key] for key in ('id', 'label', 'appId', 'addedAt', 'lastResult', 'lastAt')} |
({'lastResult': 'Relay does not accept this app'} if sub['id'] in rejected else {})
for sub in self.state['appSubscriptions']]
result['removedAppSubscriptions'] = [
{'id': job['subscriptionId'], 'lastResult': 'Phone unregistered — removed', 'lastAt': job['next']}
for job in jobs.values() if job['channel'] == 'appPush' and job['error'] == 'expired'][-8:]
enrollment = self.state['relayEnrollment']
result['relayEnrollment'] = {'supported': True, 'automaticConfigured': bool(DEFAULT_RELAY_URL), 'state': enrollment['state'],
'installationId': enrollment['id'], 'registeredCount': len(enrollment['registered'])}
result['appPushAvailable'] = self.state['channels']['appPush']['enabled'] and bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL)
if enrollment['state'] != 'unenrolled':
result['appPushAvailable'] = result['appPushAvailable'] and enrollment['state'] == 'enrolled'
for name, cfg in self.state['channels'].items():
relevant = [j for j in jobs.values() if j['channel'] == name and j['generation'] == cfg['generation']]
latest = max(relevant, key=lambda j: j['created'], default=None)
@@ -265,10 +288,40 @@ class NotificationOwner:
previous = copy.deepcopy(self.state)
action = payload['action']
now = self.clock()
if action == 'configure':
if action == 'relayEnroll':
automatic = set(payload) == {'action', 'confirmed'}
if (not automatic and set(payload) != {'action', 'url', 'invitation', 'confirmed'}) or payload.get('confirmed') is not True:
raise ValueError('Confirm relay enrollment')
current = self.state['relayEnrollment']
if automatic and current['state'] in ('enrolling', 'enrolled', 'rotating', 'confirming'):
return self.snapshot()
if current['state'] not in ('unenrolled', 'revoked'):
raise ValueError('Revoke or forget the current enrollment first')
url = DEFAULT_RELAY_URL if automatic else payload['url']
if not url:
raise ValueError('Hosted alerts are not configured in this release yet')
self.state['relayEnrollment'] = relay_enrollment.begin(url, '' if automatic else payload['invitation'])
self.state['channels']['appPush'].update(url=self.state['relayEnrollment']['url'], enabled=True, generation=uuid.uuid4().hex, since=now)
elif action in ('relayRotate', 'relayRevoke', 'relayForget'):
if set(payload) != {'action', 'confirmed'} or payload['confirmed'] is not True:
raise ValueError('Confirm the relay action')
enrollment = self.state['relayEnrollment']
if action == 'relayForget':
self.state['relayEnrollment'] = relay_enrollment.fresh()
self.state['channels']['appPush'] = fresh_state()['channels']['appPush']
elif action == 'relayRotate':
if enrollment['state'] != 'enrolled':
raise ValueError('Enroll the relay before rotating its credential')
enrollment.update(state='rotating', candidate=secrets.token_hex(32), requestId=uuid.uuid4().hex, next=0, attempts=0)
elif enrollment['id']:
enrollment.update(state='revoking', next=0, attempts=0)
self.state['channels']['appPush']['enabled'] = False
elif action == 'configure':
if set(payload) != {'action', 'channel', 'enabled', 'url', 'token'} or payload['channel'] not in CHANNELS:
raise ValueError('Invalid notification channel')
name = payload['channel']
if name == 'appPush' and self.state['relayEnrollment']['state'] != 'unenrolled':
raise ValueError('Use relay enrollment controls for this channel')
if (type(payload['enabled']) is not bool or type(payload['token']) is not str or len(payload['token']) > 512 or
any(ord(c) < 32 for c in payload['token'])):
raise ValueError('Invalid notification setting')
@@ -292,6 +345,8 @@ class NotificationOwner:
elif action == 'forget':
if set(payload) != {'action', 'channel'} or payload['channel'] not in CHANNELS:
raise ValueError('Choose a notification channel')
if payload['channel'] == 'appPush' and self.state['relayEnrollment']['state'] != 'unenrolled':
raise ValueError('Revoke or forget the relay enrollment first')
self.state['channels'][payload['channel']] = fresh_state()['channels'][payload['channel']]
if payload['channel'] == 'webPush':
self.state['subscriptions'] = []
@@ -328,6 +383,7 @@ class NotificationOwner:
raise ValueError('Invalid app subscription')
current = self.state['appSubscriptions']
existing = next((sub for sub in current if sub['token'] == payload['token']), None)
self.state['relayEnrollment']['rejected'].pop(hashlib.sha256(payload['token'].encode()).hexdigest()[:12], None)
if existing:
existing.update(appId=payload['appId'], label=payload['label'])
elif len(current) < 8:
@@ -397,7 +453,8 @@ class NotificationOwner:
self.state['jobs'][key] = {'eventId': event['eventId'], 'kind': event['kind'], 'channel': name,
'generation': cfg['generation'], 'subscriptionId': target['id'],
'created': event['wallTimeNs'] / 1e9, 'next': now, 'attempts': 0,
'state': 'queued', 'error': ''}
'state': 'queued', 'error': '',
**({k: event[k] for k in BATTERY_FIELDS} if event['kind'] == 'battery' else {})}
def delete_events(self, remove, event_id=None):
with self.lock:
@@ -420,6 +477,17 @@ class NotificationOwner:
if not self.available:
return
now = self.clock()
original_enrollment = copy.deepcopy(self.state['relayEnrollment'])
updated_enrollment = copy.deepcopy(original_enrollment)
subscriptions = copy.deepcopy(self.state['appSubscriptions'])
self.lock.release()
try:
relay_enrollment.step(updated_enrollment, subscriptions, now)
finally:
self.lock.acquire()
if self.state['relayEnrollment'] == original_enrollment:
self.state['relayEnrollment'] = updated_enrollment
self._save()
self.state['jobs'] = {key: job for key, job in self.state['jobs'].items() if now - job['created'] <= MAX_AGE}
self.state['queueFull'] = False
if not any(cfg["enabled"] for cfg in self.state["channels"].values()):
@@ -436,6 +504,9 @@ class NotificationOwner:
cfg = self.state['channels'][job['channel']]
sub = next((s['subscription'] for s in self.state['subscriptions'] if s['id'] == job['subscriptionId']), None)
if job['channel'] == 'appPush':
enrollment = self.state['relayEnrollment']
if enrollment['state'] != 'unenrolled' and (enrollment['state'] != 'enrolled' or job['subscriptionId'] not in enrollment['registered']):
continue
sub = next((s for s in self.state['appSubscriptions'] if s['id'] == job['subscriptionId']), None)
if not cfg['enabled'] or cfg['generation'] != job['generation'] or (job['channel'] in ('webPush', 'appPush') and sub is None):
job['state'] = 'cancelled'
@@ -469,6 +540,12 @@ class NotificationOwner:
current_sub['lastAt'] = self.clock()
current_sub['lastResult'] = ('Delivered to relay' if 200 <= code < 300 else 'Relay not configured' if code == 0 else
'Phone unregistered — removed' if code == 410 else 'Rate limited' if code == 429 else f'Relay error {code}')
enrollment = self.state['relayEnrollment']
if enrollment['state'] != 'unenrolled' and code in (401, 403):
enrollment['state'] = 'actionRequired'
if enrollment['state'] == 'enrolled' and code == 409:
enrollment['registered'].pop(job['subscriptionId'], None)
code = 503
if code == 410:
self.state['appSubscriptions'] = [s for s in self.state['appSubscriptions'] if s['id'] != job['subscriptionId']]
if 200 <= code < 300:
@@ -516,8 +593,9 @@ class NotificationOwner:
return files
def _send(self, name: str, cfg: dict, subscription: dict | None, job: dict, key: str, pem: str) -> int:
message = 'This is a test StarPilot Sentry notification.' if job['kind'] == 'test' else f"Parked motion detected ({job['kind']})."
event = {'eventId': job['eventId'], 'kind': job['kind'], 'systemTimeMs': int(job['created'] * 1000)}
message = message_text(job)
event = {'eventId': job['eventId'], 'kind': job['kind'], 'systemTimeMs': int(job['created'] * 1000),
**{k: job[k] for k in BATTERY_FIELDS if k in job}}
headers = {'Idempotency-Key': key}
if name == 'appPush':
if subscription is None:
@@ -525,9 +603,18 @@ class NotificationOwner:
relay = cfg['url'] or APP_PUSH_RELAY_URL
if not relay:
return 0
response = requests.post(relay.rstrip('/') + '/v1/sentry', json={
protocol = '/v1/sentry'
with self.lock:
enrollment = copy.deepcopy(self.state['relayEnrollment'])
if enrollment['state'] != 'unenrolled':
if enrollment['state'] != 'enrolled' or relay != enrollment['url']:
return 401
protocol = '/v2/sentry'
headers['Authorization'] = 'Bearer ' + enrollment['id'] + '.' + enrollment['secret']
response = requests.post(relay.rstrip('/') + protocol, json={
'token': subscription['token'], 'appId': subscription['appId'], 'kind': job['kind'], 'eventId': job['eventId'],
'wallTimeNs': int(job['created'] * 1e9), 'label': subscription['label']}, headers=headers,
'wallTimeNs': int(job['created'] * 1e9), 'label': subscription['label'],
**({'detail': message} if job['kind'] == 'battery' else {})}, headers=headers,
timeout=(3, 10), allow_redirects=False, stream=True)
elif name == 'webPush':
if subscription is None:
@@ -554,7 +641,8 @@ class NotificationOwner:
'event': json.dumps(event, separators=(',', ':'))}, files=files or None,
headers=headers, timeout=(3, 10), allow_redirects=False, stream=True)
else:
headers.update({'Title': 'StarPilot Sentry Mode', 'Priority': 'urgent', 'Tags': 'warning,car'})
headers.update({'Title': 'StarPilot Sentry Mode', 'Priority': 'high' if job['kind'] == 'battery' else 'urgent',
'Tags': 'battery,car' if job['kind'] == 'battery' else 'warning,car'})
response = requests.post(cfg['url'], data=message.encode(), headers=headers, timeout=(3, 10), allow_redirects=False, stream=True)
try:
return response.status_code
+7 -1
View File
@@ -54,10 +54,11 @@ class Inputs:
device_fresh: bool
ignition_off: bool
sample: MotionSample | None = None
sample_pending: bool = False
def __post_init__(self) -> None:
if any(type(value) is not bool for value in (self.enabled, self.offroad, self.voltage_ok,
self.device_fresh, self.ignition_off)) or \
self.device_fresh, self.ignition_off, self.sample_pending)) or \
(self.sample is not None and not isinstance(self.sample, MotionSample)):
raise ValueError("Sentry authority inputs must be explicit booleans and a validated motion sample")
@@ -123,6 +124,11 @@ class SentryPolicy:
return Decision("unavailable")
sample = inputs.sample
# A poll with no new message keeps arming while the last sample is fresh.
if sample is None and inputs.sample_pending and self.arm_started is not None and \
self.last_sample_time is not None and 0 <= now - self.last_sample_time <= MAX_SAMPLE_AGE_SECONDS:
remaining = ARM_DELAY_SECONDS - (now - self.arm_started)
return Decision("arming", seconds_remaining=max(0, math.ceil(remaining))) if remaining > 0 else Decision("armed")
if sample is None or sample.mono_time > now or now - sample.mono_time > MAX_SAMPLE_AGE_SECONDS or \
(self.last_sample_time is not None and sample.mono_time <= self.last_sample_time):
last_sample_time = self.last_sample_time
@@ -0,0 +1,2 @@
"""Hosted Sentry push relay used for one-tap enrollment."""
DEFAULT_RELAY_URL = 'https://galaxy-sentry-relay.dannyirwin31.workers.dev'
@@ -0,0 +1,123 @@
"""Private relay credential lifecycle; the owner persists each transition."""
import hashlib
import re
import secrets
import uuid
from urllib.parse import urlsplit
import requests
def fresh():
return {'version': 1, 'url': '', 'id': '', 'secret': '', 'invitation': '', 'requestId': '',
'candidate': '', 'state': 'unenrolled', 'registered': {}, 'rejected': {}, 'next': 0, 'attempts': 0}
def validate(value):
if not isinstance(value, dict) or set(value) != set(fresh()) or value['version'] != 1:
raise ValueError('Invalid relay enrollment')
if value['state'] not in ('unenrolled', 'enrolling', 'enrolled', 'rotating', 'confirming', 'revoking', 'revoked', 'actionRequired'):
raise ValueError('Invalid relay enrollment state')
for key, size in (('id', 32), ('secret', 64), ('invitation', 64), ('requestId', 32), ('candidate', 64)):
if not isinstance(value[key], str) or (value[key] and not re.fullmatch('[a-f0-9]{' + str(size) + '}', value[key])):
raise ValueError('Invalid relay credential')
if not isinstance(value['url'], str) or len(value['url']) > 2048:
raise ValueError('Invalid relay address')
if value['url']:
normalized_url(value['url'])
if (any(not isinstance(value[key], dict) or len(value[key]) > 8 or
any(not re.fullmatch(r'[a-f0-9]{12}', k) or not isinstance(v, str) or not re.fullmatch(r'[a-f0-9]{64}', v)
for k, v in value[key].items()) for key in ('registered', 'rejected')) or
type(value['next']) not in (int, float) or not 0 <= value['next'] < 2**63 or
type(value['attempts']) is not int or not 0 <= value['attempts'] <= 16):
raise ValueError('Invalid relay recovery state')
if value['state'] not in ('unenrolled', 'revoked') and not all(value[k] for k in ('url', 'id', 'secret')):
raise ValueError('Missing relay credential')
def normalized_url(url):
if not isinstance(url, str) or any(ord(c) <= 32 for c in url):
raise ValueError('Use an HTTPS relay base URL')
parts = urlsplit(url)
if parts.scheme != 'https' or not parts.hostname or parts.username or parts.password or parts.query or parts.fragment:
raise ValueError('Use an HTTPS relay base URL without a secret or query')
if parts.path.strip('/'):
raise ValueError('Use the relay host URL without a path')
port = parts.port
return 'https://' + parts.hostname.lower() + (f':{port}' if port and port != 443 else '')
def begin(url, invitation=''):
if not isinstance(invitation, str) or (invitation and not re.fullmatch(r'[a-f0-9]{64}', invitation)):
raise ValueError('Enter the operator’s enrollment code')
return {**fresh(), 'url': normalized_url(url), 'id': uuid.uuid4().hex, 'secret': secrets.token_hex(32),
'invitation': invitation, 'requestId': uuid.uuid4().hex, 'state': 'enrolling'}
def fingerprint(sub):
return hashlib.sha256((sub['appId'] + ':' + sub['token']).encode()).hexdigest()
def request(state, method, path, data=None, *, secret=None):
headers = {'Authorization': 'Bearer ' + state['id'] + '.' + (secret or state['secret'])}
with requests.request(method, state['url'] + '/v2/' + path, json=data, headers=headers,
timeout=(3, 10), allow_redirects=False, stream=True) as response:
return response.status_code
def step(state, subscriptions, now, send=request):
if state['state'] in ('unenrolled', 'revoked', 'actionRequired') or state['next'] > now:
return
phase = state['state']
try:
if phase == 'enrolling':
code = send(state, 'GET', 'installation')
if code == 401:
code = send(state, 'POST', 'enroll', {'invitation': state['invitation'], 'installationId': state['id'],
'secretDigest': hashlib.sha256(state['secret'].encode()).hexdigest(), 'requestId': state['requestId']})
if 200 <= code < 300:
state.update(state='enrolled', invitation='')
elif phase == 'rotating':
code = send({**state, 'secret': state['candidate']}, 'GET', 'installation')
if code == 401:
code = send(state, 'POST', 'credentials/rotate', {'secretDigest': hashlib.sha256(state['candidate'].encode()).hexdigest(),
'requestId': state['requestId']})
if 200 <= code < 300:
state.update(secret=state['candidate'], candidate='', state='confirming')
elif phase == 'confirming':
code = send(state, 'POST', 'credentials/confirm', {})
if 200 <= code < 300:
state['state'] = 'enrolled'
elif phase == 'revoking':
code = send(state, 'DELETE', 'installation')
if 200 <= code < 300 or code == 401:
state.update(fresh(), state='revoked')
return
else:
desired = {sub['id']: fingerprint(sub) for sub in subscriptions}
state['rejected'] = {key: value for key, value in state['rejected'].items() if desired.get(key) == value}
removed = next((key for key in state['registered'] if key not in desired), None)
pending = next((sub for sub in subscriptions if desired[sub['id']] not in (state['registered'].get(sub['id']), state['rejected'].get(sub['id']))), None)
if removed:
code = send(state, 'DELETE', 'subscriptions/' + removed)
if 200 <= code < 300:
del state['registered'][removed]
elif pending:
code = send(state, 'PUT', 'subscriptions/' + pending['id'], {key: pending[key] for key in ('token', 'appId')})
if 200 <= code < 300:
state['registered'][pending['id']] = desired[pending['id']]
elif code in (400, 409):
state['rejected'][pending['id']] = desired[pending['id']]
code = 200
else:
return
if 200 <= code < 300:
state.update(attempts=0, next=0)
return
if code not in (408, 429) and code < 500:
state.update(state='actionRequired', invitation='')
return
except requests.RequestException:
pass
state['attempts'] = min(16, state['attempts'] + 1)
state['next'] = now + min(600, 5 * 2 ** min(7, state['attempts']))
+54 -7
View File
@@ -15,11 +15,21 @@ from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.sentry_mode.policy import Inputs, MotionSample, SentryPolicy
from openpilot.starpilot.sentry_mode.preferences import read_preferences
from openpilot.starpilot.sentry_mode.status import RuntimeStatus
from openpilot.starpilot.sentry_mode.storage import StorageUnavailable
PERIPHERAL_TTL_NS = 1_500_000_000 # C++ 2 Hz publisher; one missed frame.
SAMPLE_TTL_NS = 500_000_000
TICK_SECONDS = 0.1
BATTERY_WARNING_TENTHS = 2
BATTERY_RECOVERED_TENTHS = 4
BATTERY_HOLD_NS = 60_000_000_000
BATTERY_CRITICAL_HOLD_NS = 10_000_000_000
BATTERY_REPEAT_NS = 3600 * 1_000_000_000
def recent_battery_warning(events: list[dict], now_ns: int) -> bool:
return any(event["kind"] == "battery" and 0 <= now_ns - event["wallTimeNs"] <= BATTERY_REPEAT_NS for event in events)
def boot_time_ns() -> int:
@@ -29,11 +39,15 @@ def boot_time_ns() -> int:
class SentryRuntime:
def __init__(self, params: Params, messages: Any, *, mono_clock: Callable[[], int] = time.monotonic_ns,
boot_clock: Callable[[], int] = boot_time_ns,
record: Callable[[str, int, Callable[[], bool]], Any] | None = None, status: RuntimeStatus | None = None):
record: Callable[[str, int, Callable[[], bool]], Any] | None = None, status: RuntimeStatus | None = None,
battery: Callable[[int, int], Any] | None = None):
self.params, self.messages = params, messages
self.mono_clock, self.boot_clock = mono_clock, boot_clock
self.record = record
self.status = status
self.battery = battery
self.battery_low_since: int | None = None
self.battery_warned = False
self.policy = SentryPolicy()
self.saved_raw: bytes | None = None
self.device_after_mono_ns = mono_clock()
@@ -42,6 +56,7 @@ class SentryRuntime:
self.last_pair_offset_ns: int | None = None
self.last_sample_ns = 0
self.recording_state = "idle"
self.power: tuple[int | None, int | None] = (None, None)
def _recording_state(self, state: str) -> None:
if state != self.recording_state:
@@ -80,6 +95,7 @@ class SentryRuntime:
def _authority(self) -> tuple[bool, bool, bool, bool, int]:
"""Return offroad, ignition-off, voltage-safe, device-fresh, current MONO ns."""
self.power = (None, None)
clocks = self._clocks()
if clocks is None:
return False, False, False, False, self.mono_clock()
@@ -106,9 +122,11 @@ class SentryRuntime:
peripheral = sm["peripheralState"]
power_saved = read_power(self.params)
cutoff_mv = effective_power(power_saved).cutoff_tenths * 100
voltage_ok = (power_saved.readable and power_saved.valid and self._fresh("peripheralState", now_mono_ns=now_ns,
now_boot_ns=boot_ns, ttl_ns=PERIPHERAL_TTL_NS) and
str(peripheral.pandaType) != "unknown" and int(peripheral.voltage) > cutoff_mv and
voltage_fresh = self._fresh("peripheralState", now_mono_ns=now_ns, now_boot_ns=boot_ns, ttl_ns=PERIPHERAL_TTL_NS) and \
str(peripheral.pandaType) != "unknown"
self.power = (round(int(peripheral.voltage) / 100) if voltage_fresh else None,
cutoff_mv // 100 if power_saved.readable and power_saved.valid else None)
voltage_ok = (power_saved.readable and power_saved.valid and voltage_fresh and int(peripheral.voltage) > cutoff_mv and
int(sm["deviceState"].carBatteryCapacityUwh) > 0)
return bool(manager_offroad and not sm["deviceState"].started), ignition_off, bool(voltage_ok), device_fresh, now_ns
except (AttributeError, KeyError, TypeError, ValueError, OverflowError, OSError, RuntimeError):
@@ -157,7 +175,10 @@ class SentryRuntime:
self.saved_raw = saved.raw
offroad, ignition_off, voltage_ok, fresh, now_ns = self._authority()
sample = self._sample(now_ns) if active else None
decision = self.policy.update(now_ns / 1e9, Inputs(active, offroad, voltage_ok, fresh, ignition_off, sample))
sm = self.messages
sample_pending = bool(not sm.updated["accelerometer"] and sm.seen["accelerometer"] and
sm.alive["accelerometer"] and sm.valid["accelerometer"])
decision = self.policy.update(now_ns / 1e9, Inputs(active, offroad, voltage_ok, fresh, ignition_off, sample, sample_pending))
expected_raw = saved.raw
if decision.event is not None and self.record is not None and expected_raw is not None and self._permitted(expected_raw):
try:
@@ -166,10 +187,32 @@ class SentryRuntime:
except Exception:
# No retry: storage may have published before its durability result failed.
self._recording_state("unavailable")
self._battery_warning(active and offroad and ignition_off and fresh, now_ns)
if self.status is not None:
self.status.publish(decision, saved.raw, self.recording_state)
self.status.publish(decision, saved.raw, self.recording_state, power=self.power)
return decision
def _battery_warning(self, parked: bool, now_ns: int) -> None:
voltage, cutoff = self.power
if not parked or voltage is None or cutoff is None:
self.battery_low_since = None
return
if voltage >= cutoff + BATTERY_RECOVERED_TENTHS:
self.battery_warned = False
if voltage > cutoff + BATTERY_WARNING_TENTHS or self.battery_warned:
self.battery_low_since = None
return
if self.battery_low_since is None:
self.battery_low_since = now_ns
if now_ns - self.battery_low_since < (BATTERY_CRITICAL_HOLD_NS if voltage <= cutoff else BATTERY_HOLD_NS):
return
self.battery_warned, self.battery_low_since = True, None
if self.battery is not None:
try:
self.battery(voltage, cutoff)
except StorageUnavailable:
cloudlog.exception("sentry motion battery warning failed")
def close(self) -> None:
for socket in getattr(self.messages, "sock", {}).values():
close = getattr(socket, "close", None)
@@ -198,7 +241,11 @@ def main() -> None:
if not permitted():
raise
return store.record(kind, stamp, permitted=permitted, images=images)
runtime = SentryRuntime(Params(), messages, record=record, status=RuntimeStatus())
def battery(voltage, cutoff):
if recent_battery_warning(store.snapshot()["events"], time.time_ns()):
return
store.record("battery", time.monotonic_ns(), permitted=lambda: True, details={"voltage": voltage, "cutoff": cutoff})
runtime = SentryRuntime(Params(), messages, record=record, status=RuntimeStatus(), battery=battery)
try:
while not stop.is_set():
started = time.monotonic()
+17 -4
View File
@@ -10,7 +10,7 @@ import uuid
from openpilot.common.hardware.hw import Paths
from openpilot.starpilot.parked_evidence import RESUME_SKEW_NS
from openpilot.starpilot.sentry_mode.preferences import SavedPreferences
from openpilot.starpilot.sentry_mode.storage import EventStore, FILE_FLAGS, StorageUnavailable, _private
from openpilot.starpilot.sentry_mode.storage import EventStore, FILE_FLAGS, StorageUnavailable, _private, valid_tenths, volts
from openpilot.starpilot.storage import starpilot_storage_root
@@ -38,6 +38,16 @@ def boot_id() -> str:
return ""
def low_voltage_reason(voltage, cutoff, fallback: str) -> str:
if not valid_tenths(voltage):
return "Battery voltage cannot be read"
if not valid_tenths(cutoff):
return f"Battery is {volts(voltage)}; the low-battery cutoff cannot be read"
if voltage <= cutoff:
return f"Battery is {volts(voltage)}; needs above {volts(cutoff)}"
return f"{fallback} (battery {volts(voltage)})"
class RuntimeStatus(EventStore):
def __init__(self, root: Path | None = None, *, clock=time.monotonic_ns, boot=boot_id, boot_clock=boot_clock):
namespace = hashlib.sha256(str(starpilot_storage_root()).encode()).hexdigest()[:24]
@@ -46,17 +56,18 @@ class RuntimeStatus(EventStore):
self.last_published = None
self.last_value = None
def publish(self, decision, source: bytes | None, recording: str) -> None:
def publish(self, decision, source: bytes | None, recording: str, power: tuple[int | None, int | None] = (None, None)) -> None:
identity = self.boot()
if not identity or decision.state not in STATES:
return
current = (decision, source, recording)
current = (decision, source, recording, power)
now = self.clock()
if current == self.last_value and self.last_published is not None and 0 <= now - self.last_published < 500_000_000:
return
value = {"state": decision.state, "remaining": decision.seconds_remaining,
"source": hashlib.sha256(source or b"").hexdigest(), "boot": identity,
"stamp": now, "bootStamp": self.boot_clock(), "recording": recording}
"stamp": now, "bootStamp": self.boot_clock(), "recording": recording,
"voltage": power[0], "cutoff": power[1]}
raw = json.dumps(value, separators=(",", ":")).encode()
temporary = f".{uuid.uuid4().hex}"
try:
@@ -106,6 +117,8 @@ class RuntimeStatus(EventStore):
if type(remaining) is not int or not 0 <= remaining <= 90:
raise ValueError("Invalid countdown")
label = f"Arming · {remaining}s"
if value["state"] == "low_voltage":
reason = low_voltage_reason(value.get("voltage"), value.get("cutoff"), reason)
if value.get("recording") in ("unavailable", "durability_unknown"):
reason += "; motion events could not be saved reliably"
return label, reason
+18 -4
View File
@@ -20,10 +20,19 @@ MAX_EVENTS = 512
MAX_EVENT_BYTES = 512
ID = re.compile(r"[0-9a-f]{32}\Z")
FIELDS = {"version", "eventId", "sessionId", "kind", "monoTimeNs", "wallTimeNs"}
BATTERY_FIELDS = {"voltage", "cutoff"}
DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
def valid_tenths(value: object) -> bool:
return type(value) is int and 0 < value < 1000
def volts(tenths: int) -> str:
return f"{tenths / 10:.1f} V"
class StorageUnavailable(Exception):
"""No event was published by this call; recording cannot continue."""
@@ -46,7 +55,11 @@ def _valid(value: object, event_id: str) -> bool:
if any(type(key) is not str for key in value):
return False
fields = {key: item for key, item in value.items() if type(key) is str}
if set(fields) not in (FIELDS, FIELDS | {"simulated"}) or ("simulated" in fields and fields["simulated"] is not True) or ID.fullmatch(event_id) is None:
battery = fields.get("kind") == "battery"
base = FIELDS | BATTERY_FIELDS if battery else FIELDS
if set(fields) not in (base, base | {"simulated"}) or ("simulated" in fields and fields["simulated"] is not True) or ID.fullmatch(event_id) is None:
return False
if battery and not all(valid_tenths(fields[key]) for key in BATTERY_FIELDS):
return False
version = fields["version"]
session_id = fields["sessionId"]
@@ -55,7 +68,7 @@ def _valid(value: object, event_id: str) -> bool:
return (type(version) is int and version == 1 and
fields["eventId"] == event_id and
type(session_id) is str and ID.fullmatch(session_id) is not None and
fields["kind"] in ("warning", "alarm", "selfie") and
fields["kind"] in ("warning", "alarm", "selfie", "battery") and
type(mono_time) is int and 0 <= mono_time < 2**63 and
type(wall_time) is int and 0 <= wall_time < 2**63)
@@ -117,10 +130,11 @@ class EventStore:
finally:
os.close(fd)
def record(self, kind: str, mono_time_ns: int, *, permitted: Callable[[], bool], images: dict | None = None, simulated: bool = False) -> RecordReceipt:
def record(self, kind: str, mono_time_ns: int, *, permitted: Callable[[], bool], images: dict | None = None, simulated: bool = False,
details: dict | None = None) -> RecordReceipt:
event_id = uuid.uuid4().hex
value = {"version": 1, "eventId": event_id, "sessionId": self.session_id, "kind": kind,
"monoTimeNs": mono_time_ns, "wallTimeNs": time.time_ns()}
"monoTimeNs": mono_time_ns, "wallTimeNs": time.time_ns(), **(details or {})}
if simulated:
value["simulated"] = True
if not _valid(value, event_id):
@@ -52,6 +52,11 @@ class SentryActionsTest(unittest.TestCase):
self.assertIn("Not armed", denied.subtitle)
self.assertNotIn("Monitoring motion", denied.subtitle)
self.assertFalse(any(row.available for row in denied.rows))
status.runtime_snapshot.return_value = ("Waiting for monitor", "The monitor has not responded recently")
onroad = SentryOwner(self.params, lambda: False, status=status).snapshot()
self.assertIn("Not armed: The car is on", onroad.subtitle)
parked = SentryOwner(self.params, lambda: True, status=status).snapshot()
self.assertIn("Waiting for monitor", parked.subtitle)
def test_invalid_source_requires_explicit_reset_and_exact_bytes(self):
owner = SentryOwner(self.params, lambda: True)
@@ -383,3 +383,43 @@ def test_ios_bundle_id_can_subscribe(tmp_path):
assert snapshot['appSubscriptions'][0]['appId'] == 'link.galaxy.bluetooth.natehuby76'
finally:
owner.close()
def test_battery_job_survives_restart_and_reaches_every_channel_with_voltage(tmp_path, monkeypatch):
clock = [100.]
calls = []
events = [{'eventId': 'c' * 32, 'kind': 'battery', 'wallTimeNs': int(101e9), 'voltage': 120, 'cutoff': 118}]
root = tmp_path / 'notifications'
owner = NotificationOwner(root, store(), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 503)
configure(owner, 'ntfy')
owner.store = store(events)
clock[0] = 101.
owner.tick()
owner.close()
owner = NotificationOwner(root, store(events), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 204)
try:
assert owner.available
job = calls[0][3]
assert job['kind'] == 'battery' and (job['voltage'], job['cutoff']) == (120, 118)
finally:
owner.close()
posts = []
response = SimpleNamespace(status_code=204, close=lambda: None)
monkeypatch.setattr('openpilot.starpilot.sentry_mode.notifications.requests.post', lambda *args, **kwargs: posts.append(kwargs) or response)
owner = NotificationOwner(tmp_path / 'sending', SimpleNamespace(snapshot=lambda: {'events': []}, images=lambda _: []))
try:
text = 'Car battery is low (12.0 V). The device and Sentry turn off at 11.8 V.'
job = {'eventId': 'c' * 32, 'kind': 'battery', 'created': 1000.0, 'voltage': 120, 'cutoff': 118}
for name in ('ntfy', 'discord', 'webhook'):
owner._send(name, {'token': '', 'url': 'https://example.test'}, None, job, 'key', '')
assert posts[0]['data'] == text.encode() and posts[0]['headers']['Tags'] == 'battery,car'
assert posts[1]['json']['content'] == 'StarPilot Sentry Mode: ' + text
assert json.loads(posts[2]['data']['event'])['voltage'] == 120
owner._send('appPush', {'url': 'https://relay.example'}, {'token': 't', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'}, job, 'key', '')
assert posts[3]['json']['kind'] == 'battery' and posts[3]['json']['detail'] == text
owner._send('appPush', {'url': 'https://relay.example'}, {'token': 't', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'},
{**job, 'kind': 'warning'}, 'key', '')
assert 'detail' not in posts[4]['json']
finally:
owner.close()
@@ -74,6 +74,22 @@ def test_missing_sensor_during_arming_and_after_armed_requires_full_rearm():
assert policy.update(180.5, active(motion(180.5, 9.8))).state == "arming"
def test_pending_messages_preserve_countdown_but_never_count_as_motion():
policy = SentryPolicy()
pending = Inputs(True, True, True, True, True, sample_pending=True)
assert policy.update(0.0, pending).state == "sensor_unavailable"
for tenth in range(1, 905):
now = tenth / 10
decision = policy.update(now, active(motion(now, 9.8)) if tenth % 2 else pending)
assert decision.state == ("arming" if now < 90.1 else "armed")
assert decision.event is None
assert policy.trigger_count == 0
# The previous sample is 90.3; repeated polls cannot renew its freshness.
assert policy.update(90.7, pending).state == "armed"
assert policy.update(90.9, pending).state == "sensor_unavailable"
assert policy.update(91.0, active(motion(91.0, 9.8))).seconds_remaining == 90
def test_disabled_onroad_low_voltage_and_lost_device_rearm_for_full_90_seconds():
for stopped, expected in ((Inputs(False, True, True, True, True), "disabled"),
(Inputs(True, False, True, True, True), "disabled_onroad"),
@@ -0,0 +1,210 @@
from openpilot.starpilot.sentry_mode import relay_enrollment as relay
def test_enrollment_recovers_saved_candidate_without_redeeming_twice():
state = relay.begin('https://relay.example/', 'a' * 64)
original = state['secret']
calls = []
def send(state, method, path, data=None):
calls.append(path)
return 200
relay.step(state, [], 100, send)
assert calls == ['installation']
assert state['state'] == 'enrolled'
assert state['secret'] == original
assert state['invitation'] == ''
relay.validate(state)
def test_offline_revoke_retries_and_retains_credential_until_acknowledged():
state = relay.begin('https://relay.example', 'a' * 64)
state['state'] = 'revoking'
relay.step(state, [], 100, lambda *args: 503)
assert state['state'] == 'revoking' and state['secret']
assert state['next'] > 100
relay.step(state, [], state['next'], lambda *args: 200)
assert state['state'] == 'revoked' and not state['secret']
def test_subscription_reconciliation_removes_before_adding():
state = relay.begin('https://relay.example', 'a' * 64)
state.update(state='enrolled', invitation='', registered={'0123456789ab': 'b' * 64})
sub = {'id': 'abcdef012345', 'token': 'phone', 'appId': 'com.starpilot.galaxy'}
calls = []
def send(state, method, path, data=None):
calls.append((method, path))
return 200
relay.step(state, [sub], 100, send)
relay.step(state, [sub], 101, send)
assert calls == [('DELETE', 'subscriptions/0123456789ab'), ('PUT', 'subscriptions/abcdef012345')]
assert state['registered'] == {sub['id']: relay.fingerprint(sub)}
def test_rejected_destination_is_skipped_without_blocking_other_phones():
state = relay.begin('https://relay.example', 'a' * 64)
state.update(state='enrolled', invitation='')
iphone = {'id': 'aaaaaaaaaaaa', 'token': 'apns.production.' + 'ab' * 32, 'appId': 'link.galaxy.bluetooth.natehuby76'}
android = {'id': 'bbbbbbbbbbbb', 'token': 'fcm', 'appId': 'com.starpilot.galaxy'}
calls = []
def send(state, method, path, data=None):
calls.append(path)
return 400 if data and data['appId'] == iphone['appId'] else 200
for now in range(100, 104):
relay.step(state, [iphone, android], now, send)
assert state['state'] == 'enrolled'
assert calls == ['subscriptions/' + iphone['id'], 'subscriptions/' + android['id']]
assert state['registered'] == {android['id']: relay.fingerprint(android)}
assert state['rejected'] == {iphone['id']: relay.fingerprint(iphone)}
relay.validate(state)
relay.step(state, [android], 104, send)
assert state['rejected'] == {}
def test_resubscribing_retries_a_rejected_phone_and_old_state_loads(tmp_path):
import json
from types import SimpleNamespace
from openpilot.starpilot.sentry_mode.notifications import NotificationOwner
root = tmp_path / 'notifications'
store = SimpleNamespace(snapshot=lambda: {'events': []})
owner = NotificationOwner(root, store)
token = 'apns.production.' + 'ab' * 32
owner.action({'action': 'appSubscribe', 'token': token, 'appId': 'link.galaxy.bluetooth.natehuby76', 'label': 'iPhone'})
sub_id = owner.state['appSubscriptions'][0]['id']
owner.state['relayEnrollment']['rejected'] = {sub_id: relay.fingerprint(owner.state['appSubscriptions'][0])}
assert owner.snapshot()['appSubscriptions'][0]['lastResult'] == 'Relay does not accept this app'
owner.action({'action': 'appSubscribe', 'token': token, 'appId': 'link.galaxy.bluetooth.natehuby76', 'label': 'iPhone'})
assert owner.state['relayEnrollment']['rejected'] == {}
del owner.state['relayEnrollment']['rejected']
saved = json.dumps(owner.state)
owner.close()
(root / 'state.json').write_text(saved)
owner = NotificationOwner(root, store)
assert owner.state['relayEnrollment']['rejected'] == {}
owner.close()
def test_owner_persists_candidate_redacts_secrets_and_recovers_restart(tmp_path, monkeypatch):
import json
from types import SimpleNamespace
from openpilot.starpilot.sentry_mode.notifications import NotificationOwner
root = tmp_path / 'notifications'
store = SimpleNamespace(snapshot=lambda: {'events': []})
owner = NotificationOwner(root, store)
result = owner.action({'action': 'relayEnroll', 'url': 'https://relay.example', 'invitation': 'a' * 64, 'confirmed': True})
assert result['relayEnrollment']['state'] == 'enrolling'
saved = owner.state['relayEnrollment'].copy()
assert saved['secret'] not in json.dumps(result) and 'a' * 64 not in json.dumps(result)
owner.close()
owner = NotificationOwner(root, store)
assert owner.state['relayEnrollment'] == saved
original_step = relay.step
monkeypatch.setattr(relay, 'step', lambda state, subs, now: original_step(state, subs, now, lambda *args: 200))
owner.tick()
assert owner.snapshot()['appPushAvailable']
owner.action({'action': 'relayRevoke', 'confirmed': True})
assert not owner.snapshot()['appPushAvailable']
owner.tick()
assert owner.snapshot()['relayEnrollment']['state'] == 'revoked'
owner.close()
def test_owner_rejects_silent_enrollment_and_host_changes(tmp_path):
import pytest
from types import SimpleNamespace
from openpilot.starpilot.sentry_mode.notifications import NotificationOwner
owner = NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []}))
try:
with pytest.raises(ValueError):
owner.action({'action': 'relayEnroll', 'url': 'https://relay.example', 'invitation': 'a' * 64, 'confirmed': False})
owner.action({'action': 'relayEnroll', 'url': 'https://relay.example', 'invitation': 'a' * 64, 'confirmed': True})
with pytest.raises(ValueError):
owner.action({'action': 'configure', 'channel': 'appPush', 'enabled': True, 'url': 'https://other.example', 'token': ''})
finally:
owner.close()
def test_owner_requires_resolving_action_required_relay_before_enrollment(tmp_path):
import pytest
from types import SimpleNamespace
from openpilot.starpilot.sentry_mode.notifications import NotificationOwner
owner = NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []}))
try:
owner.state['relayEnrollment'] = relay.begin('https://relay.example', 'a' * 64)
owner.state['relayEnrollment'].update(state='actionRequired', invitation='')
with pytest.raises(ValueError):
owner.action({'action': 'relayEnroll', 'confirmed': True})
owner.action({'action': 'relayRevoke', 'confirmed': True})
assert owner.state['relayEnrollment']['state'] == 'revoking'
finally:
owner.close()
def test_rotation_recovers_after_old_credential_overlap_expired():
state = relay.begin('https://relay.example', 'a' * 64)
state.update(state='rotating', invitation='', candidate='b' * 64)
calls = []
def send(current, method, path, data=None):
calls.append((current['secret'], method, path))
return 200 if current['secret'] == 'b' * 64 else 401
relay.step(state, [], 100, send)
assert state['state'] == 'confirming' and state['secret'] == 'b' * 64
assert calls == [('b' * 64, 'GET', 'installation')]
relay.step(state, [], 101, send)
assert state['state'] == 'enrolled'
def test_v2_sender_binds_bearer_to_host_and_disables_redirects(tmp_path, monkeypatch):
from types import SimpleNamespace
from openpilot.starpilot.sentry_mode.notifications import NotificationOwner
from openpilot.starpilot.sentry_mode import notifications
owner = NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []}))
state = relay.begin('https://relay.example', 'a' * 64)
state.update(state='enrolled', invitation='')
owner.state['relayEnrollment'] = state
calls = []
monkeypatch.setattr(notifications.requests, 'post', lambda url, **kwargs: calls.append((url, kwargs)) or SimpleNamespace(status_code=200, close=lambda: None))
sub = {'token': 'phone', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'}
job = {'kind': 'test', 'eventId': 'b' * 32, 'created': 100}
try:
assert owner._send('appPush', {'url': state['url']}, sub, job, 'key', '') == 200
assert calls[0][0] == 'https://relay.example/v2/sentry'
assert calls[0][1]['headers']['Authorization'] == 'Bearer ' + state['id'] + '.' + state['secret']
assert calls[0][1]['allow_redirects'] is False
assert owner._send('appPush', {'url': 'https://elsewhere.example'}, sub, job, 'key', '') == 401
assert len(calls) == 1
state['state'] = 'revoking'
assert owner._send('appPush', {'url': state['url']}, sub, job, 'key', '') == 401
assert len(calls) == 1
finally:
owner.close()
def test_one_tap_enrollment_uses_release_host_and_preserves_candidate_on_retry(tmp_path, monkeypatch):
from types import SimpleNamespace
from openpilot.starpilot.sentry_mode import notifications
owner = notifications.NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []}))
monkeypatch.setattr(notifications, 'DEFAULT_RELAY_URL', 'https://hosted.example')
try:
status = owner.action({'action': 'relayEnroll', 'confirmed': True})
saved = owner.state['relayEnrollment'].copy()
assert status['relayEnrollment']['automaticConfigured']
assert saved['url'] == 'https://hosted.example' and saved['invitation'] == ''
owner.action({'action': 'relayEnroll', 'confirmed': True})
assert owner.state['relayEnrollment'] == saved
finally:
owner.close()
def test_unconfigured_release_does_not_claim_automatic_alerts(tmp_path, monkeypatch):
import pytest
from types import SimpleNamespace
from openpilot.starpilot.sentry_mode import notifications
monkeypatch.setattr(notifications, 'DEFAULT_RELAY_URL', '')
owner = notifications.NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []}))
try:
assert not owner.snapshot()['relayEnrollment']['automaticConfigured']
with pytest.raises(ValueError, match='not configured'):
owner.action({'action': 'relayEnroll', 'confirmed': True})
assert owner.state['relayEnrollment']['state'] == 'unenrolled'
finally:
owner.close()
@@ -9,6 +9,7 @@ from unittest.mock import Mock, patch
from openpilot.cereal import log, messaging
from openpilot.common.params import Params
from openpilot.starpilot.power.offroad_preferences import read_saved as read_power, effective as effective_power
from openpilot.starpilot.sentry_mode.preferences import KEY, Preferences, encode
from openpilot.starpilot.sentry_mode.runtime import SentryRuntime
from openpilot.starpilot.sentry_mode import runtime as runtime_module
@@ -35,6 +36,7 @@ class Messages:
self.logMonoTime = dict.fromkeys(names, 0)
self.recv_time = dict.fromkeys(names, 0.0)
self.sock = {}
self.voltage = 12_500
def __getitem__(self, name):
return self.messages[name]
@@ -50,7 +52,7 @@ class Messages:
self.updated[name] = True
self.messages["deviceState"] = NS(started=False, carBatteryCapacityUwh=1_000_000)
self.messages["pandaStates"] = [NS(ignitionLine=False, ignitionCan=False, pandaType="uno")]
self.messages["peripheralState"] = NS(pandaType="uno", voltage=12_500)
self.messages["peripheralState"] = NS(pandaType="uno", voltage=self.voltage)
# A real Cap'n Proto event is serialized and decoded before projection.
event = messaging.new_message("accelerometer", valid=True)
event.accelerometer.timestamp = sample_stamp if sample_stamp is not None else now - 20_000_000
@@ -68,6 +70,7 @@ class TestRuntime(unittest.TestCase):
self.addCleanup(self.environment.stop)
self.params = Params()
self.params.put_bool("IsOffroad", True, block=True)
self.cutoff = effective_power(read_power(self.params)).cutoff_tenths
Path(self.params.get_param_path(KEY)).write_bytes(encode(Preferences(True)))
self.clock = Clock()
self.messages = Messages(self.clock)
@@ -87,9 +90,10 @@ class TestRuntime(unittest.TestCase):
status = Mock()
self.runtime.status = status
decision = self.tick()
status.publish.assert_called_once_with(decision, encode(Preferences(True)), "idle")
status.publish.assert_called_once_with(decision, encode(Preferences(True)), "idle", power=(125, self.cutoff))
self.assertEqual(decision.state, "arming")
self.messages.updated["accelerometer"] = False
self.messages.valid["accelerometer"] = False
self.clock.mono += 100_000_000
decision = self.runtime.tick()
self.assertEqual(decision.state, "sensor_unavailable")
@@ -101,10 +105,77 @@ class TestRuntime(unittest.TestCase):
self.assertIsNotNone(self.runtime.policy.last_sample_time)
self.messages.updated["accelerometer"] = False
self.clock.mono += 100_000_000
self.assertEqual(self.runtime.tick().state, "arming")
self.clock.mono += 400_000_000
self.messages.set_sources()
self.messages.updated["accelerometer"] = False
self.assertEqual(self.runtime.tick().state, "sensor_unavailable")
self.assertIsNone(self.runtime.policy.arm_started)
self.assertEqual(self.events, [])
def test_low_battery_warns_once_after_holding_near_cutoff_and_rearms_after_recovery(self):
warnings = []
self.runtime.battery = lambda voltage, cutoff: warnings.append((voltage, cutoff))
cutoff = self.cutoff
self.messages.voltage = (cutoff + 2) * 100
for _ in range(599):
self.tick()
self.assertEqual(warnings, [])
for _ in range(10):
self.tick()
self.assertEqual(warnings, [(cutoff + 2, cutoff)])
for _ in range(700):
self.tick()
self.assertEqual(len(warnings), 1)
self.messages.voltage = (cutoff + 4) * 100
self.tick()
self.messages.voltage = (cutoff - 1) * 100
for _ in range(105):
self.tick()
self.assertEqual(warnings[-1], (cutoff - 1, cutoff))
self.assertEqual(len(warnings), 2)
def test_brief_voltage_dip_does_not_warn(self):
warnings = []
self.runtime.battery = lambda voltage, cutoff: warnings.append((voltage, cutoff))
cutoff = self.cutoff
for _ in range(20):
self.messages.voltage = (cutoff + 1) * 100
for _ in range(300):
self.tick()
self.messages.voltage = (cutoff + 3) * 100
self.tick()
self.assertEqual(warnings, [])
def test_battery_warning_storage_failure_is_logged_but_bugs_propagate(self):
self.messages.voltage = (self.cutoff - 1) * 100
self.runtime.battery = Mock(side_effect=runtime_module.StorageUnavailable("Event storage is full"))
for _ in range(101):
self.tick()
self.runtime.battery.assert_called_once()
self.runtime.battery_warned = False
self.runtime.battery = Mock(side_effect=ValueError("Invalid Sentry event"))
with self.assertRaises(ValueError):
for _ in range(101):
self.tick()
def test_recent_battery_warning_ignores_old_other_and_future_events(self):
hour = runtime_module.BATTERY_REPEAT_NS
now = 10 * hour
self.assertTrue(runtime_module.recent_battery_warning([{"kind": "battery", "wallTimeNs": now - hour}], now))
self.assertFalse(runtime_module.recent_battery_warning([{"kind": "battery", "wallTimeNs": now - hour - 1}], now))
self.assertFalse(runtime_module.recent_battery_warning([{"kind": "warning", "wallTimeNs": now}], now))
self.assertFalse(runtime_module.recent_battery_warning([{"kind": "battery", "wallTimeNs": now + 1}], now))
def test_arming_completes_with_brief_gaps_between_sensor_messages(self):
for _ in range(451):
self.tick()
self.messages.updated["accelerometer"] = False
self.clock.mono += 100_000_000
decision = self.runtime.tick()
self.assertIn(decision.state, ("arming", "armed"))
self.assertEqual(decision.state, "armed")
self.assertEqual(self.events, [])
def test_serialized_device_panda_peripheral_use_actual_clock_domains(self):
self.clock.mono += 100_000_000
@@ -6,7 +6,7 @@ from unittest.mock import patch
from openpilot.starpilot.sentry_mode.policy import Decision
from openpilot.starpilot.sentry_mode.preferences import Preferences, SavedPreferences, encode
from openpilot.starpilot.sentry_mode.status import RuntimeStatus, TTL_NS
from openpilot.starpilot.sentry_mode.status import RuntimeStatus, TTL_NS, STATES
class RuntimeStatusTest(unittest.TestCase):
@@ -67,3 +67,13 @@ class RuntimeStatusTest(unittest.TestCase):
target.write_bytes(original)
destination.symlink_to(target)
self.assertEqual(self.status.runtime_snapshot(self.saved)[0], "Waiting for monitor")
def test_low_voltage_reports_the_battery_reading(self):
self.status.publish(Decision("low_voltage"), self.saved.raw, "idle", power=(118, 120))
self.assertEqual(self.status.runtime_snapshot(self.saved), ("Not armed", "Battery is 11.8 V; needs above 12.0 V"))
self.status.publish(Decision("low_voltage"), self.saved.raw, "idle", power=(None, 120))
self.assertEqual(self.status.runtime_snapshot(self.saved)[1], "Battery voltage cannot be read")
self.status.publish(Decision("low_voltage"), self.saved.raw, "idle", power=(125, 120))
self.assertEqual(self.status.runtime_snapshot(self.saved)[1], f"{STATES['low_voltage'][1]} (battery 12.5 V)")
self.status.publish(Decision("low_voltage"), self.saved.raw, "idle", power=(125, None))
self.assertEqual(self.status.runtime_snapshot(self.saved)[1], "Battery is 12.5 V; the low-battery cutoff cannot be read")
+23 -4
View File
@@ -24,6 +24,8 @@ from openpilot.starpilot.ui.settings_geometry import (
LABEL_SIZE = 50
DETAIL_SIZE = 35
SUBTITLE_MIN_SIZE = 24
SUBTITLE_TONES = {"ok": rl.Color(70, 200, 110, 255), "warn": rl.Color(251, 191, 36, 255)}
PAGE_TRANSITION_TIME = 0.18
EDGE_ACCENT = rl.color_lerp(PANEL_BG, ACCENT, 0.4)
EDGE_FADE_HEIGHT = 96
@@ -111,10 +113,7 @@ class FeatureSettingsView:
draw_settings_header(self.fonts, state.sidebar_expanded, state.title, state.parent_title, back=True)
finally:
clip.end_scissor_mode()
subtitle = self._elide(state.subtitle, FontRole.NORMAL, DETAIL_SIZE, 2094 - left - 55)
if subtitle:
top, _ = self.fonts.vertical_ink(subtitle, FontRole.NORMAL, DETAIL_SIZE)
self.fonts.draw(subtitle, FontRole.NORMAL, DETAIL_SIZE, left + 55, 116 - top, TEXT_SECONDARY)
self._draw_subtitle(state, left)
if len(state.rows) == 1 and (buttons := value_buttons(state, state.rows[0], row_top)):
self._page_frame(state, 0, False)
self._value_row(state, state.rows[0], rl.Rectangle(*value_editor_rect(state)), buttons, pressed)
@@ -342,5 +341,25 @@ class FeatureSettingsView:
self.fonts.draw(text, role, size, rect.x + (rect.width - self.fonts.measure(text, role, size).width) / 2,
rect.y + rect.height / 2 - (top + bottom) / 2, color)
def _draw_subtitle(self, state: FeatureSettingsState, left: float) -> None:
width = 2094 - left - 55
size = DETAIL_SIZE
accent = state.subtitle_accent if state.subtitle.startswith(state.subtitle_accent) else ""
if accent:
# One row: shrink the line to fit instead of cutting off the notification hint.
while size > SUBTITLE_MIN_SIZE and self.fonts.measure(state.subtitle, FontRole.NORMAL, size).width > width:
size -= 1
text = self._elide(state.subtitle, FontRole.NORMAL, size, width)
if not text:
return
top, _ = self.fonts.vertical_ink(text, FontRole.NORMAL, size)
if accent and text.startswith(accent):
color = SUBTITLE_TONES.get(state.subtitle_tone, TEXT_SECONDARY)
self.fonts.draw(accent, FontRole.NORMAL, size, left + 55, 116 - top, color)
offset = self.fonts.measure(accent, FontRole.NORMAL, size).width
self.fonts.draw(text[len(accent):], FontRole.NORMAL, size, left + 55 + offset, 116 - top, TEXT_SECONDARY)
else:
self.fonts.draw(text, FontRole.NORMAL, size, left + 55, 116 - top, TEXT_SECONDARY)
def _elide(self, text: str, role: FontRole, size: float, width: float) -> str:
return elide_text(self.fonts, text, role, size, width)
@@ -125,6 +125,8 @@ class FeatureSettingsState:
parent_title: str = "StarPilot"
editor: bool = False
save_hint: str = "Changes save automatically"
subtitle_accent: str = ""
subtitle_tone: str = ""
def feature_row_top(state: FeatureSettingsState) -> int:
+99 -3
View File
@@ -31,6 +31,88 @@ def _external_shader(fragment: str) -> str:
return result.replace(yuv, "vec3 rgb = pow(texture(texture0, uv).rgb, vec3(1.0/1.28));")
# Gray-world white balance for the cabin camera's magenta cast.
WB_STRENGTH = 1.0
WB_GAIN_MIN, WB_GAIN_MAX = 0.6, 1.6
WB_SAMPLES = 16 # 16x16 grid of NV12 samples per crop
WB_INTERVAL = 0.25 # seconds between samples per side
WB_SMOOTHING = 0.35 # EMA weight of a new sample
NEUTRAL_GAIN = (1.0, 1.0, 1.0)
WB_SATURATION = 0.75
WB_PARAM = "CabinWhiteBalance"
WB_PARAM_INTERVAL = 1.0
def crop_white_balance(frame, crop) -> tuple[float, float, float] | None:
"""Pre-gamma RGB gains that neutralize the crop's average color, or None if unreadable."""
try:
width, height, stride, uv_offset = int(frame.width), int(frame.height), int(frame.stride), int(frame.uv_offset)
raw = np.frombuffer(frame.data, dtype=np.uint8)
except (AttributeError, TypeError, ValueError):
return None
if width <= 0 or height <= 0 or stride < width or uv_offset < stride * height or len(raw) < uv_offset + stride * (height // 2):
return None
steps = (np.arange(WB_SAMPLES) + 0.5) / WB_SAMPLES * crop.size
xs = np.clip((crop.x + steps).astype(np.int64) // 2, 0, width // 2 - 1)
ys = np.clip((crop.y + steps).astype(np.int64) // 2, 0, height // 2 - 1)
luma = raw[(ys[:, None] * 2) * stride + xs[None, :] * 2].astype(np.float32).mean() / 255.0
uv_index = uv_offset + ys[:, None] * stride + xs[None, :] * 2
u = raw[uv_index].astype(np.float32).mean() / 255.0 - 0.5
v = raw[uv_index + 1].astype(np.float32).mean() / 255.0 - 0.5
if luma < 0.02:
return None
mean_rgb = np.array([luma + 1.402 * v, luma - 0.344 * u - 0.714 * v, luma + 1.772 * u])
gains = luma / np.maximum(mean_rgb, 1e-3)
gains = np.clip(1.0 + WB_STRENGTH * (gains - 1.0), WB_GAIN_MIN, WB_GAIN_MAX)
return float(gains[0]), float(gains[1]), float(gains[2])
class _WhiteBalance:
"""Per-side smoothed gains, resampled a few times a second from the CPU-visible frame."""
def __init__(self):
self.gains: dict[str, np.ndarray] = {}
self._sampled_at: dict[str, float] = {}
def reset(self, side: str | None = None) -> None:
if side is None:
self.gains.clear()
self._sampled_at.clear()
else:
self.gains.pop(side, None)
self._sampled_at.pop(side, None)
def update(self, side: str, frame, crop, now: float) -> tuple[float, float, float]:
last = self._sampled_at.get(side)
if last is None or now - last >= WB_INTERVAL or now < last:
self._sampled_at[side] = now
sample = crop_white_balance(frame, crop)
if sample is not None:
target = np.array(sample, dtype=np.float32)
current = self.gains.get(side)
self.gains[side] = target if current is None else current + WB_SMOOTHING * (target - current)
gains = self.gains.get(side)
return NEUTRAL_GAIN if gains is None else (float(gains[0]), float(gains[1]), float(gains[2]))
class _WhiteBalanceSwitch:
"""The saved Galaxy choice, re-read about once a second."""
def __init__(self):
self._enabled = True
self._read_at: float | None = None
self._params = None
def enabled(self, now: float) -> bool:
if self._read_at is None or now - self._read_at >= WB_PARAM_INTERVAL or now < self._read_at:
self._read_at = now
if self._params is None:
from openpilot.common.params import Params
self._params = Params()
self._enabled = self._params.get_bool(WB_PARAM)
return self._enabled
class PiPRenderer:
"""One renderer; close it on onroad exit or when the owning shell closes."""
@@ -50,6 +132,8 @@ class PiPRenderer:
self._stream_generation: int | None = None
self._active_sides: set[str] = set()
self._activated_at: dict[str, float] = {}
self._white_balance = _WhiteBalance()
self._white_balance_switch = _WhiteBalanceSwitch()
def _shader(self) -> rl.Shader:
if self.shader is None:
@@ -91,6 +175,7 @@ class PiPRenderer:
self._stream_generation = None
self._active_sides.clear()
self._activated_at.clear()
self._white_balance.reset()
def _texture(self, frame) -> rl.Texture | None:
size = (int(frame.width), int(frame.height), int(frame.stride))
@@ -162,6 +247,7 @@ class PiPRenderer:
return "inactive"
for side in set(sides) - self._active_sides:
self._activated_at[side] = observed_now
self._white_balance.reset(side) # lighting may have changed since this side was last shown
self._active_sides = set(sides)
if frame is None:
self._clear_images()
@@ -189,8 +275,11 @@ class PiPRenderer:
if placements is not None:
rect = placements[side]
warning = (signals.vasm_left or signals.left_blindspot) if side == "left" else (signals.vasm_right or signals.right_blindspot)
def draw(rect=rect, crop=crop, warning=warning):
self._draw(shader, texture, frame, rect, crop.x, crop.y, crop.size, invert, warning=warning)
balanced = self._white_balance_switch.enabled(observed_now)
gain = self._white_balance.update(side, frame, crop, observed_now) if balanced else NEUTRAL_GAIN
def draw(rect=rect, crop=crop, warning=warning, gain=gain, balanced=balanced):
self._draw(shader, texture, frame, rect, crop.x, crop.y, crop.size, invert, warning=warning, gain=gain,
saturation=WB_SATURATION if balanced else 1.0)
if submit is None:
draw()
else:
@@ -198,7 +287,8 @@ class PiPRenderer:
return "rendered"
def _draw(self, shader: rl.Shader, texture: rl.Texture, frame, rect: Rect,
crop_x: float, crop_y: float, crop_size: float, invert: bool, *, warning: bool = False) -> None:
crop_x: float, crop_y: float, crop_size: float, invert: bool, *, warning: bool = False,
gain: tuple[float, float, float] = NEUTRAL_GAIN, saturation: float = WB_SATURATION) -> None:
texture_width = frame.width if COMMA_HARDWARE else frame.stride
crop_min = rl.Vector2(crop_x / texture_width, crop_y / frame.height)
crop_extent = rl.Vector2(crop_size / texture_width, crop_size / frame.height)
@@ -215,6 +305,12 @@ class PiPRenderer:
flip = np.array([1 if invert else 0], dtype=np.int32)
rl.set_shader_value(shader, rl.get_shader_location(shader, "uFlipX"), flip,
rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
# The comma samples gamma-adjusted RGB, so the gain is raised to 1/1.28.
shader_gain = np.power(np.array(gain, dtype=np.float32), 1.0 / 1.28) if COMMA_HARDWARE else np.array(gain, dtype=np.float32)
rl.set_shader_value(shader, rl.get_shader_location(shader, "uGain"), shader_gain,
rl.ShaderUniformDataType.SHADER_UNIFORM_VEC3)
rl.set_shader_value(shader, rl.get_shader_location(shader, "uSaturation"), np.array([saturation], dtype=np.float32),
rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT)
if self.shape == "curved":
panel = rl.Vector2(rect.width, rect.height)
rl.set_shader_value(shader, rl.get_shader_location(shader, "uRectSize"), panel,
+10
View File
@@ -26,6 +26,8 @@ uniform sampler2D texture1;
uniform vec2 uCropMin;
uniform vec2 uCropSize;
uniform int uFlipX;
uniform vec3 uGain;
uniform float uSaturation;
out vec4 fragColor;
const float BUBBLE_REFRACTION = 0.016;
@@ -60,6 +62,9 @@ void main() {
float y = texture(texture0, uv).r;
vec2 c = texture(texture1, uv).ra - 0.5;
vec3 rgb = vec3(y + 1.402 * c.y, y - 0.344 * c.x - 0.714 * c.y, y + 1.772 * c.x);
// Cancel the cabin camera's magenta cast; leave clipped highlights white.
rgb = mix(rgb * uGain, rgb, smoothstep(0.95, 1.0, max(rgb.r, max(rgb.g, rgb.b))));
rgb = mix(vec3(dot(rgb, vec3(0.299, 0.587, 0.114))), rgb, uSaturation);
// Keep the interior gradient and use cheap analytic lighting instead of specular math.
float edgeShade = smoothstep(0.22, 0.98, radius);
@@ -84,6 +89,8 @@ uniform sampler2D texture1;
uniform vec2 uCropMin;
uniform vec2 uCropSize;
uniform int uFlipX;
uniform vec3 uGain;
uniform float uSaturation;
uniform vec2 uRectSize;
out vec4 fragColor;
@@ -129,6 +136,9 @@ void main() {
float y = texture(texture0, uv).r;
vec2 c = texture(texture1, uv).ra - 0.5;
vec3 rgb = vec3(y + 1.402 * c.y, y - 0.344 * c.x - 0.714 * c.y, y + 1.772 * c.x);
// Cancel the cabin camera's magenta cast; leave clipped highlights white.
rgb = mix(rgb * uGain, rgb, smoothstep(0.95, 1.0, max(rgb.r, max(rgb.g, rgb.b))));
rgb = mix(vec3(dot(rgb, vec3(0.299, 0.587, 0.114))), rgb, uSaturation);
// Let the rim blend into the camera image and the UI underneath it.
float edgeShade = smoothstep(-radius, 0.0, dist);
+7 -3
View File
@@ -5,7 +5,7 @@ from dataclasses import replace
from openpilot.starpilot.sentry_mode.actions import WriteResult, commit
from openpilot.starpilot.sentry_mode.policy import Settings
from openpilot.starpilot.sentry_mode.status import RuntimeStatus
from openpilot.starpilot.sentry_mode.status import STATES, RuntimeStatus
from openpilot.starpilot.sentry_mode.preferences import Preferences, encode, read_preferences
from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsRequest, FeatureSettingsState
@@ -29,7 +29,10 @@ class SentryOwner:
label, reason = self.status.runtime_snapshot(saved)
if not parked and (label == "Monitoring motion" or label.startswith("Arming")):
label, reason = "Not armed", "Waiting for confirmation that the car is parked"
monitor = f"{label}: {reason}. Motion events can include camera snapshots. Notification settings are in Cameras & Monitoring."
elif not parked and label == "Waiting for monitor":
label, reason = STATES["disabled_onroad"]
status = f"{label}: {reason}."
monitor = f"{status} Motion events can include camera snapshots. Notification settings are in Cameras & Monitoring."
rows: list[FeatureRow] = []
if not saved.readable:
rows.append(FeatureRow("", "Saved motion settings", "Unavailable",
@@ -54,7 +57,8 @@ class SentryOwner:
defaults = {ENABLED: 'Off', SENSITIVITY: f'{initial.settings.sensitivity:.3f}', WARNING: f'{initial.settings.warning_time_seconds:.1f}'}
rows = [replace(row, default_value=defaults[row.key]) if row.key in defaults else row for row in rows]
return FeatureSettingsState(page="sentry", title="Sentry motion settings",
subtitle=monitor,
subtitle=monitor, subtitle_accent=status,
subtitle_tone="ok" if label == "Monitoring motion" else "warn",
rows=tuple(rows), parked=parked)
def apply(self, request: FeatureSettingsRequest) -> bool:
@@ -254,6 +254,58 @@ def test_frozen_shape_shaders_crop_and_mask_before_camera_reads():
assert "texture(texture1" not in external
def test_shaders_apply_white_balance_gain_on_both_sampling_paths():
for shader in (PIP_FRAGMENT_SHADER, PIP_CURVED_FRAGMENT_SHADER):
for source in (shader, _external_shader(shader)):
assert "uniform vec3 uGain;" in source
assert source.index("vec3 rgb =") < source.index("rgb * uGain")
def _nv12(width, height, y_value, u_value, v_value):
y = bytes([y_value]) * (width * height)
uv = bytes([u_value, v_value]) * (width * height // 4)
return SimpleNamespace(width=width, height=height, stride=width, uv_offset=width * height, data=memoryview(y + uv))
def test_white_balance_neutralizes_magenta_cast_and_fails_soft():
# IR-lit cabin camera: red and blue high, green low (U and V above neutral).
frame = _nv12(64, 64, 100, 150, 160)
gains = pip_render.crop_white_balance(frame, Crop(0, 0, 64))
assert gains is not None
red, green, blue = gains
assert red < 1.0 and blue < 1.0 and green > 1.0
assert all(pip_render.WB_GAIN_MIN <= gain <= pip_render.WB_GAIN_MAX for gain in gains)
neutral = pip_render.crop_white_balance(_nv12(64, 64, 100, 128, 128), Crop(0, 0, 64))
assert neutral is not None and all(abs(gain - 1.0) < 0.02 for gain in neutral)
assert pip_render.crop_white_balance(SimpleNamespace(width=64, height=64, stride=64), Crop(0, 0, 64)) is None
assert pip_render.crop_white_balance(_nv12(64, 64, 2, 150, 160), Crop(0, 0, 64)) is None # too dark to judge
short = _nv12(64, 64, 100, 150, 160)
short.data = short.data[:64 * 64]
assert pip_render.crop_white_balance(short, Crop(0, 0, 64)) is None
def test_white_balance_is_throttled_smoothed_and_reset():
balance = pip_render._WhiteBalance()
missing = SimpleNamespace(width=64, height=64, stride=64)
assert balance.update("left", missing, Crop(0, 0, 64), 0.0) == pip_render.NEUTRAL_GAIN
magenta = _nv12(64, 64, 100, 150, 160)
first = balance.update("left", magenta, Crop(0, 0, 64), 1.0)
assert first[1] > 1.0
neutral = _nv12(64, 64, 100, 128, 128)
assert balance.update("left", neutral, Crop(0, 0, 64), 1.1) == first # inside the sample interval
eased = balance.update("left", neutral, Crop(0, 0, 64), 1.0 + pip_render.WB_INTERVAL)
assert 1.0 < eased[1] < first[1]
assert balance.update("right", missing, Crop(0, 0, 64), 2.0) == pip_render.NEUTRAL_GAIN
balance.update("right", magenta, Crop(0, 0, 64), 2.5)
balance.reset("left")
assert balance.update("left", missing, Crop(0, 0, 64), 2.6) == pip_render.NEUTRAL_GAIN
assert balance.update("right", missing, Crop(0, 0, 64), 2.6) != pip_render.NEUTRAL_GAIN
balance.reset()
assert balance.update("right", missing, Crop(0, 0, 64), 3.0) == pip_render.NEUTRAL_GAIN
def test_renderer_uses_saved_widget_position_and_removal_deactivates_camera():
mask = Mask.parse({"width": 100, "height": 100, "center_left": [25, 50],
"center_right": [75, 50], "crop_size": 20})
@@ -737,3 +789,25 @@ def test_camera_resolution_adaptation_preserves_saved_crop_and_side_identity():
assert mask.width == 1928 and mask.center_left == (315, 548)
assert mask.for_frame(1928, 1208) is mask
assert mask.for_frame(640, 480) is None
def test_white_balance_switch_follows_saved_choice(monkeypatch):
import sys
import types
from openpilot.starpilot.ui.pip_render import _WhiteBalanceSwitch
class FakeParams:
value = True
def get_bool(self, key):
assert key == "CabinWhiteBalance"
return self.value
module = types.ModuleType("openpilot.common.params")
module.Params = FakeParams
monkeypatch.setitem(sys.modules, "openpilot.common.params", module)
switch = _WhiteBalanceSwitch()
assert switch.enabled(0.0)
FakeParams.value = False
assert switch.enabled(0.5)
assert not switch.enabled(1.5)