From 8f0b3d4963f53fe713ebdf3cb516133c0db5f771 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:50:00 -0800 Subject: [PATCH 01/20] PiP: white-balance the cabin camera's blind-spot crops The cabin camera has no IR-cut filter, so the window crops come out magenta. Sample a gray-world gain from each shown crop a few times a second, smooth it, and apply it in the shader with clipped highlights left white and a mild desaturation for the remaining cast. --- openpilot/starpilot/ui/pip_render.py | 79 ++++++++++++++++++- openpilot/starpilot/ui/pip_shaders.py | 12 +++ .../starpilot/ui/tests/test_pip_sidecam.py | 52 ++++++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/openpilot/starpilot/ui/pip_render.py b/openpilot/starpilot/ui/pip_render.py index 4db34a67c3..45cb3388a1 100644 --- a/openpilot/starpilot/ui/pip_render.py +++ b/openpilot/starpilot/ui/pip_render.py @@ -31,6 +31,70 @@ def _external_shader(fragment: str) -> str: return result.replace(yuv, "vec3 rgb = pow(texture(texture0, uv).rgb, vec3(1.0/1.28));") +# The cabin camera has no IR-cut filter, so the window crops come out red/magenta. +# Gray-world white balance over the crop being shown. Tuned on a daytime frame +# (2026-11-04): full strength made gray pavement and a silver car neutral, and the +# gain clamp keeps a single strongly colored object from being over-corrected. +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) + + +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 PiPRenderer: """One renderer; close it on onroad exit or when the owning shell closes.""" @@ -50,6 +114,7 @@ class PiPRenderer: self._stream_generation: int | None = None self._active_sides: set[str] = set() self._activated_at: dict[str, float] = {} + self._white_balance = _WhiteBalance() def _shader(self) -> rl.Shader: if self.shader is None: @@ -91,6 +156,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 +228,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 +256,9 @@ 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) + gain = self._white_balance.update(side, frame, crop, observed_now) + def draw(rect=rect, crop=crop, warning=warning, gain=gain): + self._draw(shader, texture, frame, rect, crop.x, crop.y, crop.size, invert, warning=warning, gain=gain) if submit is None: draw() else: @@ -198,7 +266,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) -> 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 +284,10 @@ 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 (pow 1/1.28), so a pre-gamma gain g becomes g^(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) if self.shape == "curved": panel = rl.Vector2(rect.width, rect.height) rl.set_shader_value(shader, rl.get_shader_location(shader, "uRectSize"), panel, diff --git a/openpilot/starpilot/ui/pip_shaders.py b/openpilot/starpilot/ui/pip_shaders.py index 9fd0db2b2d..381b50affd 100644 --- a/openpilot/starpilot/ui/pip_shaders.py +++ b/openpilot/starpilot/ui/pip_shaders.py @@ -26,6 +26,7 @@ uniform sampler2D texture1; uniform vec2 uCropMin; uniform vec2 uCropSize; uniform int uFlipX; +uniform vec3 uGain; out vec4 fragColor; const float BUBBLE_REFRACTION = 0.016; @@ -60,6 +61,11 @@ 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 IR (no IR-cut filter) magenta cast; leave clipped highlights white. + // The cast is strongest through the glass and absent in open air, so one gain can't fix both: + // a mild desaturation hides what's left (pink foliage, a slightly cyan sky). + 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, 0.75); // Keep the interior gradient and use cheap analytic lighting instead of specular math. float edgeShade = smoothstep(0.22, 0.98, radius); @@ -84,6 +90,7 @@ uniform sampler2D texture1; uniform vec2 uCropMin; uniform vec2 uCropSize; uniform int uFlipX; +uniform vec3 uGain; uniform vec2 uRectSize; out vec4 fragColor; @@ -129,6 +136,11 @@ 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 IR (no IR-cut filter) magenta cast; leave clipped highlights white. + // The cast is strongest through the glass and absent in open air, so one gain can't fix both: + // a mild desaturation hides what's left (pink foliage, a slightly cyan sky). + 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, 0.75); // Let the rim blend into the camera image and the UI underneath it. float edgeShade = smoothstep(-radius, 0.0, dist); diff --git a/openpilot/starpilot/ui/tests/test_pip_sidecam.py b/openpilot/starpilot/ui/tests/test_pip_sidecam.py index 74f9e9f355..bb612ebd93 100644 --- a/openpilot/starpilot/ui/tests/test_pip_sidecam.py +++ b/openpilot/starpilot/ui/tests/test_pip_sidecam.py @@ -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}) From 5ea75564bfde4b0e4597052bd056f154ebe96b27 Mon Sep 17 00:00:00 2001 From: Danny Date: Thu, 5 Nov 2026 08:02:51 -0800 Subject: [PATCH 02/20] PiP: Make White Balance a Toggle and Apply to Other Driver Cam Things --- openpilot/common/params_keys.h | 1 + openpilot/starpilot/galaxy/server.py | 25 +++- .../starpilot/galaxy/tests/test_sentry.mjs | 2 +- openpilot/starpilot/galaxy/web/css/galaxy.css | 4 + openpilot/starpilot/galaxy/web/js/cameras.js | 9 +- openpilot/starpilot/galaxy/web/js/pip.js | 7 +- openpilot/starpilot/galaxy/web/js/sentry.js | 6 +- openpilot/starpilot/galaxy/web/js/vasm.js | 7 +- .../starpilot/galaxy/web/js/white-balance.js | 113 ++++++++++++++++++ openpilot/starpilot/ui/pip_render.py | 37 +++++- openpilot/starpilot/ui/pip_shaders.py | 6 +- .../starpilot/ui/tests/test_pip_sidecam.py | 22 ++++ 12 files changed, 222 insertions(+), 17 deletions(-) create mode 100644 openpilot/starpilot/galaxy/web/js/white-balance.js 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/server.py b/openpilot/starpilot/galaxy/server.py index 00fc7b7e58..2a11f9e6e0 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,29 @@ 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 + try: + 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') + except Exception: + self.json(503, {'error': 'White balance is unavailable'}) + else: + 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..a1f89edb22 100644 --- a/openpilot/starpilot/galaxy/tests/test_sentry.mjs +++ b/openpilot/starpilot/galaxy/tests/test_sentry.mjs @@ -17,7 +17,7 @@ assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0] 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/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index d1b51a8333..ad82da4fbe 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; 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..ef068cfd9e 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 { WhiteBalanceSwitch, 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" @@ -15,7 +16,7 @@ const SIDES = [ export const PipPage = { name: "PipPage", - components: { GxDialog, GxState, GxNotice, BluetoothNotice, GalaxySettingRow }, + components: { GxDialog, GxState, GxNotice, BluetoothNotice, GalaxySettingRow, WhiteBalanceSwitch }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, setup(props) { @@ -42,7 +43,7 @@ export const PipPage = { } } } }) - return { state, feed, FORMATS, SIDES, bluetooth: bluetoothConnection() } + return { state, feed, FORMATS, SIDES, bluetooth: bluetoothConnection(), pref: whiteBalancePref } }, mounted() { this._dragIndex = -1 @@ -57,6 +58,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)) }, @@ -189,6 +191,7 @@ export const PipPage = {
Camera Crop

