fix large toggles responses

This commit is contained in:
natehuby76
2026-10-09 12:58:55 -04:00
parent 639450281e
commit a49d8772f3
6 changed files with 82 additions and 12 deletions
+9 -3
View File
@@ -2,7 +2,7 @@
A working prototype source project that bundles StarPilot's existing Galaxy mobile interface inside a native SwiftUI / WKWebView iPhone app. Requests to the comma travel through Core Bluetooth instead of Wi-Fi or a Galaxy relay.
**Hardware validation is pending.** The app compiles and links as an unsigned ARM64 iPhone executable, and automated protocol/proxy tests pass. This has not yet connected to a physical iPhone and comma 4. Bluetooth support in StarPilot does not by itself establish that its installed kernel, controller firmware and BlueZ configuration support this particular LE peripheral service; run the included probe before testing.
**Hardware testing is in progress.** A signed app has launched on a physical iPhone, connected to the comma 4 bridge, and loaded the dashboard with the phone's Wi-Fi and cellular disabled. The first Toggles test exposed an oversized internal parameter in StarPilot's settings response; the bridge now excludes that unused learning-history field. The corrected Toggles transfer and a real setting change still need hardware verification. Automated protocol/proxy tests pass. Run the included BlueZ probe before testing on another device.
## What is included
@@ -28,6 +28,8 @@ The two HTTP hops stay inside their respective devices. The iPhone-to-comma conn
Map search, online map tiles, model downloads and software updates can still require internet access on the phone or comma, as they do in Galaxy today. Bluetooth replaces the connection between the phone and comma; it does not make those external services available offline.
Local toggles and settings are intended to work with Wi-Fi and cellular disabled on either or both devices. Bluetooth must stay enabled, Galaxy must be running on the comma, and the bridge must remain running. The bridge omits `LiveTorqueParameters` from `/api/params/all`: it is internal learning history, is not in the Toggles catalog, and is unused by the bundled UI. Every other parameter is retained. This changes the transferred snapshot only; it does not delete or modify the parameter on the comma.
## Run on iPhone
1. Open `ios/GalaxyBluetooth.xcodeproj` in Xcode 16 or later.
@@ -37,6 +39,8 @@ Map search, online map tiles, model downloads and software updates can still req
5. Start the bridge on your comma using the steps below, paste its 64-character pairing key into the app and select **Scan for comma**.
6. Select **Galaxy** in the scan results. The app verifies the key before opening Galaxy and saves the key in the iPhone Keychain.
If Xcode fails while copying iPhone debugging symbols, try **Product > Scheme > Edit Scheme > Run > Info** and turn off **Debug executable**, then run again. This launches without attaching the debugger; it does not fix an unavailable USB connection.
An iOS Simulator can help inspect the shell but cannot validate this Bluetooth connection. A development-signed install from Xcode is required; this project is not an App Store submission or a signed IPA.
## Run the bridge on comma
@@ -82,6 +86,8 @@ If you need to view the pairing key again, run this locally on the comma and do
Test while parked. Open Home and verify device status, then open Settings and read the existing values. Change an ordinary display preference and confirm it on the comma. Disconnect and reconnect, and confirm the key is remembered.
For an offline test, disable Wi-Fi and cellular on the iPhone while leaving Bluetooth enabled. Open Toggles, read a display preference, change it, and verify the result on the comma. Before disabling the comma's network too, use the startup service below so the bridge survives the SSH session ending. Repeat with both devices offline, then reconnect and read back the preference. Settings load errors remain visible with a **Retry loading toggles** button; retrying this button only reads the settings.
If a mutating request times out after transmission, its outcome can be unknown. Reconnect and read the setting before retrying. Cancelling a queued request removes it; cancelling a partially sent request closes the connection; an already-sent request may finish on the comma while the app drains its response.
## Optional startup service
@@ -113,11 +119,11 @@ You can then remove `/data/galaxy-ble` and delete the iPhone app. Flashing back
- The app runs in the foreground. It does not claim background reconnect or persistent background execution.
- HTTP API requests are serialized over Bluetooth. Settings and status are the first hardware validation target; high-frequency plots may be slow.
- Request/response bodies are limited to 1 MiB, with a 2 MiB bounded encrypted/decoded envelope. Large exports/uploads return an error.
- Request/response bodies are limited to 1 MiB, with a 2 MiB bounded encrypted/decoded envelope. The all-parameters snapshot may be read up to 8 MiB before its unused internal learning history is omitted; the resulting BLE response must still fit the 1 MiB limit. Large exports/uploads return an error.
- Finite event-stream responses, such as a route list, are buffered until complete. Continuous EventSource streams are not implemented; the mobile log view already uses snapshots.
- Video downloads and video/multipart live streams are rejected explicitly. Direct media element loads, continuous camera viewing, direct downloads and browser push notifications are not supported in this prototype.
- A few image elements request dynamic files without the app's API header. These may not render; JSON settings and normal `fetch` / XHR requests are the supported path.
- Galaxy's UI bundle is pinned. A different StarPilot version may need a matching bundle; `UPSTREAM.json` records the revision and three small native-shell adaptations.
- Galaxy's UI bundle is pinned. A different StarPilot version may need a matching bundle; `UPSTREAM.json` records the revision and four small native-shell adaptations.
- One saved pairing key is supported at a time. The bridge creates independent sessions for up to four clients. Encryption protects payloads; radio jamming or unauthenticated connection flooding can still disrupt availability.
## Development checks
+1 -1
View File
@@ -1 +1 @@
{"repository":"https://github.com/firestar5683/StarPilot","revision":"2a12dbd0ad94b46f8a7b6099d32d7216b7676f79","path":"starpilot/system/the_galaxy","bundleChanges":["Treat native shell as installed in mobile/js/install.js","Use phone loopback for localDeviceUrl in mobile/js/views/Galaxy.js and Recordings.js"]}
{"repository":"https://github.com/firestar5683/StarPilot","revision":"2a12dbd0ad94b46f8a7b6099d32d7216b7676f79","path":"starpilot/system/the_galaxy","bundleChanges":["Treat native shell as installed in mobile/js/install.js","Use phone loopback for localDeviceUrl in mobile/js/views/Galaxy.js and Recordings.js","Keep settings load errors visible and provide a read-only retry button in mobile/js/views/Settings.js"]}
+14 -5
View File
@@ -5,19 +5,28 @@
| iPhone compilation and linking | Passed, using Apple Swift 6.4 in Swift 5 mode, iPhoneOS 27.0 SDK, ARM64 target, iOS 17 minimum |
| iPhone compiler warnings | None in the final compile/link run |
| Xcode project and Info.plist parsing | Passed |
| Python protocol/proxy/GATT session tests | 11 tests passed |
| Python protocol/proxy/GATT session tests | 13 tests passed, including oversized learning-history filtering, preservation of toggle values, and upstream/output snapshot bounds |
| Installer with unavailable ensurepip | Passed with uv and pip 26.2.1 using real isolated environments and local dependency wheels; recovered a partial environment and preserved the pairing key on reinstall |
| Swift → Python and Python → Swift | Passed for encrypted, compressed messages using production wire code |
| Swift HTTP parser | Passed for partial bodies and malformed/duplicate/chunked header rejection |
| Production loopback HTTP server on macOS | Passed for serving Galaxy HTML/modules, API forwarding to a fixture, request key enforcement, origin rejection and disconnected responses |
| Bundled Galaxy references | All 352 checked module/HTML/CSS asset references resolve |
| Physical iPhone installation and Bluetooth exchange | Pending |
| comma 4 BlueZ advertising/peripheral capability and coexistence | Pending |
| Signed Xcode build / simulator launch | Not performed successfully in the restricted session; Xcode's build process could not complete while simulator services were inaccessible |
| Signed Xcode build for a physical iPhone | Passed in the user's Xcode with automatic signing and their personal team |
| Xcode simulator build | Passed in the user's Xcode; a simulator launch was not used for Bluetooth testing |
| Physical iPhone launch and Bluetooth exchange | App launched after turning off debugger attachment; user reported connection and dashboard loading with iPhone Wi-Fi/cellular disabled |
| Toggles over Bluetooth | First hardware test failed at the 1 MiB response limit. The corrected bridge/UI still need to be installed and retested on the device |
| comma 4 BlueZ prerequisites | Passed in the user's device probe: powered hci0, central/peripheral roles, GATT server and advertising interfaces, five supported advertising instances and zero active before bridge startup; Galaxy HTTP status 200 |
| comma 4 GATT and advertisement registration | Passed; foreground bridge reported ready on hci0 and forwarding to localhost:8082 |
| Bluetooth coexistence with other accessories | Pending |
| Both devices without network connectivity | Pending; the bridge must run independently of the SSH session before this test |
| Video / continuous stream support | Not implemented in this prototype |
The unsigned executable used for compiler validation is a temporary development output, not an installable application included with this source project. Open the supplied Xcode project and sign it using your development team to install it on your iPhone.
The automated tests use a local fixture API and a fixed public test key. They do not contact or change settings on a real comma device. Passing them confirms the bridge implementation and cross-language protocol agree; hardware timing, BlueZ behavior and installed StarPilot API compatibility still need the included device test steps.
The first comma 4 installation attempt reported that `ensurepip` was unavailable. The revised installer creates its environment without pip, recognizes `/usr/local/venv`, and uses existing uv or pip to install into the bridge environment. The user confirmed `/usr/comma/shims/uv` and pip 26.2.1 are available on the device. A successful device installation with the revised script is still pending.
The first comma 4 installation attempt reported that `ensurepip` was unavailable. The revised installer creates its environment without pip, recognizes `/usr/local/venv`, and uses existing uv or pip to install into the bridge environment. The user confirmed `/usr/comma/shims/uv` and pip 26.2.1 are available on the device. The revised installation succeeded, and the user supplied successful probe and bridge startup output.
The bridge is installed separately from the user's existing StarPilot Dom installation. Current upstream Dom commit `934dadbcdbaaaac78dd6badd09a1ebadd298bae8` has the same Galaxy asset tree as the pinned bundle, and all 171 Galaxy route paths in the pinned server are present in its server source. This is a source compatibility check; matching paths do not establish complete behavior or device timing compatibility.
Read-only diagnosis of the user's Galaxy API found `/api/params/all` was approximately 1.55 MB, including roughly 1.46 MB of `LiveTorqueParameters`. This key is absent from the settings catalog and unused by the bundled frontend. Running the corrected production proxy against that API returned HTTP 200 with approximately 92 KB, preserved all 765 other values exactly, and produced an encrypted/compressed frame of approximately 27 KB. This check used the Mac's network connection for diagnosis; the iPhone's corrected Bluetooth transfer and a real toggle write remain pending. The settings catalog was approximately 190 KB and defaults approximately 20 KB, both below the existing BLE body limit.
+21 -2
View File
@@ -12,6 +12,12 @@ REQUEST_HEADERS = {"content-type", "accept", "cookie", "range"}
RESPONSE_HEADERS = {"content-type", "content-range", "accept-ranges", "content-disposition"}
METHODS = {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
MEDIA_PREFIXES = ("/video/", "/screen_recordings/", "/api/screen_recordings/download/", "/api/sentry/video/")
# StarPilot includes this binary learning history in its all-parameters endpoint.
# Galaxy's bundled UI never reads it and it is not a toggle in the settings catalog.
# Bound the upstream snapshot separately, then retain every other parameter within
# the existing BLE body limit. No parameter on the comma is modified.
PARAMS_SNAPSHOT_LIMIT = 8 * MAX_BODY
INTERNAL_PARAMS = {"LiveTorqueParameters"}
def validate_target(target):
@@ -79,17 +85,30 @@ class GalaxyProxy:
return error_response(request_id, 501, "Continuous media streams are not supported over this BLE bridge.")
if content_type.startswith("text/event-stream") and target.split("?")[0] not in ("/api/routes", "/api/screen_recordings/list"):
return error_response(request_id, 501, "Continuous event streams are not supported; use a snapshot endpoint.")
compact_parameters = method == "GET" and target.split("?")[0] == "/api/params/all"
read_limit = PARAMS_SNAPSHOT_LIMIT if compact_parameters else MAX_BODY
chunks, total = [], 0
deadline = time.monotonic() + 35
while total <= MAX_BODY:
while total <= read_limit:
if time.monotonic() > deadline:
return error_response(request_id, 504, "Galaxy response took too long to complete.")
chunk = response.read1(min(65536, MAX_BODY + 1 - total))
chunk = response.read1(min(65536, read_limit + 1 - total))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
data = b"".join(chunks)
if len(data) > read_limit:
return error_response(request_id, 413, "Galaxy response exceeds the bounded bridge read limit.")
if compact_parameters and response.status == 200:
try:
values = json.loads(data)
except (ValueError, UnicodeDecodeError):
return error_response(request_id, 502, "Galaxy returned invalid settings JSON.")
if not isinstance(values, dict):
return error_response(request_id, 502, "Galaxy returned an invalid settings snapshot.")
data = json.dumps({k: v for k, v in values.items() if k not in INTERNAL_PARAMS},
separators=(",", ":"), ensure_ascii=False).encode()
if len(data) > MAX_BODY:
return error_response(request_id, 413, "Response exceeds the 1 MiB BLE limit.")
if 300 <= response.status < 400:
@@ -33,6 +33,7 @@ export const Settings = {
values: {},
expanded: {},
loading: true,
loadError: "",
activeSectionSlug: "",
defaultSectionSlug: "lateral-steering",
}
@@ -78,6 +79,8 @@ export const Settings = {
modeSection(s) { return this.layout.find(section => section.name === s.name && section.params.some(p => p.key === LONGITUDINAL_MODE_KEY)) },
ordinaryParams(s) { return s.params.filter(p => !this.isModeParam(p)) },
async load() {
this.loading = true
this.loadError = ""
try {
const [layout, values, defaults] = await Promise.all([
api.getLayout(), api.getParams(), api.getDefaults(),
@@ -91,6 +94,7 @@ export const Settings = {
this.activeSectionSlug = (preferred || this.sections[0]).slug
}
} catch (e) {
this.loadError = "Failed to load settings: " + (e?.message || e)
showSnackbar("Failed to load settings: " + (e?.message || e), "error")
} finally {
this.loading = false
@@ -156,6 +160,11 @@ export const Settings = {
<div v-if="loading" class="gx-loading">{{ tr("Loading configuration...") }}</div>
<div v-else-if="loadError" class="gx-card" role="alert">
<p>{{ loadError }}</p>
<button type="button" class="gx-btn" @click="load">Retry loading toggles</button>
</div>
<template v-else-if="sections.length">
<div v-if="searchActive">
<div class="gx-card">
+28 -1
View File
@@ -11,7 +11,7 @@ import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "bridge"))
from dbus_next import Variant
from protocol import Assembler, MAX_BODY, MAX_FRAME, open_message, packets, seal
from proxy import GalaxyProxy, validate_target
from proxy import GalaxyProxy, PARAMS_SNAPSHOT_LIMIT, validate_target
from server import Application, Characteristic, GattService, Gateway, SERVICE_PATH
KEY = bytes(range(32))
@@ -78,6 +78,16 @@ class Fixture(BaseHTTPRequestHandler):
self.respond(200, b'data: waiting\n\n', "text/event-stream")
elif self.path == "/api/binary":
self.respond(200, bytes(range(256)), "image/jpeg")
elif self.path == "/api/params/all":
self.respond(200, json.dumps({"Metric": True, "LanguageSetting": "en",
"LongitudinalPersonalityProfiles": {"custom": [1, 2, 3]},
"LiveTorqueParameters": "learning history" * 110000}).encode())
elif self.path == "/api/params/all?remaining-large":
self.respond(200, json.dumps({"Metric": True, "OtherValue": "x" * MAX_BODY}).encode())
elif self.path == "/api/params/all?upstream-large":
self.respond(200, b"x" * (PARAMS_SNAPSHOT_LIMIT + 1))
elif self.path == "/api/params/all?invalid":
self.respond(200, b"[1, 2, 3]")
else:
self.respond(200, b'{"online":true}')
@@ -112,6 +122,23 @@ class ProxyTests(unittest.TestCase):
self.assertEqual(base64.b64decode(result["body"]), body)
self.assertEqual(Fixture.seen[-1], ("PUT", "/api/params", body))
def test_large_learning_history_does_not_block_toggles(self):
result = self.request("/api/params/all")
self.assertEqual(result["status"], 200)
data = base64.b64decode(result["body"])
self.assertLess(len(data), MAX_BODY)
self.assertEqual(json.loads(data), {"Metric": True, "LanguageSetting": "en",
"LongitudinalPersonalityProfiles": {"custom": [1, 2, 3]}})
assembler = Assembler()
for packet in packets(seal(result, KEY, "response")):
message = assembler.add(packet)
self.assertEqual(open_message(message, KEY, "response"), result)
def test_parameter_snapshot_is_still_bounded_and_requires_an_object(self):
self.assertEqual(self.request("/api/params/all?remaining-large")["status"], 413)
self.assertEqual(self.request("/api/params/all?upstream-large")["status"], 413)
self.assertEqual(self.request("/api/params/all?invalid")["status"], 502)
def test_preserves_query_binary_sse_and_filters_headers(self):
result = self.request("/api/params?key=Metric", headers={"Cookie": "a=b", "Host": "bad", "Authorization": "bad"})
self.assertEqual(result["status"], 200)