mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-12 07:23:45 +08:00
@@ -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}},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 & 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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']))
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user