Drag a circle onto the cabin window you want to watch. Both crops share one size.

+

{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}

diff --git a/openpilot/starpilot/galaxy/web/js/sentry.js b/openpilot/starpilot/galaxy/web/js/sentry.js index 457153c4d8..b9518d0e0e 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, WhiteBalanceSwitch } from "./white-balance.js" import { deleteRecords, storedSize } from "./record-deletion.js" // Historical motion records and independently configured notifications. @@ -50,7 +51,7 @@ export class SentryEventsFeed extends SnapshotFeed { export const SentryEventsPage = { name: "SentryEventsPage", - components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage }, + components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage, BalancedImage, WhiteBalanceSwitch }, 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 }), @@ -79,12 +80,13 @@ export const SentryEventsPage = { This scan was incomplete. More local motion events may exist. No local motion events found in this scan. +

{{ event.kind === 'selfie' ? 'Selfie' : event.kind === 'alarm' ? 'Alarm' : 'Warning' }}

System time: {{ systemTime(event.systemTimeMs) }}

Event ID: {{ event.eventId }} - +
diff --git a/openpilot/starpilot/galaxy/web/js/vasm.js b/openpilot/starpilot/galaxy/web/js/vasm.js index 80433fc4c8..4f5d99955d 100644 --- a/openpilot/starpilot/galaxy/web/js/vasm.js +++ b/openpilot/starpilot/galaxy/web/js/vasm.js @@ -4,13 +4,14 @@ 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 { WhiteBalanceSwitch, 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" export const VasmPage = { name: "VasmPage", - components: { GxDialog, GxState, GalaxySettingRow, GxNotice, BluetoothNotice }, + components: { GxDialog, GxState, GalaxySettingRow, GxNotice, BluetoothNotice, WhiteBalanceSwitch }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, setup(props) { @@ -34,7 +35,7 @@ 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._dragIndex = -1 @@ -49,6 +50,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)) }, @@ -181,6 +183,7 @@ export const VasmPage = {
Camera Window Regions

The display is mirrored: vehicle left is camera right; vehicle right is camera left. Trace visible side glass, leaving pillars and interior out.

+

{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}

diff --git a/openpilot/starpilot/galaxy/web/js/white-balance.js b/openpilot/starpilot/galaxy/web/js/white-balance.js new file mode 100644 index 0000000000..4539c1ee7a --- /dev/null +++ b/openpilot/starpilot/galaxy/web/js/white-balance.js @@ -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: `
White balanceCorrects the cabin camera's magenta tint.
+
`, +} + +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: ``, +} diff --git a/openpilot/starpilot/ui/pip_render.py b/openpilot/starpilot/ui/pip_render.py index 45cb3388a1..b6bffa5333 100644 --- a/openpilot/starpilot/ui/pip_render.py +++ b/openpilot/starpilot/ui/pip_render.py @@ -41,6 +41,9 @@ 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: @@ -95,6 +98,27 @@ class _WhiteBalance: 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; on when unreadable.""" + + 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 + try: + if self._params is None: + from openpilot.common.params import Params + self._params = Params() + self._enabled = bool(self._params.get_bool(WB_PARAM)) + except Exception: + self._enabled = True + return self._enabled + + class PiPRenderer: """One renderer; close it on onroad exit or when the owning shell closes.""" @@ -115,6 +139,7 @@ class PiPRenderer: 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: @@ -256,9 +281,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) - gain = self._white_balance.update(side, frame, crop, observed_now) - def draw(rect=rect, crop=crop, warning=warning, gain=gain): - self._draw(shader, texture, frame, rect, crop.x, crop.y, crop.size, invert, warning=warning, gain=gain) + 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: @@ -267,7 +294,7 @@ class PiPRenderer: 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, - gain: tuple[float, float, float] = NEUTRAL_GAIN) -> None: + 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) @@ -288,6 +315,8 @@ class PiPRenderer: 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, diff --git a/openpilot/starpilot/ui/pip_shaders.py b/openpilot/starpilot/ui/pip_shaders.py index 381b50affd..a77cc53114 100644 --- a/openpilot/starpilot/ui/pip_shaders.py +++ b/openpilot/starpilot/ui/pip_shaders.py @@ -27,6 +27,7 @@ uniform vec2 uCropMin; uniform vec2 uCropSize; uniform int uFlipX; uniform vec3 uGain; +uniform float uSaturation; out vec4 fragColor; const float BUBBLE_REFRACTION = 0.016; @@ -65,7 +66,7 @@ void main() { // The cast is strongest through the glass and absent in open air, so one gain can't fix both: // a mild desaturation hides what's left (pink foliage, a slightly cyan sky). 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, 0.75); + 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); @@ -91,6 +92,7 @@ uniform vec2 uCropMin; uniform vec2 uCropSize; uniform int uFlipX; uniform vec3 uGain; +uniform float uSaturation; uniform vec2 uRectSize; out vec4 fragColor; @@ -140,7 +142,7 @@ void main() { // The cast is strongest through the glass and absent in open air, so one gain can't fix both: // a mild desaturation hides what's left (pink foliage, a slightly cyan sky). 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, 0.75); + 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); diff --git a/openpilot/starpilot/ui/tests/test_pip_sidecam.py b/openpilot/starpilot/ui/tests/test_pip_sidecam.py index bb612ebd93..e1eec29491 100644 --- a/openpilot/starpilot/ui/tests/test_pip_sidecam.py +++ b/openpilot/starpilot/ui/tests/test_pip_sidecam.py @@ -789,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) From 61f97758039d68c72447c868d426a7ff43339b47 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:07:55 -0800 Subject: [PATCH 03/20] Sentry: Add automatic relay enrollment --- docs/design/sentry-relay-enrollment.md | 48 ++++++ .../galaxy/web/js/sentry-notifications.js | 50 +++++- .../starpilot/sentry_mode/notifications.py | 77 ++++++++- .../starpilot/sentry_mode/relay_config.py | 2 + .../starpilot/sentry_mode/relay_enrollment.py | 119 ++++++++++++++ .../tests/test_relay_enrollment.py | 151 ++++++++++++++++++ 6 files changed, 440 insertions(+), 7 deletions(-) create mode 100644 docs/design/sentry-relay-enrollment.md create mode 100644 openpilot/starpilot/sentry_mode/relay_config.py create mode 100644 openpilot/starpilot/sentry_mode/relay_enrollment.py create mode 100644 openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py diff --git a/docs/design/sentry-relay-enrollment.md b/docs/design/sentry-relay-enrollment.md new file mode 100644 index 0000000000..1b53cf56b8 --- /dev/null +++ b/docs/design/sentry-relay-enrollment.md @@ -0,0 +1,48 @@ +# Automatic Sentry relay enrollment + +Implemented across [StarPilot #18](https://github.com/dirwin31/openpilot/pull/18), [Android/Worker #3](https://github.com/dirwin31/StarPilotAndroid/pull/3), and [iOS #4](https://github.com/natehuby76/ios-clone/pull/4). StarPilot is based on FixesTest; Android is based on Play-Dev. + +## User flow + +The user connects the phone app to comma and enables alerts. The app requests OS notification permission, registers its FCM/APNs token with comma, and asks comma to enroll automatically. Galaxy's Enable alerts action starts the same flow. Users never copy keys, invitations, tokens or URLs. Each phone must grant permission; comma and phone need internet for delivery. + +The operator deploys the Worker and configures provider credentials once. The StarPilot maintainer sets `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` once before release. Hosting is not deployed yet, so that value is intentionally empty: the UI reports that hosting is not configured and does not claim success. The apps discover readiness from comma and need no duplicate URL configuration. + +Enabling is explicit. Existing subscriptions or app upgrades do not silently opt into a new relay. Custom invitation-based relays remain available under advanced controls; they are not the normal user flow. + +## Credential ownership and recovery + +Comma creates a random installation ID and 256-bit sending secret and saves them privately before enrollment. It sends only the SHA-256 digest at enrollment. Later requests use an installation bearer credential. The Worker stores digests; phone apps never receive the comma credential. Provider keys remain Worker secrets. Snapshots expose only capability, lifecycle state, installation ID and destination count. + +Pending enrollment, rotation, revocation and destination reconciliation survive restarts. Requests use HTTPS without redirects; credentials are bound to the configured origin. A lost enrollment response is recovered by authenticating with the saved candidate. Rotation checks the candidate before retrying, including after the old credential's ten-minute overlap expires. Confirmation retires the old credential. + +Revocation stops new local sending immediately and retries remote revocation offline. The Worker keeps a revocation tombstone so retries cannot reactivate the old ID. An already authorized provider request may finish. Forgetting locally does not revoke a copied credential. An explicit new enrollment generates a new ID; anonymous enrollment cannot prove that it represents the same hardware. + +## Public enrollment and capacity + +Automatic enrollment is intentionally public. It does not provide hardware attestation or verified ownership. The Worker requires operator configuration and uses transactional Durable Object state for signup limits, credential checks, destination scope, revocation, send quotas and deduplication. Approximate ingress rate limiting adds another layer. + +Default limits are 5 new installations per IP/day, 100 per relay/day, 1,000 total automatic installations including revoked IDs, 8 destinations per installation, 60 sends/hour/installation and 1,000 sends/day/relay. Operators can change these bounded settings. Capacity exhaustion fails closed. Daily IP hashes expire after two days and are pruned by an alarm. Event receipts expire after a day and are pruned on later sends. + +An attacker can consume capacity or generate new identities. These controls limit resource use; they do not establish physical-device identity. A stronger trust boundary would require accounts, attestation or a challenge, which is outside the one-action anonymous flow. A registered installation can only send to its own registered destinations; provider-token secrecy still matters because this phase does not prove phone ownership cryptographically. + +## Protocol + +| Route | Purpose | +| --- | --- | +| `POST /v2/enroll` | Register installation and digest; an empty invitation selects automatic enrollment | +| `GET /v2/installation` | Recover enrollment or rotation with the saved credential | +| `PUT /v2/subscriptions/{id}` | Register or replace a scoped destination | +| `DELETE /v2/subscriptions/{id}` | Remove a destination | +| `POST /v2/sentry` | Send an allowlisted, bounded event to a registered destination | +| `POST /v2/credentials/rotate` | Activate a replacement with bounded overlap | +| `POST /v2/credentials/confirm` | Retire the old credential | +| `DELETE /v2/installation` | Revoke the installation | + +Operator-only routes issue optional private invitations and revoke installations. Admin credentials never enter apps or comma. Provider responses are acceptance, not proof of display; interrupted provider responses may lead to duplicates. Camera media stays on comma. Authentication failures pause v2 delivery; they never fall back to anonymous or legacy sending. + +## Setup and validation + +Follow the [operator setup guide](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/SETUP.md). Required external work is Firebase configuration for Android, Nate's APNs signing credentials, Worker deployment, the release host setting, signed app distribution and physical-device delivery testing. Nate already has Apple developer membership. Cloudflare Free and Firebase's no-cost FCM support a pilot within their limits; no paid domain is required. + +Focused notification/recovery tests, Galaxy browser checks, Worker unit tests, Android Play builds/tests and iOS production-model regressions cover the implementation. Full Galaxy HTTP tests need Linux GUI dependencies; Wrangler dry-run needs working npm DNS; full iOS compilation needs upstream's newer SDK. Live delivery has not been tested without provider credentials. diff --git a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js index 55267e9b73..fdcb1582fa 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js +++ b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js @@ -61,7 +61,7 @@ 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: {}, testPoll: null, relayUrl: '', enrollmentCode: '', relayConfirmed: false, channels, labels, notifications: null, notificationBusy: false, notificationError: "", 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() { @@ -83,6 +83,25 @@ 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 + }, + 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 = '' }, @@ -137,8 +156,33 @@ export const SentryNotifications = {

{{ notifications.deliverySemantics }}

Galaxy app phones

- - +

{{ pushState.status }}

+ +

Configure a push relay to deliver phone alerts.

diff --git a/openpilot/starpilot/sentry_mode/notifications.py b/openpilot/starpilot/sentry_mode/notifications.py index 4ee2ccd817..1f10947390 100644 --- a/openpilot/starpilot/sentry_mode/notifications.py +++ b/openpilot/starpilot/sentry_mode/notifications.py @@ -11,6 +11,7 @@ import json import os from pathlib import Path import re +import secrets import stat import threading import time @@ -21,7 +22,8 @@ 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 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 = '' @@ -54,7 +56,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 +144,8 @@ 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()) self._validate_state(state) self.state = state self.saved_bytes = json.dumps(state, separators=(',', ':'), allow_nan=False).encode() @@ -149,6 +153,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 @@ -241,7 +246,11 @@ class NotificationOwner: 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 +274,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', 'actionRequired'): + 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 +331,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'] = [] @@ -420,6 +461,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 +488,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 +524,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: @@ -525,7 +586,15 @@ 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, timeout=(3, 10), allow_redirects=False, stream=True) diff --git a/openpilot/starpilot/sentry_mode/relay_config.py b/openpilot/starpilot/sentry_mode/relay_config.py new file mode 100644 index 0000000000..ebf96a3163 --- /dev/null +++ b/openpilot/starpilot/sentry_mode/relay_config.py @@ -0,0 +1,2 @@ +"""Set once by the release maintainer after deploying the hosted relay.""" +DEFAULT_RELAY_URL = '' diff --git a/openpilot/starpilot/sentry_mode/relay_enrollment.py b/openpilot/starpilot/sentry_mode/relay_enrollment.py new file mode 100644 index 0000000000..5413448618 --- /dev/null +++ b/openpilot/starpilot/sentry_mode/relay_enrollment.py @@ -0,0 +1,119 @@ +"""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': {}, '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 (not isinstance(value['registered'], dict) or len(value['registered']) > 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['registered'].items()) 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['state'] = 'revoked' + return + else: + desired = {sub['id']: fingerprint(sub) for sub in subscriptions} + removed = next((key for key in state['registered'] if key not in desired), None) + pending = next((sub for sub in subscriptions if state['registered'].get(sub['id']) != desired[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']] + 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'])) diff --git a/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py b/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py new file mode 100644 index 0000000000..b72abd8d49 --- /dev/null +++ b/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py @@ -0,0 +1,151 @@ +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_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_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() From be398dbbec0c208b6b3041ec53c4a6a03d82a75d Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:06:30 -0800 Subject: [PATCH 04/20] Sentry: Skip phones the relay rejects instead of blocking the comma --- docs/design/sentry-relay-enrollment.md | 4 +- .../starpilot/sentry_mode/notifications.py | 10 ++++- .../starpilot/sentry_mode/relay_enrollment.py | 16 ++++--- .../tests/test_relay_enrollment.py | 43 +++++++++++++++++++ 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/docs/design/sentry-relay-enrollment.md b/docs/design/sentry-relay-enrollment.md index 1b53cf56b8..90bfeed213 100644 --- a/docs/design/sentry-relay-enrollment.md +++ b/docs/design/sentry-relay-enrollment.md @@ -43,6 +43,6 @@ Operator-only routes issue optional private invitations and revoke installations ## Setup and validation -Follow the [operator setup guide](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/SETUP.md). Required external work is Firebase configuration for Android, Nate's APNs signing credentials, Worker deployment, the release host setting, signed app distribution and physical-device delivery testing. Nate already has Apple developer membership. Cloudflare Free and Firebase's no-cost FCM support a pilot within their limits; no paid domain is required. +The host follows [HOSTING.md](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/HOSTING.md); the launch checklist is in [SETUP.md](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/SETUP.md). Required external work is Nate's APNs signing credentials, Worker deployment, the release host setting, signed app distribution and physical-device delivery testing. Firebase is configured. -Focused notification/recovery tests, Galaxy browser checks, Worker unit tests, Android Play builds/tests and iOS production-model regressions cover the implementation. Full Galaxy HTTP tests need Linux GUI dependencies; Wrangler dry-run needs working npm DNS; full iOS compilation needs upstream's newer SDK. Live delivery has not been tested without provider credentials. +Focused notification/recovery tests, Galaxy browser checks, Worker unit tests, Android Play builds/tests and iOS production-model regressions cover the implementation. The Worker was also run locally in the Cloudflare runtime against the real Firebase project. A relay rejecting one phone skips that phone instead of blocking the comma. Full Galaxy HTTP tests need Linux GUI dependencies; full iOS compilation needs upstream's newer SDK. Live delivery to phones has not been tested. diff --git a/openpilot/starpilot/sentry_mode/notifications.py b/openpilot/starpilot/sentry_mode/notifications.py index 1f10947390..2689d00ee2 100644 --- a/openpilot/starpilot/sentry_mode/notifications.py +++ b/openpilot/starpilot/sentry_mode/notifications.py @@ -146,6 +146,8 @@ class NotificationOwner: 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() @@ -241,13 +243,16 @@ 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['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' @@ -369,6 +374,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: diff --git a/openpilot/starpilot/sentry_mode/relay_enrollment.py b/openpilot/starpilot/sentry_mode/relay_enrollment.py index 5413448618..549b9c0855 100644 --- a/openpilot/starpilot/sentry_mode/relay_enrollment.py +++ b/openpilot/starpilot/sentry_mode/relay_enrollment.py @@ -10,7 +10,7 @@ import requests def fresh(): return {'version': 1, 'url': '', 'id': '', 'secret': '', 'invitation': '', 'requestId': '', - 'candidate': '', 'state': 'unenrolled', 'registered': {}, 'next': 0, 'attempts': 0} + 'candidate': '', 'state': 'unenrolled', 'registered': {}, 'rejected': {}, 'next': 0, 'attempts': 0} def validate(value): @@ -25,9 +25,9 @@ def validate(value): raise ValueError('Invalid relay address') if value['url']: normalized_url(value['url']) - if (not isinstance(value['registered'], dict) or len(value['registered']) > 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['registered'].items()) or + 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') @@ -91,12 +91,13 @@ def step(state, subscriptions, now, send=request): elif phase == 'revoking': code = send(state, 'DELETE', 'installation') if 200 <= code < 300 or code == 401: - state.update(fresh()); state['state'] = 'revoked' + 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 state['registered'].get(sub['id']) != desired[sub['id']]), 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: @@ -105,6 +106,9 @@ def step(state, subscriptions, now, send=request): 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: diff --git a/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py b/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py index b72abd8d49..37ad04c8bd 100644 --- a/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py +++ b/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py @@ -40,6 +40,49 @@ def test_subscription_reconciliation_removes_before_adding(): 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 d47d0d5539e9c3d84cea391de63445a7a6c4fb17 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:23:55 -0800 Subject: [PATCH 05/20] Sentry: Point one-tap alerts at the hosted relay --- docs/design/sentry-relay-enrollment.md | 2 +- openpilot/starpilot/sentry_mode/relay_config.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/design/sentry-relay-enrollment.md b/docs/design/sentry-relay-enrollment.md index 90bfeed213..1e2fcb863a 100644 --- a/docs/design/sentry-relay-enrollment.md +++ b/docs/design/sentry-relay-enrollment.md @@ -6,7 +6,7 @@ Implemented across [StarPilot #18](https://github.com/dirwin31/openpilot/pull/18 The user connects the phone app to comma and enables alerts. The app requests OS notification permission, registers its FCM/APNs token with comma, and asks comma to enroll automatically. Galaxy's Enable alerts action starts the same flow. Users never copy keys, invitations, tokens or URLs. Each phone must grant permission; comma and phone need internet for delivery. -The operator deploys the Worker and configures provider credentials once. The StarPilot maintainer sets `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` once before release. Hosting is not deployed yet, so that value is intentionally empty: the UI reports that hosting is not configured and does not claim success. The apps discover readiness from comma and need no duplicate URL configuration. +The operator deploys the Worker and configures provider credentials once. The StarPilot maintainer sets `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` once before release. It points at `https://galaxy-sentry-relay.dannyirwin31.workers.dev`. With an empty value the UI reports that hosting is not configured and does not claim success. The apps discover readiness from comma and need no duplicate URL configuration. Enabling is explicit. Existing subscriptions or app upgrades do not silently opt into a new relay. Custom invitation-based relays remain available under advanced controls; they are not the normal user flow. diff --git a/openpilot/starpilot/sentry_mode/relay_config.py b/openpilot/starpilot/sentry_mode/relay_config.py index ebf96a3163..32a4474bc4 100644 --- a/openpilot/starpilot/sentry_mode/relay_config.py +++ b/openpilot/starpilot/sentry_mode/relay_config.py @@ -1,2 +1,2 @@ -"""Set once by the release maintainer after deploying the hosted relay.""" -DEFAULT_RELAY_URL = '' +"""Hosted Sentry push relay used for one-tap enrollment.""" +DEFAULT_RELAY_URL = 'https://galaxy-sentry-relay.dannyirwin31.workers.dev' From 400843143abc3d593a99f391818a9f48caf9ae40 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:32:14 -0800 Subject: [PATCH 06/20] Sentry: Mark relay setup steps that are done --- docs/design/sentry-relay-enrollment.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/sentry-relay-enrollment.md b/docs/design/sentry-relay-enrollment.md index 1e2fcb863a..7c92efd462 100644 --- a/docs/design/sentry-relay-enrollment.md +++ b/docs/design/sentry-relay-enrollment.md @@ -6,7 +6,7 @@ Implemented across [StarPilot #18](https://github.com/dirwin31/openpilot/pull/18 The user connects the phone app to comma and enables alerts. The app requests OS notification permission, registers its FCM/APNs token with comma, and asks comma to enroll automatically. Galaxy's Enable alerts action starts the same flow. Users never copy keys, invitations, tokens or URLs. Each phone must grant permission; comma and phone need internet for delivery. -The operator deploys the Worker and configures provider credentials once. The StarPilot maintainer sets `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` once before release. It points at `https://galaxy-sentry-relay.dannyirwin31.workers.dev`. With an empty value the UI reports that hosting is not configured and does not claim success. The apps discover readiness from comma and need no duplicate URL configuration. +The relay is deployed with its provider credentials, and `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` points at it (`https://galaxy-sentry-relay.dannyirwin31.workers.dev`). With an empty value the UI reports that hosting is not configured and does not claim success. The apps discover readiness from comma and need no duplicate URL configuration. Enabling is explicit. Existing subscriptions or app upgrades do not silently opt into a new relay. Custom invitation-based relays remain available under advanced controls; they are not the normal user flow. @@ -43,6 +43,6 @@ Operator-only routes issue optional private invitations and revoke installations ## Setup and validation -The host follows [HOSTING.md](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/HOSTING.md); the launch checklist is in [SETUP.md](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/SETUP.md). Required external work is Nate's APNs signing credentials, Worker deployment, the release host setting, signed app distribution and physical-device delivery testing. Firebase is configured. +The relay is running (see [HOSTING.md](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/HOSTING.md)). Remaining work is registering the iOS app and Nate's APNs key in Firebase, signed app builds and delivery tests on real phones. Focused notification/recovery tests, Galaxy browser checks, Worker unit tests, Android Play builds/tests and iOS production-model regressions cover the implementation. The Worker was also run locally in the Cloudflare runtime against the real Firebase project. A relay rejecting one phone skips that phone instead of blocking the comma. Full Galaxy HTTP tests need Linux GUI dependencies; full iOS compilation needs upstream's newer SDK. Live delivery to phones has not been tested. From e92d6e2758ac76ec45b5ead3d7b53fdc203d979c Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:56:36 -0800 Subject: [PATCH 07/20] Sentry: Tidy the events page and phone alerts card --- openpilot/starpilot/galaxy/web/css/galaxy.css | 10 ++ .../galaxy/web/js/sentry-notifications.js | 98 +++++++++++-------- openpilot/starpilot/galaxy/web/js/sentry.js | 21 ++-- 3 files changed, 81 insertions(+), 48 deletions(-) diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index ad82da4fbe..4149a4536e 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -1160,6 +1160,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/js/sentry-notifications.js b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js index fdcb1582fa..0f6f886dfe 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js +++ b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js @@ -70,6 +70,7 @@ export const SentryNotifications = { this.start() }, beforeUnmount() { clearInterval(this.testPoll); this.client.stop(); clearInterval(this.poll); document.removeEventListener("visibilitychange", this.visibility) }, + computed: { relay() { return this.notifications?.relayEnrollment?.supported ? this.notifications.relayEnrollment : null } }, methods: { reportSave, refreshPush() { try { this.pushState = JSON.parse(window.StarPilotApp?.sentryPush?.() || '{}') } catch { this.pushState = {} } }, @@ -98,6 +99,9 @@ export const SentryNotifications = { 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.' @@ -154,45 +158,61 @@ export const SentryNotifications = { The notification queue is full. Some events could not be queued.
`, diff --git a/openpilot/starpilot/galaxy/web/js/sentry.js b/openpilot/starpilot/galaxy/web/js/sentry.js index b9518d0e0e..448e4ffba8 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry.js +++ b/openpilot/starpilot/galaxy/web/js/sentry.js @@ -63,6 +63,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' } }) }, template: `

