Enabled
diff --git a/openpilot/starpilot/galaxy/web/js/sentry.js b/openpilot/starpilot/galaxy/web/js/sentry.js
index 07dcdc95be..457153c4d8 100644
--- a/openpilot/starpilot/galaxy/web/js/sentry.js
+++ b/openpilot/starpilot/galaxy/web/js/sentry.js
@@ -16,7 +16,8 @@ export function validSentryEvents(value) {
if (value.storageIncomplete !== undefined && typeof value.storageIncomplete !== "boolean") return false
const seen = new Set()
return value.events.every((event) => {
- if (event === null || typeof event !== "object" || Object.keys(event).some((key) => !["eventId", "kind", "systemTimeMs", "images"].includes(key)) ||
+ if (event === null || typeof event !== "object" || Object.keys(event).some((key) => !["eventId", "kind", "systemTimeMs", "images", "simulated"].includes(key)) ||
+ (event.simulated !== undefined && event.simulated !== true) ||
(event.images !== undefined && (!Array.isArray(event.images) || event.images.length > 2 ||
new Set(event.images).size !== event.images.length || event.images.some((camera) => !["wide", "cabin"].includes(camera)))) ||
typeof event.eventId !== "string" || !ID.test(event.eventId) || seen.has(event.eventId) ||
diff --git a/openpilot/starpilot/sentry_mode/README.md b/openpilot/starpilot/sentry_mode/README.md
index 487e715353..58bf184302 100644
--- a/openpilot/starpilot/sentry_mode/README.md
+++ b/openpilot/starpilot/sentry_mode/README.md
@@ -1,13 +1,13 @@
# Sentry motion events
This is the parked motion-event part of Sentry. Galaxy can list local event
-metadata at `#/cameras/events`. Camera capture, image galleries, notifications
-and remote access are not implemented here. Motion detection has
+metadata and captured images at `#/cameras/events`. Galaxy supports browser push,
+Discord, webhooks, ntfy, and optional Firebase app push through the companion relay. Motion detection has
not yet been qualified on a physical device or vehicle.
The manager runs it only on device hardware when both
-`STARPILOT_SENTRY_DEVELOPMENT=1` and a valid enabled `SentryMotionPreferences`
-document are present. Ordinary startup is unchanged. The same manager-owned
+the vehicle is parked and a valid enabled `SentryMotionPreferences`
+document is present. The same manager-owned
`sensord` supplies acceleration onroad and, under that explicit opt-in, while
parked. Sentry never starts another sensor, camera or power service.
diff --git a/openpilot/starpilot/sentry_mode/notifications.py b/openpilot/starpilot/sentry_mode/notifications.py
index ae102400bd..23a4ec26a9 100644
--- a/openpilot/starpilot/sentry_mode/notifications.py
+++ b/openpilot/starpilot/sentry_mode/notifications.py
@@ -23,7 +23,9 @@ from openpilot.starpilot.storage import starpilot_storage_root
from openpilot.starpilot.sentry_mode.storage import EventStore
from openpilot.starpilot.sentry_mode import web_push
-CHANNELS = ('webPush', 'discord', 'webhook', 'ntfy')
+CHANNELS = ('webPush', 'discord', 'webhook', 'ntfy', 'appPush')
+APP_PUSH_RELAY_URL = ''
+APP_IDS = ('link.firestar.companion', 'link.firestar.galaxy', 'link.galaxy.bluetooth.natehuby76')
MAX_STATE_BYTES = 1024 * 1024
MAX_JOBS = 512
MAX_AGE = 86400
@@ -52,7 +54,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},
- 'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False}
+ 'appSubscriptions': [], 'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False}
def wall_seconds() -> float:
@@ -136,6 +138,10 @@ class NotificationOwner:
result[key] = value
return result
state = json.loads(raw, object_pairs_hook=unique)
+ if type(state) is dict and 'appSubscriptions' not in state:
+ state['appSubscriptions'] = []
+ if type(state.get('channels')) is dict:
+ state['channels']['appPush'] = fresh_state()['channels']['appPush']
self._validate_state(state)
self.state = state
self.saved_bytes = json.dumps(state, separators=(',', ':'), allow_nan=False).encode()
@@ -161,8 +167,18 @@ class NotificationOwner:
raise ValueError('Invalid notification channel')
if channel['url']:
endpoint(channel['url'], allow_http=self.allow_http)
- if name != 'webPush' and channel['enabled'] and not channel['url']:
+ if name not in ('webPush', 'appPush') and channel['enabled'] and not channel['url']:
raise ValueError('Missing notification endpoint')
+ if type(state['appSubscriptions']) is not list or len(state['appSubscriptions']) > 8:
+ raise ValueError('Invalid app subscriptions')
+ for sub in state['appSubscriptions']:
+ if (type(sub) is not dict or set(sub) != {'id', 'token', 'appId', 'label', 'addedAt', 'since', 'lastResult', 'lastAt'} or
+ type(sub['token']) is not str or not 1 <= len(sub['token']) <= 4096 or any(ord(c) <= 32 for c in sub['token']) or
+ sub['id'] != hashlib.sha256(sub['token'].encode()).hexdigest()[:12] or sub['appId'] not in APP_IDS or
+ type(sub['label']) is not str or len(sub['label']) > 64 or
+ type(sub['lastResult']) is not str or len(sub['lastResult']) > 64 or
+ any(type(sub[key]) not in (int, float) or not 0 <= sub[key] < 2**63 for key in ('addedAt', 'since', 'lastAt'))):
+ raise ValueError('Invalid app subscription')
for sub in state['subscriptions']:
if type(sub) is not dict or set(sub) != {'id', 'subscription', 'since'} or type(sub['id']) is not str or ID.fullmatch(sub['id']) is None:
raise ValueError('Invalid browser subscription')
@@ -176,7 +192,7 @@ class NotificationOwner:
type(key) is not str or len(key) != 64 or type(job['eventId']) is not str or ID.fullmatch(job['eventId']) is None or
job['kind'] not in ('warning', 'alarm', 'test') or job['channel'] not in CHANNELS or
type(job['generation']) is not str or ID.fullmatch(job['generation']) is None or
- (type(job['subscriptionId']) is not str or (job['subscriptionId'] and ID.fullmatch(job['subscriptionId']) is None)) or
+ (type(job['subscriptionId']) is not str or (job['subscriptionId'] and re.fullmatch(r'[0-9a-f]{12}' if job['channel'] == 'appPush' else r'[0-9a-f]{32}', job['subscriptionId']) is None)) or
type(job['attempts']) is not int or not 0 <= job['attempts'] <= MAX_ATTEMPTS or
job['state'] not in ('queued', 'sending', 'sent', 'failed', 'cancelled') or
job['error'] not in ('', 'http', 'expired', 'timeout', 'transport', 'delivery_unknown', 'stale') or
@@ -219,6 +235,9 @@ 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')} 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:]
+ result['appPushAvailable'] = bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL)
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)
@@ -262,7 +281,7 @@ class NotificationOwner:
# Empty fields preserve configured credentials; explicit disable does not delete them.
url = url or old['url']
token = payload['token'] or old['token']
- if payload['enabled'] and name != 'webPush' and not url:
+ if payload['enabled'] and name not in ('webPush', 'appPush') and not url:
raise ValueError('Enter a notification URL')
self.state['channels'][name] = {'enabled': payload['enabled'], 'url': url, 'token': token,
'generation': uuid.uuid4().hex, 'since': now}
@@ -272,6 +291,8 @@ class NotificationOwner:
self.state['channels'][payload['channel']] = fresh_state()['channels'][payload['channel']]
if payload['channel'] == 'webPush':
self.state['subscriptions'] = []
+ if payload['channel'] == 'appPush':
+ self.state['appSubscriptions'] = []
elif action == 'pushKey':
if set(payload) != {'action'}:
raise ValueError('Invalid push key request')
@@ -295,6 +316,41 @@ class NotificationOwner:
if set(payload) != {'action', 'id'} or type(payload['id']) is not str or ID.fullmatch(payload['id']) is None:
raise ValueError('Choose a browser subscription')
self.state['subscriptions'] = [s for s in self.state['subscriptions'] if s['id'] != payload['id']]
+ elif action == 'appSubscribe':
+ if (set(payload) != {'action', 'token', 'appId', 'label'} or type(payload['token']) is not str or
+ not 1 <= len(payload['token']) <= 4096 or any(ord(c) <= 32 for c in payload['token']) or
+ payload['appId'] not in APP_IDS or type(payload['label']) is not str or len(payload['label']) > 64):
+ raise ValueError('Invalid app subscription')
+ current = self.state['appSubscriptions']
+ existing = next((sub for sub in current if sub['token'] == payload['token']), None)
+ if existing:
+ existing.update(appId=payload['appId'], label=payload['label'])
+ elif len(current) < 8:
+ current.append({'id': hashlib.sha256(payload['token'].encode()).hexdigest()[:12], 'token': payload['token'],
+ 'appId': payload['appId'], 'label': payload['label'], 'addedAt': now, 'since': now,
+ 'lastResult': '', 'lastAt': 0})
+ else:
+ raise ValueError('Remove a phone before adding another')
+ cfg = self.state['channels']['appPush']
+ cfg['enabled'] = True
+ cfg['url'] = cfg['url'] or APP_PUSH_RELAY_URL
+ elif action == 'appUnsubscribe':
+ if set(payload) not in ({'action', 'token'}, {'action', 'id'}):
+ raise ValueError('Choose a phone subscription')
+ key = 'token' if 'token' in payload else 'id'
+ if type(payload[key]) is not str:
+ raise ValueError('Choose a phone subscription')
+ self.state['appSubscriptions'] = [sub for sub in self.state['appSubscriptions'] if sub[key] != payload[key]]
+ elif action == 'test' and 'channel' not in payload:
+ if set(payload) - {'action', 'target', 'id'} or payload.get('target', 'all') not in ('all', 'app'):
+ raise ValueError('Choose a test target')
+ target_id = payload.get('id')
+ if payload.get('target') == 'app' and not any(sub['id'] == target_id for sub in self.state['appSubscriptions']):
+ raise LookupError('Phone subscription not found')
+ if sum(j['kind'] == 'test' and j['created'] > now - 60 for j in self.state['jobs'].values()) >= 32:
+ raise ValueError('Wait a minute before sending more tests')
+ self._enqueue({'eventId': uuid.uuid4().hex, 'kind': 'test', 'wallTimeNs': int(now * 1e9)},
+ only='appPush' if payload.get('target') == 'app' else None, target_id=target_id)
elif action == 'test':
if set(payload) != {'action', 'channel'} or payload['channel'] not in CHANNELS:
raise ValueError('Choose a test channel')
@@ -314,15 +370,17 @@ class NotificationOwner:
self._save()
return self.snapshot()
- def _enqueue(self, event: dict, *, only: str | None = None) -> None:
+ def _enqueue(self, event: dict, *, only: str | None = None, target_id: str | None = None) -> None:
if event['kind'] == 'selfie':
return
now = self.clock()
for name, cfg in self.state['channels'].items():
if not cfg['enabled'] or (only is not None and only != name):
continue
- targets = self.state['subscriptions'] if name == 'webPush' else [{'id': '', 'since': cfg['since']}]
+ targets = self.state['subscriptions'] if name == 'webPush' else self.state['appSubscriptions'] if name == 'appPush' else [{'id': '', 'since': cfg['since']}]
for target in targets:
+ if target_id is not None and target['id'] != target_id:
+ continue
if event['kind'] != 'test' and event['wallTimeNs'] / 1e9 <= max(cfg['since'], target['since']):
continue
key = hashlib.sha256((event['eventId'] + name + cfg['generation'] + target['id']).encode()).hexdigest()
@@ -372,7 +430,9 @@ class NotificationOwner:
continue
cfg = self.state['channels'][job['channel']]
sub = next((s['subscription'] for s in self.state['subscriptions'] if s['id'] == job['subscriptionId']), None)
- if not cfg['enabled'] or cfg['generation'] != job['generation'] or (job['channel'] == 'webPush' and sub is None):
+ if job['channel'] == 'appPush':
+ 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'
continue
if now < job['created'] or now - job['created'] > MAX_AGE:
@@ -398,8 +458,18 @@ class NotificationOwner:
if current_job is None:
continue
job = current_job
+ if job['channel'] == 'appPush':
+ current_sub = next((s for s in self.state['appSubscriptions'] if s['id'] == job['subscriptionId']), None)
+ if current_sub is not None:
+ 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}')
+ if code == 410:
+ self.state['appSubscriptions'] = [s for s in self.state['appSubscriptions'] if s['id'] != job['subscriptionId']]
if 200 <= code < 300:
job['state'], job['error'] = 'sent', ''
+ elif code == 410 and job['channel'] == 'appPush':
+ job['state'], job['error'], job['next'] = 'failed', 'expired', now
elif code in (404, 410) and job['channel'] == 'webPush':
job['state'], job['error'] = 'failed', 'expired'
self.state['subscriptions'] = [s for s in self.state['subscriptions']
@@ -408,9 +478,13 @@ class NotificationOwner:
job['state'], job['error'] = ('queued' if code in (408, 429) or code >= 500 else 'failed'), 'http'
except (requests.Timeout, TimeoutError):
job['state'], job['error'] = 'queued', 'timeout'
+ if job['channel'] == 'appPush' and sub is not None:
+ sub['lastAt'], sub['lastResult'] = self.clock(), 'Relay timeout'
except Exception:
# Never log response bodies, URLs, exception text, subscription secrets or auth headers.
job['state'], job['error'] = 'queued', 'transport'
+ if job['channel'] == 'appPush' and sub is not None:
+ sub['lastAt'], sub['lastResult'] = self.clock(), 'Relay unavailable'
if job['state'] == 'queued':
if job['attempts'] >= MAX_ATTEMPTS:
job['state'] = 'failed'
@@ -440,7 +514,17 @@ class NotificationOwner:
message = 'This is a test StarPilot Sentry notification.' if job['kind'] == 'test' else f"Parked motion detected ({job['kind']})."
event = {'eventId': job['eventId'], 'kind': job['kind'], 'systemTimeMs': int(job['created'] * 1000)}
headers = {'Idempotency-Key': key}
- if name == 'webPush':
+ if name == 'appPush':
+ if subscription is None:
+ raise ValueError('Missing app subscription')
+ relay = cfg['url'] or APP_PUSH_RELAY_URL
+ if not relay:
+ return 0
+ response = requests.post(relay.rstrip('/') + '/v1/sentry', 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)
+ elif name == 'webPush':
if subscription is None:
raise ValueError('Missing browser subscription')
body, push_headers = web_push.request(subscription, {'title': 'StarPilot Sentry Mode', 'body': message,
diff --git a/openpilot/starpilot/sentry_mode/storage.py b/openpilot/starpilot/sentry_mode/storage.py
index 9f4e2b3410..8e32a11875 100644
--- a/openpilot/starpilot/sentry_mode/storage.py
+++ b/openpilot/starpilot/sentry_mode/storage.py
@@ -46,7 +46,7 @@ def _valid(value: object, event_id: str) -> bool:
if any(type(key) is not str for key in value):
return False
fields = {key: item for key, item in value.items() if type(key) is str}
- if set(fields) != FIELDS or ID.fullmatch(event_id) is None:
+ if set(fields) not in (FIELDS, FIELDS | {"simulated"}) or ("simulated" in fields and fields["simulated"] is not True) or ID.fullmatch(event_id) is None:
return False
version = fields["version"]
session_id = fields["sessionId"]
@@ -117,10 +117,12 @@ class EventStore:
finally:
os.close(fd)
- def record(self, kind: str, mono_time_ns: int, *, permitted: Callable[[], bool], images: dict | None = None) -> RecordReceipt:
+ def record(self, kind: str, mono_time_ns: int, *, permitted: Callable[[], bool], images: dict | None = None, simulated: bool = False) -> RecordReceipt:
event_id = uuid.uuid4().hex
value = {"version": 1, "eventId": event_id, "sessionId": self.session_id, "kind": kind,
"monoTimeNs": mono_time_ns, "wallTimeNs": time.time_ns()}
+ if simulated:
+ value["simulated"] = True
if not _valid(value, event_id):
raise ValueError("Invalid Sentry event")
raw = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
@@ -262,6 +264,9 @@ class EventStore:
value = json.loads(raw, object_pairs_hook=_unique)
if not _valid(value, event_id):
raise ValueError("Invalid event metadata")
+ if value.get('simulated') and time.time_ns() - value['wallTimeNs'] > 600_000_000_000:
+ os.unlink(entry.name, dir_fd=directory)
+ continue
result["events"].append(value)
except (OSError, ValueError, TypeError, RecursionError):
result["incomplete"] = True
diff --git a/openpilot/starpilot/sentry_mode/tests/test_notifications.py b/openpilot/starpilot/sentry_mode/tests/test_notifications.py
index 0161e25fad..406c018c4e 100644
--- a/openpilot/starpilot/sentry_mode/tests/test_notifications.py
+++ b/openpilot/starpilot/sentry_mode/tests/test_notifications.py
@@ -120,7 +120,7 @@ def test_real_http_contracts_and_independent_channels(tmp_path):
assert paths['/push'][0]['Authorization'].startswith('vapid t=')
assert paths['/push'][0]['Content-Encoding'] == 'aes128gcm'
assert len(paths['/push'][1]) > 100 and b'This is a test' not in paths['/push'][1]
- assert all(owner.snapshot()['channels'][name]['lastState'] == 'sent' for name in CHANNELS)
+ assert all(owner.snapshot()['channels'][name]['lastState'] == 'sent' for name in ('webPush', 'discord', 'webhook', 'ntfy'))
finally:
owner.close()
server.shutdown()
@@ -312,3 +312,54 @@ def test_missing_saved_images_send_original_text_contract(tmp_path, monkeypatch,
assert json.loads(sent['data']['event']) == {'eventId': 'a' * 32, 'kind': 'warning', 'systemTimeMs': 1000000}
finally:
owner.close()
+
+
+def test_app_subscription_dispatch_redaction_and_expired_removal(tmp_path):
+ calls = []
+ clock = [100.]
+ owner = NotificationOwner(tmp_path / 'notifications', store(), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 410)
+ try:
+ snapshot = owner.action({'action': 'appSubscribe', 'token': 'private-fcm-token', 'appId': 'link.firestar.galaxy', 'label': 'Phone'})
+ sub_id = snapshot['appSubscriptions'][0]['id']
+ assert len(sub_id) == 12 and 'private-fcm-token' not in json.dumps(snapshot)
+ with pytest.raises(LookupError):
+ owner.action({'action': 'test', 'target': 'app', 'id': 'missing'})
+ owner.action({'action': 'test', 'target': 'app', 'id': sub_id})
+ owner.tick()
+ assert calls[0][0] == 'appPush' and calls[0][2]['token'] == 'private-fcm-token'
+ assert owner.snapshot()['appSubscriptions'] == []
+ finally:
+ owner.close()
+
+
+def test_app_success_dedupe_selfie_and_migration(tmp_path):
+ calls = []
+ clock = [100.]
+ events = []
+ root = tmp_path / 'notifications'
+ owner = NotificationOwner(root, store(events), clock=lambda: clock[0], transport=lambda *args: calls.append(args) or 200)
+ owner.action({'action': 'appSubscribe', 'token': 'token', 'appId': 'link.firestar.companion', 'label': 'Phone'})
+ events.extend([{'eventId': 'a' * 32, 'kind': 'selfie', 'wallTimeNs': int(101e9)}, {'eventId': 'b' * 32, 'kind': 'warning', 'wallTimeNs': int(101e9)}])
+ clock[0] = 102.
+ owner.tick(); owner.tick()
+ assert len(calls) == 1
+ assert owner.snapshot()['appSubscriptions'][0]['lastResult'] == 'Delivered to relay'
+ owner.close()
+ reopened = NotificationOwner(root, store(events), clock=lambda: clock[0])
+ assert reopened.available and len(reopened.snapshot()['appSubscriptions']) == 1
+ reopened.close()
+ state = json.loads((root / 'state.json').read_text())
+ state.pop('appSubscriptions'); state['channels'].pop('appPush'); state['jobs'] = {}
+ (root / 'state.json').write_text(json.dumps(state))
+ migrated = NotificationOwner(root, store())
+ assert migrated.available and migrated.snapshot()['appSubscriptions'] == []
+ migrated.close()
+
+
+def test_ios_bundle_id_can_subscribe(tmp_path):
+ owner = NotificationOwner(tmp_path / 'notifications', store(), transport=lambda *args: 200)
+ try:
+ snapshot = owner.action({'action': 'appSubscribe', 'token': 'ios-fcm-token', 'appId': 'link.galaxy.bluetooth.natehuby76', 'label': 'iPhone'})
+ assert snapshot['appSubscriptions'][0]['appId'] == 'link.galaxy.bluetooth.natehuby76'
+ finally:
+ owner.close()
diff --git a/openpilot/starpilot/sentry_mode/tests/test_storage.py b/openpilot/starpilot/sentry_mode/tests/test_storage.py
index 0106c30f32..42550993f5 100644
--- a/openpilot/starpilot/sentry_mode/tests/test_storage.py
+++ b/openpilot/starpilot/sentry_mode/tests/test_storage.py
@@ -140,3 +140,17 @@ class EventStoreTest(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
+
+
+def test_simulated_events_expire_without_removing_real_events(tmp_path, monkeypatch):
+ import time
+ from openpilot.starpilot.sentry_mode.storage import EventStore
+ store = EventStore(tmp_path / 'events')
+ clock = [1000000000000]
+ monkeypatch.setattr(time, 'time_ns', lambda: clock[0])
+ fake = store.record('warning', 1, permitted=lambda: True, simulated=True)
+ real = store.record('warning', 2, permitted=lambda: True)
+ assert len(store.snapshot()['events']) == 2
+ clock[0] += 601000000000
+ assert [event['eventId'] for event in store.snapshot()['events']] == [real.event_id]
+ assert not (store.root / (fake.event_id + '.json')).exists()