diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 8aa036deef..b3e5d3b76d 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -159,6 +159,7 @@ inline static std::unordered_map 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}}, diff --git a/openpilot/starpilot/galaxy/sentry_events.py b/openpilot/starpilot/galaxy/sentry_events.py index ee531ef394..88e2c936f2 100644 --- a/openpilot/starpilot/galaxy/sentry_events.py +++ b/openpilot/starpilot/galaxy/sentry_events.py @@ -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, diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index 24db05626d..851de24aa3 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -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 diff --git a/openpilot/starpilot/galaxy/tests/test_sentry.mjs b/openpilot/starpilot/galaxy/tests/test_sentry.mjs index 04f62d354c..addf66816d 100644 --- a/openpilot/starpilot/galaxy/tests/test_sentry.mjs +++ b/openpilot/starpilot/galaxy/tests/test_sentry.mjs @@ -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, /]*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/) diff --git a/openpilot/starpilot/galaxy/tests/test_sentry_events.py b/openpilot/starpilot/galaxy/tests/test_sentry_events.py index e709aa10d6..a66b49fbf9 100644 --- a/openpilot/starpilot/galaxy/tests/test_sentry_events.py +++ b/openpilot/starpilot/galaxy/tests/test_sentry_events.py @@ -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" diff --git a/openpilot/starpilot/galaxy/tests/test_settings.mjs b/openpilot/starpilot/galaxy/tests/test_settings.mjs index e33275e79e..af973b3a6d 100644 --- a/openpilot/starpilot/galaxy/tests/test_settings.mjs +++ b/openpilot/starpilot/galaxy/tests/test_settings.mjs @@ -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") diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index 70f7fa99f7..7c6725284a 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -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); } diff --git a/openpilot/starpilot/galaxy/web/css/settings.css b/openpilot/starpilot/galaxy/web/css/settings.css index 87b6206be7..1c78a2cd87 100644 --- a/openpilot/starpilot/galaxy/web/css/settings.css +++ b/openpilot/starpilot/galaxy/web/css/settings.css @@ -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); } diff --git a/openpilot/starpilot/galaxy/web/js/cameras.js b/openpilot/starpilot/galaxy/web/js/cameras.js index 9eb714f23d..e20642dc0a 100644 --- a/openpilot/starpilot/galaxy/web/js/cameras.js +++ b/openpilot/starpilot/galaxy/web/js/cameras.js @@ -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 = { {{ error }} - + +

Camera snapshots are available on the connected device.

Snapshots are not saved. @@ -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 diff --git a/openpilot/starpilot/galaxy/web/js/pip.js b/openpilot/starpilot/galaxy/web/js/pip.js index a1e4c2d376..113d62acff 100644 --- a/openpilot/starpilot/galaxy/web/js/pip.js +++ b/openpilot/starpilot/galaxy/web/js/pip.js @@ -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)) }, diff --git a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js index 55267e9b73..7112532844 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js +++ b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js @@ -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 = {

Notification configuration is available on your connected device.

`, diff --git a/openpilot/starpilot/galaxy/web/js/sentry.js b/openpilot/starpilot/galaxy/web/js/sentry.js index 457153c4d8..89c9ec0976 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry.js +++ b/openpilot/starpilot/galaxy/web/js/sentry.js @@ -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: `

Sentry

@@ -75,17 +79,19 @@ export const SentryEventsPage = { Reading local motion events… {{ error }}
`, diff --git a/openpilot/starpilot/galaxy/web/js/settings.js b/openpilot/starpilot/galaxy/web/js/settings.js index 923bdab8b8..1b7b4e6409 100644 --- a/openpilot/starpilot/galaxy/web/js/settings.js +++ b/openpilot/starpilot/galaxy/web/js/settings.js @@ -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 = {

Assign what the steering-wheel controls do.

Choose what uploads when the vehicle uses mobile data.

Adjust acceleration, braking and following for this driving style.

-

{{ state.data?.subtitle || "Checking motion monitor…" }}

Local settings are unavailable in preview.