Sentry

@@ -76,18 +77,20 @@ export const SentryEventsPage = { Reading local motion events… {{ error }}
`, From dae94d0202ef8d54e21d9b8f9b536d99116f8681 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 08:59:48 -0800 Subject: [PATCH 08/20] Sentry: Move channel switches into the card headers --- openpilot/starpilot/galaxy/web/js/sentry-notifications.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js index 0f6f886dfe..4bb103800e 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js +++ b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js @@ -214,13 +214,12 @@ export const SentryNotifications = {
-
{{ labels[name] }}
-
Enabled - {{ notifications.channels[name].configured ? 'Configured' : 'Not configured' }}
+
{{ labels[name] }}
-

Delivery: {{ notifications.channels[name].lastState }} · Pending: {{ notifications.channels[name].pending }}

+
+

{{ notifications.channels[name].configured ? 'Configured' : 'Not configured' }}

Delivery status: {{ notifications.channels[name].lastError.replaceAll('_', ' ') }}
From 70157478aa45e9a4280c225844b637c16091e484 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 09:02:59 -0800 Subject: [PATCH 09/20] Cameras: Show the white balance switch only on the cameras page --- openpilot/starpilot/galaxy/web/js/pip.js | 6 +++--- openpilot/starpilot/galaxy/web/js/sentry.js | 5 ++--- openpilot/starpilot/galaxy/web/js/vasm.js | 6 +++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/openpilot/starpilot/galaxy/web/js/pip.js b/openpilot/starpilot/galaxy/web/js/pip.js index ef068cfd9e..113d62acff 100644 --- a/openpilot/starpilot/galaxy/web/js/pip.js +++ b/openpilot/starpilot/galaxy/web/js/pip.js @@ -3,7 +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 { WhiteBalanceSwitch, whiteBalancePref } from "./white-balance.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" @@ -16,7 +16,7 @@ const SIDES = [ export const PipPage = { name: "PipPage", - components: { GxDialog, GxState, GxNotice, BluetoothNotice, GalaxySettingRow, WhiteBalanceSwitch }, + components: { GxDialog, GxState, GxNotice, BluetoothNotice, GalaxySettingRow }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, setup(props) { @@ -46,6 +46,7 @@ export const PipPage = { return { state, feed, FORMATS, SIDES, bluetooth: bluetoothConnection(), pref: whiteBalancePref } }, mounted() { + this.pref.load() this._dragIndex = -1 this._dragSide = null this.liveCamera = new LiveCameraPreview({ @@ -191,7 +192,6 @@ export const PipPage = {
Camera Crop

Drag a circle onto the cabin window you want to watch. Both crops share one size.

-

{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}

diff --git a/openpilot/starpilot/galaxy/web/js/sentry.js b/openpilot/starpilot/galaxy/web/js/sentry.js index 448e4ffba8..3a0e042962 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry.js +++ b/openpilot/starpilot/galaxy/web/js/sentry.js @@ -4,7 +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, WhiteBalanceSwitch } from "./white-balance.js" +import { BalancedImage } from "./white-balance.js" import { deleteRecords, storedSize } from "./record-deletion.js" // Historical motion records and independently configured notifications. @@ -51,7 +51,7 @@ export class SentryEventsFeed extends SnapshotFeed { export const SentryEventsPage = { name: "SentryEventsPage", - components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage, BalancedImage, WhiteBalanceSwitch }, + 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 }), @@ -82,7 +82,6 @@ export const SentryEventsPage = { This scan was incomplete. More local motion events may exist. No motion events yet. Events appear here when Sentry detects motion while parked. -
{{ kinds[event.kind].label }}
diff --git a/openpilot/starpilot/galaxy/web/js/vasm.js b/openpilot/starpilot/galaxy/web/js/vasm.js index 4f5d99955d..dec0666462 100644 --- a/openpilot/starpilot/galaxy/web/js/vasm.js +++ b/openpilot/starpilot/galaxy/web/js/vasm.js @@ -4,14 +4,14 @@ 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 { WhiteBalanceSwitch, whiteBalancePref } from "./white-balance.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" export const VasmPage = { name: "VasmPage", - components: { GxDialog, GxState, GalaxySettingRow, GxNotice, BluetoothNotice, WhiteBalanceSwitch }, + components: { GxDialog, GxState, GalaxySettingRow, GxNotice, BluetoothNotice }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, setup(props) { @@ -38,6 +38,7 @@ export const VasmPage = { return { state, feed, FORMATS, displaySide, bluetooth: bluetoothConnection(), pref: whiteBalancePref } }, mounted() { + this.pref.load() this._dragIndex = -1 this._dragSide = null this.liveCamera = new LiveCameraPreview({ @@ -183,7 +184,6 @@ export const VasmPage = {
Camera Window Regions

The display is mirrored: vehicle left is camera right; vehicle right is camera left. Trace visible side glass, leaving pillars and interior out.

-

{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}

From f69ca7e75b0eeaf178d26fdb1c2327c463158348 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 09:15:30 -0800 Subject: [PATCH 10/20] Sentry: Hide Channel Settings Unless Enabled --- .../galaxy/web/js/sentry-notifications.js | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js index 4bb103800e..adf9ec4d18 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js +++ b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js @@ -62,6 +62,7 @@ export const SentryNotifications = { name: "SentryNotifications", props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testPoll: null, relayUrl: '', enrollmentCode: '', relayConfirmed: false, channels, labels, notifications: null, notificationBusy: 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() { @@ -116,16 +117,25 @@ export const SentryNotifications = { this.client.run() this.poll = setInterval(() => { 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)) { @@ -215,10 +225,10 @@ export const SentryNotifications = {
{{ labels[name] }} -
-
+
+

{{ notifications.channels[name].configured ? 'Configured' : 'Not configured' }}

Delivery status: {{ notifications.channels[name].lastError.replaceAll('_', ' ') }}
From a7656afe9d643b185eada33b79ab3806946698a9 Mon Sep 17 00:00:00 2001 From: Danny Date: Fri, 6 Nov 2026 12:47:59 -0800 Subject: [PATCH 11/20] Sentry: Hint How to Remove Phone, Color Armed and Disarmed --- openpilot/starpilot/galaxy/web/css/galaxy.css | 2 ++ .../galaxy/web/js/sentry-notifications.js | 11 ++++---- openpilot/starpilot/ui/feature_settings.py | 27 ++++++++++++++++--- .../starpilot/ui/feature_settings_state.py | 2 ++ openpilot/starpilot/ui/sentry_owner.py | 6 +++-- 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index 4149a4536e..8c73fd5bf8 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -895,6 +895,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; diff --git a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js index adf9ec4d18..5fd1c18bba 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,7 @@ 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: '', enrollmentCode: '', relayConfirmed: false, channels, labels, notifications: null, notificationBusy: false, notificationError: "", + data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, 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 }) }, @@ -164,7 +164,7 @@ export const SentryNotifications = {

Notification configuration is available on your connected device.