mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
Android Auto: AAWireless v1 adapter option
Co-authored-by: Peter Nguyen <trung791997@gmail.com>
This commit is contained in:
@@ -513,14 +513,23 @@ const namedCalls = []
|
||||
named.control = (action, extra) => namedCalls.push([action, extra])
|
||||
named.saveCarType(0, 'Wireless adapter')
|
||||
assert.deepEqual(namedCalls, [['car_type', { carType: 'adapter' }]])
|
||||
// A tuned car profile only shows up when one is detected or pinned.
|
||||
const profiled = page(setup)
|
||||
assert.equal(profiled.showProfile, false, 'standard cars see nothing')
|
||||
profiled.runtime = { car_profile_label: 'Hyundai Palisade', car_profile_source: 'detected' }
|
||||
assert.equal(profiled.showProfile, true)
|
||||
profiled.runtime = { car_profile_label: '', car_profile_source: 'override' }
|
||||
assert.equal(profiled.showProfile, true, 'a pinned standard profile can be undone')
|
||||
// The adapter model picks the AAWireless tuning; any other adapter keeps the regular pairing.
|
||||
const dongle = page(setup)
|
||||
dongle.runtime = { car_type: 'adapter', car_profile: 'default', car_profile_source: 'standard' }
|
||||
assert.equal(dongle.adapterRow.value, 'Choose one', 'no default: the owner picks')
|
||||
assert.match(dongle.pairingReason, /adapter model/)
|
||||
dongle.runtime = { car_type: 'adapter', car_profile: 'default', car_profile_source: 'override' }
|
||||
assert.equal(dongle.adapterRow.value, 'Other')
|
||||
assert.deepEqual(dongle.adapterRow.choices, ['AAWireless v1', 'Other'])
|
||||
dongle.runtime = { car_type: 'adapter', car_profile: 'aawireless', car_profile_source: 'override' }
|
||||
assert.equal(dongle.adapterRow.value, 'AAWireless v1')
|
||||
const dongleCalls = []
|
||||
dongle.control = (action, extra) => dongleCalls.push([action, extra])
|
||||
dongle.saveAdapter(0, 'Other')
|
||||
dongle.saveAdapter(0, 'AAWireless v1')
|
||||
assert.deepEqual(dongleCalls, [['car_profile', { profile: 'default' }], ['car_profile', { profile: 'aawireless' }]])
|
||||
assert.match(AndroidAutoPage.template, /adapterSetup" :row="adapterRow"/)
|
||||
assert.match(AndroidAutoPage.template, /What’s in your car\?|carTypeRow/)
|
||||
assert.match(AndroidAutoPage.template, /runtime\?\.pairing_note/)
|
||||
assert.match(AndroidAutoPage.template, /'Find ' \+ findLabel/)
|
||||
console.log('Android Auto page: setup type, adapter checklist and car profile line passed')
|
||||
console.log('Android Auto page: setup type, adapter checklist and adapter model passed')
|
||||
|
||||
@@ -44,6 +44,7 @@ export function uploadPackage(path, options, progress, makeRequest = () => new X
|
||||
}
|
||||
|
||||
const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
|
||||
const ADAPTER_MODELS = { aawireless: "AAWireless v1", default: "Other" }
|
||||
const HEAD_UNIT_APPS = { headunitpad: "HeadunitPad", openheadunit: "Open Headunit" }
|
||||
const HOTSPOT_LINK = `<a class="gx-link" href="#/galaxy">Galaxy Wi-Fi Hotspot</a>`
|
||||
const APP_SETUP = `
|
||||
@@ -510,7 +511,12 @@ export const AndroidAutoPage = {
|
||||
return { label: "What’s in your car?", value: labels[this.carType], choices: Object.values(labels), available: true, action: true,
|
||||
reason: "Built-in: the car supports wireless Android Auto. Adapter: a dongle such as AAWireless, Motorola MA1 or Carlinkit. Both the adapter and your car get paired." }
|
||||
},
|
||||
showProfile() { return !!(this.runtime?.car_profile_label || this.runtime?.car_profile_source === "override") },
|
||||
adapterRow() {
|
||||
const chosen = this.runtime?.car_profile_source === "override"
|
||||
return { label: "Adapter", value: !chosen ? "Choose one" : this.runtime.car_profile === "aawireless" ? ADAPTER_MODELS.aawireless : ADAPTER_MODELS.default,
|
||||
choices: Object.values(ADAPTER_MODELS), available: true, action: true,
|
||||
reason: "AAWireless v1 gets video and car-data settings tuned for that dongle. Other adapters use the regular pairing." }
|
||||
},
|
||||
appDisabled() {
|
||||
return !this.setup?.enabled || !this.setup.parked || !!this.pairing?.active || !!this.runtime?.running
|
||||
},
|
||||
@@ -573,6 +579,7 @@ export const AndroidAutoPage = {
|
||||
if (!this.setup.installReady) return "This build is missing the Android Auto display or encoder."
|
||||
if (this.setup.enabled && !this.setup.serviceReady) return "Waiting for the Android Auto service to start."
|
||||
if (!this.setup.bluetoothEnabled && this.setup.enabled) return "Turn on Bluetooth on the Bluetooth page."
|
||||
if (this.adapterSetup && this.runtime?.car_profile_source !== "override") return "Choose your adapter model first."
|
||||
if (!this.setup.parked) return "Use offroad mode or Park before starting pairing."
|
||||
if (this.runtime?.running) return "Disconnect from your car before pairing."
|
||||
return ""
|
||||
@@ -582,6 +589,7 @@ export const AndroidAutoPage = {
|
||||
if (!this.setup?.enabled) return "Turn on Android Auto Support in step 1."
|
||||
if (!this.setup.serviceReady) return "Waiting for the Android Auto service to start."
|
||||
if (this.pairing?.active) return "Finish or cancel pairing first."
|
||||
if (this.adapterSetup && this.runtime?.car_profile_source !== "override") return "Choose your adapter model in step 2 first."
|
||||
return ""
|
||||
},
|
||||
waitingForCar() {
|
||||
@@ -606,6 +614,10 @@ export const AndroidAutoPage = {
|
||||
if (carType === this.carType) return false
|
||||
return carType ? this.control("car_type", { carType }) : false
|
||||
},
|
||||
saveAdapter(index, value) {
|
||||
const profile = Object.keys(ADAPTER_MODELS).find(key => ADAPTER_MODELS[key] === value)
|
||||
return profile ? this.control("car_profile", { profile }) : false
|
||||
},
|
||||
saveApp(index, value) {
|
||||
const connection = Object.keys(HEAD_UNIT_APPS).find(key => HEAD_UNIT_APPS[key] === value) || "wireless"
|
||||
return this.control("connection", { connection })
|
||||
@@ -795,10 +807,7 @@ export const AndroidAutoPage = {
|
||||
<GalaxySettingRow :row="carTypeRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveCarType" />
|
||||
<GxNotice v-if="wiredUnavailable" tone="warn">Wired AA is unavailable: {{ runtime.wired_blocked }}.</GxNotice>
|
||||
<GalaxySettingRow v-if="carType === 'app'" :row="appRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveApp" />
|
||||
<p v-if="showProfile" class="gx-note" role="status">
|
||||
{{ runtime.car_profile_source === 'override' ? 'Using standard settings for this car.' : 'Tuned for ' + runtime.car_profile_label + ' (detected).' }}
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="busy || !setup.parked"
|
||||
@click="control('car_profile', { profile: runtime.car_profile_source === 'override' ? '' : 'default' })">{{ runtime.car_profile_source === 'override' ? 'Use detected settings' : 'Use standard settings' }}</button></p>
|
||||
<GalaxySettingRow v-if="adapterSetup" :row="adapterRow" :index="0" :busy="busy" :disabled="appDisabled" :save-value="saveAdapter" />
|
||||
<template v-if="carType === 'usb'">
|
||||
<GalaxySettingRow :row="wiredRow" :index="0" :busy="busy" :disabled="wiredDisabled"
|
||||
:save-value="(index, value) => control('connection', { connection: value === 'On' ? 'wired' : 'wireless' })" />
|
||||
|
||||
@@ -6,12 +6,18 @@ in the config pins a profile (or "default") for a single comma while testing wit
|
||||
"""
|
||||
import re
|
||||
|
||||
DEFAULTS = {"connect_delay_s": 0.0} # wait before the Bluetooth connect, for head units that need time to wake
|
||||
# connect_delay_s: wait before the Bluetooth connect, for head units that need time to wake
|
||||
# max_window: most unacknowledged video messages in flight; phone_services: open the car-data and Bluetooth channels
|
||||
# like a phone does; phone_frames: send picture NAL units only, without AUD or repeated SPS/PPS
|
||||
DEFAULTS = {"connect_delay_s": 0.0, "max_window": 2, "phone_services": False, "phone_frames": False}
|
||||
|
||||
FINGERPRINT_KEYS = ("head_unit_make", "head_unit_model", "car_make", "car_model", "car_year")
|
||||
|
||||
# {"id": "...", "label": "...", "match": {fingerprint key: regex, ...}, "tuning": {key from DEFAULTS: value}}
|
||||
RULES: list[dict] = []
|
||||
RULES: list[dict] = [
|
||||
{"id": "aawireless", "label": "AAWireless v1", "match": {},
|
||||
"tuning": {"max_window": 30, "phone_services": True, "phone_frames": True}},
|
||||
]
|
||||
|
||||
|
||||
def profile_ids() -> list[str]:
|
||||
|
||||
@@ -83,7 +83,7 @@ def load_identity(directory: Path | None = None, now: datetime | None = None) ->
|
||||
expires.isoformat() if expires is not None else "unknown", days_left)
|
||||
|
||||
|
||||
CONFIG_VERSION = 6
|
||||
CONFIG_VERSION = 7
|
||||
# Before config versions, every save wrote these defaults, which pinned projection
|
||||
# at 12 fps / 4000 kbps. Unversioned files holding exactly them get today's defaults.
|
||||
LEGACY_DEFAULTS = {"fps": 12, "bitrate_kbps": 4000}
|
||||
@@ -132,7 +132,7 @@ DEFAULT_CONFIG = {
|
||||
"auto_connect": True, # start projection on its own when the chosen car is on (onroad, or it reaches the comma)
|
||||
"rfcomm_cache": {}, # car address -> Android Auto RFCOMM channel learned over SDP, to skip discovery next time
|
||||
"car_type": "", # wireless setup shown in Galaxy: "builtin" or "adapter"; "" = work it out from the saved companion car
|
||||
"car_profile_override": "", # "" = pick by fingerprint; "default" = standard behaviour; otherwise a profile id from car_profiles
|
||||
"car_profile_override": "", # "" = pick by fingerprint (a wireless adapter must choose); "default" = standard; otherwise a car_profiles id
|
||||
"car_fingerprint": {}, # what the chosen car's head unit reported last time, used to pick a profile before it connects
|
||||
}
|
||||
|
||||
@@ -152,6 +152,10 @@ def load_config(path: Path | None = None) -> dict:
|
||||
stored = {key: value for key, value in stored.items() if key not in VERIFY_RESET}
|
||||
if not isinstance(version, int) or version < 6:
|
||||
stored = {key: value for key, value in stored.items() if key not in DEDICATED_WIFI_RESET}
|
||||
if not isinstance(version, int) or version < 7: # adapter owners from before the adapter model choice keep connecting
|
||||
on_adapter = stored.get("car_type") == "adapter" or (not stored.get("car_type") and stored.get("companion_address"))
|
||||
if on_adapter and not stored.get("car_profile_override"):
|
||||
stored = {**stored, "car_profile_override": "default"}
|
||||
config.update({key: value for key, value in stored.items() if key in DEFAULT_CONFIG and isinstance(value, type(DEFAULT_CONFIG[key]))})
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
@@ -68,6 +68,17 @@ INPUT_EVENT = 0x8001
|
||||
INPUT_BINDING_REQUEST = 0x8002
|
||||
INPUT_BINDING_RESPONSE = 0x8003
|
||||
|
||||
# Car data (sensor) and Bluetooth channels, opened the way a phone does.
|
||||
SENSOR_SERVICE = 2
|
||||
BLUETOOTH_SERVICE = 6
|
||||
SENSOR_START_REQUEST = 0x8001
|
||||
SENSOR_START_RESPONSE = 0x8002
|
||||
SENSOR_EVENT = 0x8003
|
||||
BLUETOOTH_PAIRING_REQUEST = 0x8001
|
||||
BLUETOOTH_PAIRING_RESPONSE = 0x8002
|
||||
BLUETOOTH_AUTH_DATA = 0x8003
|
||||
SENSOR_NIGHT, SENSOR_DRIVING_STATUS = 10, 13
|
||||
|
||||
CODEC_H264_BP = 3
|
||||
SETUP_STATUS_READY = 2
|
||||
FOCUS_PROJECTED = 1
|
||||
@@ -118,6 +129,18 @@ def codec_config(access_unit: bytes) -> bytes:
|
||||
return b"".join(units)
|
||||
|
||||
|
||||
def wire_frame(access_unit: bytes, *, drop_config: bool) -> bytes:
|
||||
"""The picture NAL units as Android's encoder sends them: no AUD, and no SPS/PPS when the codec config went separately."""
|
||||
starts = list(START_CODE.finditer(access_unit))
|
||||
drop = {9, 7, 8} if drop_config else {9}
|
||||
units = []
|
||||
for index, match in enumerate(starts):
|
||||
end = starts[index + 1].start() if index + 1 < len(starts) else len(access_unit)
|
||||
if match.end() < end and access_unit[match.end()] & 31 not in drop:
|
||||
units.append(b"\x00\x00\x00\x01" + access_unit[match.end():end])
|
||||
return b"".join(units) or access_unit
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoMode:
|
||||
channel: int
|
||||
@@ -469,6 +492,16 @@ class Session:
|
||||
item["input_config"] = parse_input_config(one(fields, 4))
|
||||
except ValueError:
|
||||
item["input_config"] = InputConfig()
|
||||
if isinstance(one(fields, SENSOR_SERVICE), bytes):
|
||||
try:
|
||||
item["sensors"] = [one(parse_fields(s), 1) for s in parse_fields(one(fields, SENSOR_SERVICE)).get(1, []) if isinstance(s, bytes)]
|
||||
except ValueError:
|
||||
item["sensors"] = []
|
||||
if isinstance(one(fields, BLUETOOTH_SERVICE), bytes):
|
||||
try:
|
||||
item["bluetooth"] = parse_bluetooth(one(fields, BLUETOOTH_SERVICE))
|
||||
except ValueError:
|
||||
item["bluetooth"] = {}
|
||||
channels.append(item)
|
||||
# Everything but the channel list: make, model, year, software and, on newer units, headunit_info.
|
||||
head_unit = redact_head_unit(describe_fields({number: values for number, values in response.items() if number != 1}))
|
||||
@@ -499,6 +532,33 @@ HEAD_UNIT_INFO = 17 # ServiceDiscoveryResponse.headunit_info
|
||||
HEAD_UNIT_INFO_VEHICLE_ID = 4
|
||||
|
||||
|
||||
def packed_varints(value) -> list[int]:
|
||||
"""A repeated enum: packed bytes on newer units, one varint per field on older ones."""
|
||||
if isinstance(value, int):
|
||||
return [value]
|
||||
out, pos = [], 0
|
||||
while pos < len(value):
|
||||
number, shift = 0, 0
|
||||
while True:
|
||||
if pos >= len(value) or shift > 63:
|
||||
raise ValueError("Truncated packed varint")
|
||||
byte = value[pos]
|
||||
pos += 1
|
||||
number |= (byte & 127) << shift
|
||||
shift += 7
|
||||
if byte < 128:
|
||||
break
|
||||
out.append(number)
|
||||
return out
|
||||
|
||||
|
||||
def parse_bluetooth(data: bytes) -> dict:
|
||||
fields = parse_fields(data)
|
||||
address = one(fields, 1)
|
||||
methods = [m for value in fields.get(2, []) for m in packed_varints(value)]
|
||||
return {"car_address": address.decode(errors="replace") if isinstance(address, bytes) else "", "pairing_methods": methods}
|
||||
|
||||
|
||||
def redact_head_unit(described: dict) -> dict:
|
||||
"""Drop the vehicle identifier from a described discovery response: logs are shared for diagnosis."""
|
||||
if SDR_VEHICLE_ID in described:
|
||||
@@ -549,6 +609,13 @@ class ProjectionSession(Session):
|
||||
self.epoch_acked = 0
|
||||
self.video_confirmed = False
|
||||
self.input_channel: int | None = None
|
||||
self.max_window = self.MAX_WINDOW # raised by the supervisor for adapters that need the car's full window
|
||||
self.phone_services = False # open the car-data and Bluetooth channels like a phone (adapter profile)
|
||||
self.phone_frames = False # send picture NAL units only (adapter profile)
|
||||
self.phone_address = "" # this comma's Bluetooth address, for the pairing request
|
||||
self.sensor_channel: int | None = None
|
||||
self.sensor_events: dict[int, int] = {}
|
||||
self.bluetooth_channel: int | None = None
|
||||
self.nav = None # NavStatus, attached by the supervisor when the head unit offers turn-by-turn
|
||||
self.audio = None # MediaAudio, attached by the supervisor when Spotify plays through Android Auto
|
||||
self.input_events = 0
|
||||
@@ -567,6 +634,8 @@ class ProjectionSession(Session):
|
||||
self.mode = choose_video_mode(self.channels)
|
||||
self.open_video()
|
||||
self.open_input()
|
||||
if self.phone_services:
|
||||
self.open_phone_services()
|
||||
self.request_projection()
|
||||
return self.mode
|
||||
|
||||
@@ -585,7 +654,7 @@ class ProjectionSession(Session):
|
||||
configs = setup.get(3, [])
|
||||
if configs and mode.config_index not in configs:
|
||||
raise ValueError(f"Head unit did not accept video configuration {mode.config_index}: {json_fields(setup)}")
|
||||
self.window = min(window, self.MAX_WINDOW)
|
||||
self.window = min(window, self.max_window)
|
||||
self.event("video_setup", mode=mode.as_dict(), window=window, used_window=self.window)
|
||||
|
||||
def open_input(self) -> None:
|
||||
@@ -609,6 +678,36 @@ class ProjectionSession(Session):
|
||||
except (TimeoutError, ValueError) as error:
|
||||
self.event("input_unavailable", error=str(error))
|
||||
|
||||
def _open_optional(self, channel: dict, name: str) -> bool:
|
||||
try:
|
||||
self.send(channel["id"], MSG_CHANNEL_OPEN_REQUEST, field(1, 0) + field(2, channel["id"]), control=True)
|
||||
opened = parse_fields(self.wait_for(channel["id"], MSG_CHANNEL_OPEN_RESPONSE, timeout=3.0))
|
||||
if signed(one(opened, 1)) != 0:
|
||||
raise ValueError(f"channel open rejected: {signed(one(opened, 1))}")
|
||||
return True
|
||||
except (TimeoutError, ValueError) as error:
|
||||
self.event(f"{name}_unavailable", channel=channel["id"], error=str(error))
|
||||
return False
|
||||
|
||||
def open_phone_services(self) -> None:
|
||||
"""Open the car-data and Bluetooth channels before asking for the screen, as a phone does. Failures are not fatal."""
|
||||
sensors = next((ch for ch in self.channels if "sensors" in ch), None)
|
||||
if sensors is not None and self._open_optional(sensors, "sensors"):
|
||||
self.sensor_channel = sensors["id"]
|
||||
# Only what decides whether the screen may project; location would put the car's position in the session log.
|
||||
wanted = [kind for kind in (SENSOR_DRIVING_STATUS, SENSOR_NIGHT) if kind in sensors["sensors"]]
|
||||
for kind in wanted:
|
||||
self.send(sensors["id"], SENSOR_START_REQUEST, field(1, kind) + field(2, 0))
|
||||
self.event("sensors_opened", channel=sensors["id"], requested=wanted)
|
||||
bluetooth = next((ch for ch in self.channels if "bluetooth" in ch), None)
|
||||
if bluetooth is not None and self._open_optional(bluetooth, "bluetooth"):
|
||||
self.bluetooth_channel = bluetooth["id"]
|
||||
methods = bluetooth["bluetooth"].get("pairing_methods") or []
|
||||
if self.phone_address:
|
||||
self.send(bluetooth["id"], BLUETOOTH_PAIRING_REQUEST, field(1, self.phone_address) + field(2, methods[0] if methods else 2))
|
||||
self.event("bluetooth_opened", channel=bluetooth["id"], phone_address=self.phone_address,
|
||||
car_address=bluetooth["bluetooth"].get("car_address", ""), pairing_methods=methods)
|
||||
|
||||
def request_projection(self) -> None:
|
||||
"""Ask for display focus; the Mazda donor needed this, DHU grants it unsolicited."""
|
||||
assert self.mode is not None
|
||||
@@ -693,6 +792,24 @@ class ProjectionSession(Session):
|
||||
self.event("input_bound", status=signed(one(fields, 1, 0)))
|
||||
else:
|
||||
self.ignored("input_ignored", channel, kind, data)
|
||||
elif channel is not None and channel == self.sensor_channel:
|
||||
if kind == SENSOR_START_RESPONSE:
|
||||
self.event("sensor_started", status=signed(one(fields, 1, 0)))
|
||||
elif kind == SENSOR_EVENT:
|
||||
for number, values in fields.items():
|
||||
first = number not in self.sensor_events
|
||||
self.sensor_events[number] = self.sensor_events.get(number, 0) + len(values)
|
||||
if first:
|
||||
self.event("sensor_event", field=number, data=describe_fields(fields))
|
||||
else:
|
||||
self.ignored("sensor_ignored", channel, kind, data)
|
||||
elif channel is not None and channel == self.bluetooth_channel:
|
||||
if kind == BLUETOOTH_PAIRING_RESPONSE:
|
||||
self.event("bluetooth_pairing_response", already_paired=one(fields, 1), status=signed(one(fields, 2, 0)))
|
||||
elif kind == BLUETOOTH_AUTH_DATA:
|
||||
self.event("bluetooth_auth_data", data=describe_fields(fields))
|
||||
else:
|
||||
self.ignored("bluetooth_ignored", channel, kind, data)
|
||||
else:
|
||||
self.ignored("channel_ignored", channel, kind, data)
|
||||
|
||||
@@ -803,12 +920,15 @@ class ProjectionSession(Session):
|
||||
raise ValueError("A fresh media epoch must start with SPS/PPS and an IDR frame")
|
||||
assert self.mode is not None
|
||||
fragments = 0
|
||||
config = b""
|
||||
if self.needs_keyframe:
|
||||
config = codec_config(data)
|
||||
if config:
|
||||
fragments += self.send(self.mode.channel, AV_MEDIA_CODEC_CONFIG, config)
|
||||
if not self.fragment_acks:
|
||||
self.config_ack_slack += 1
|
||||
if self.phone_frames:
|
||||
data = wire_frame(data, drop_config=bool(config))
|
||||
fragments += self.send(self.mode.channel, AV_MEDIA_WITH_TIMESTAMP, struct.pack(">Q", timestamp_us) + data)
|
||||
if self.fragment_acks:
|
||||
self.frame_fragments.append(fragments)
|
||||
@@ -839,4 +959,5 @@ class ProjectionSession(Session):
|
||||
def stats(self) -> dict:
|
||||
return {"frames_sent": self.frames_sent, "frames_acked": self.acked, "pending": self.unacked,
|
||||
"focus_epoch": self.focus_epoch, "focused": self.focused, "max_ack_ms": round(self.max_ack_seconds * 1000),
|
||||
"input_events": self.input_events, "touch": self.touch is not None}
|
||||
"input_events": self.input_events, "touch": self.touch is not None,
|
||||
"sensor_events": sum(self.sensor_events.values())}
|
||||
|
||||
@@ -353,6 +353,7 @@ class Supervisor:
|
||||
status["pairing_note"] = self._pairing_note
|
||||
status["car_type"] = self.car_type()
|
||||
status.update(car_profiles.summary(self.config))
|
||||
status["adapter_choice_needed"] = self._adapter_choice_needed()
|
||||
status["configured_view"] = self.config["view"]
|
||||
status["connection"] = self.config["connection"]
|
||||
status["wired_blocked"] = wired_blocker()
|
||||
@@ -387,10 +388,15 @@ class Supervisor:
|
||||
identity_store.save_config(self.config)
|
||||
self.log("dedicated_wifi_selected", enabled=bool(enabled))
|
||||
|
||||
def _adapter_choice_needed(self) -> bool:
|
||||
return self.car_type() == "adapter" and not self.config.get("car_profile_override")
|
||||
|
||||
def start(self, trigger: str = "manual") -> None:
|
||||
with self._lock:
|
||||
if not self._projection_enabled():
|
||||
raise RuntimeError("Android Auto is disabled")
|
||||
if self._adapter_choice_needed():
|
||||
raise RuntimeError("Choose your adapter model (AAWireless v1 or Other) in Galaxy first")
|
||||
if self.config["connection"] == "wired" and (reason := wired_blocker()):
|
||||
raise RuntimeError(reason)
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
@@ -512,6 +518,8 @@ class Supervisor:
|
||||
if car_type in ("builtin", "adapter"):
|
||||
with self._lock:
|
||||
self.config["car_type"] = car_type
|
||||
if car_type != "adapter":
|
||||
self.config["car_profile_override"] = "" # the adapter model only applies to a wireless adapter
|
||||
identity_store.save_config(self.config)
|
||||
self.log("car_type_selected", car_type=car_type)
|
||||
|
||||
@@ -533,6 +541,8 @@ class Supervisor:
|
||||
|
||||
def prepare_pairing(self, seconds: float = 180.0) -> None:
|
||||
"""Present as a phone (HFP gateway, smartphone class) while the car pairs."""
|
||||
if self._adapter_choice_needed():
|
||||
raise RuntimeError("Choose your adapter model (AAWireless v1 or Other) in Galaxy first")
|
||||
if self._bluez_factory is None and (self._source_session is None or self._shared_bluetooth_owner is None or
|
||||
not self._shared_bluetooth_owner.session_valid(self._source_session)):
|
||||
raise RuntimeError('Current Galaxy pairing authorization required')
|
||||
@@ -788,8 +798,9 @@ class Supervisor:
|
||||
if not self._no_aa_since:
|
||||
self._no_aa_since = now
|
||||
elif now - self._no_aa_since >= NO_ANDROID_AUTO_WAIT and not self._pairing_note:
|
||||
self._pairing_note = (f"{device['name'] or 'This device'} paired, but it doesn't offer wireless Android Auto. "
|
||||
"Using a wireless adapter? Choose Wireless adapter, pair the adapter here, then connect your car on the Bluetooth page.")
|
||||
name = device["name"] or "This device"
|
||||
hint = "Using a wireless adapter? Choose Wireless adapter, pair the adapter here, then connect your car on the Bluetooth page."
|
||||
self._pairing_note = f"{name} paired, but it doesn't offer wireless Android Auto. {hint}"
|
||||
self._pairing_step("no_android_auto")
|
||||
self.log("pairing_no_android_auto", address=device["address"])
|
||||
|
||||
@@ -1352,12 +1363,25 @@ class Supervisor:
|
||||
self._wait(1.0 + attempt * 0.5)
|
||||
raise RuntimeError(f"Car did not accept the projection connection: {last_error}")
|
||||
|
||||
def _adapter_address(self) -> str:
|
||||
"""This comma's Bluetooth address for the head unit's pairing request; empty when BlueZ is not up."""
|
||||
try:
|
||||
return str(self._bluez.adapter()[1].get("Address", "")).upper() if self._bluez is not None else ""
|
||||
except Exception: # DBus errors vary; the pairing request is optional
|
||||
return ""
|
||||
|
||||
def _project(self, result, lease, ident, connect=None, fragment_acks: bool = False) -> None:
|
||||
config = self.config
|
||||
sock = connect() if connect is not None else self._connect_tcp(result, lease)
|
||||
ca = ident.root if config.get("verify_head_unit", False) else None
|
||||
session = ProjectionSession(sock, ident.cert, ident.key, self.log, ca)
|
||||
session.fragment_acks = fragment_acks
|
||||
tuning = car_profiles.tuning(config)
|
||||
session.max_window = tuning["max_window"]
|
||||
session.phone_frames = tuning["phone_frames"]
|
||||
if tuning["phone_services"]:
|
||||
session.phone_services = True
|
||||
session.phone_address = self._adapter_address()
|
||||
self._stage("authenticating")
|
||||
session.authenticate()
|
||||
self._stage("negotiating")
|
||||
|
||||
@@ -63,12 +63,21 @@ def video_config(resolution: int, margin_w: int = 0, margin_h: int = 0) -> bytes
|
||||
return field(1, resolution) + field(2, 2) + field(3, margin_w) + field(4, margin_h) + field(5, 160)
|
||||
|
||||
|
||||
def discovery_response(video_channel: int = 3, input_channel: int = 1) -> bytes:
|
||||
SENSOR_CHANNEL, BLUETOOTH_CHANNEL = 7, 8
|
||||
|
||||
|
||||
def discovery_response(video_channel: int = 3, input_channel: int = 1, *, phone_services: bool = False) -> bytes:
|
||||
"""``phone_services`` adds the car-data and Bluetooth channels (sensor types one per entry, pairing method packed)."""
|
||||
av = field(1, 3) + field(4, video_config(1)) + field(4, video_config(2, 0, 240))
|
||||
video = field(1, video_channel) + field(3, av)
|
||||
audio = field(1, 4) + field(3, field(1, 1))
|
||||
touch = field(1, input_channel) + field(4, field(1, 84) + field(1, 4) + field(2, field(1, 1280) + field(2, 720)))
|
||||
return field(1, video) + field(1, audio) + field(1, touch) + field(2, "Honda") + field(3, "Civic")
|
||||
extra = b""
|
||||
if phone_services:
|
||||
sensors = b"".join(field(1, field(1, kind)) for kind in (1, 3, 7, 8, 10, 13, 21))
|
||||
extra = (field(1, field(1, SENSOR_CHANNEL) + field(2, sensors)) +
|
||||
field(1, field(1, BLUETOOTH_CHANNEL) + field(6, field(1, "F8:36:9B:0A:7D:C8") + field(2, bytes([2])))))
|
||||
return field(1, video) + field(1, audio) + field(1, touch) + extra + field(2, "Honda") + field(3, "Civic")
|
||||
|
||||
|
||||
class FakeHeadUnit:
|
||||
@@ -76,8 +85,13 @@ class FakeHeadUnit:
|
||||
|
||||
def __init__(self, identity: dict[str, Path], *, window: int = 4, reject_auth: bool = False, require_client_cert: bool = True,
|
||||
unsolicited_focus: bool = False, version: tuple[int, int] = (1, 7), ack_codec_config: bool | int = True,
|
||||
connect_to: tuple[str, int] | None = None, ack_fragments: bool = False):
|
||||
connect_to: tuple[str, int] | None = None, ack_fragments: bool = False,
|
||||
phone_services: bool = False):
|
||||
self.phone_services = phone_services
|
||||
self.sensor_requests: list[int] = []
|
||||
self.pairing_requests: list[tuple[str, int]] = []
|
||||
self.unsolicited_focus = unsolicited_focus
|
||||
self.opened: list[int] = []
|
||||
self.connect_to = connect_to
|
||||
self.ack_fragments = ack_fragments
|
||||
self.fragments = 0
|
||||
@@ -218,7 +232,7 @@ class FakeHeadUnit:
|
||||
fields = parse_fields(data) if kind not in (0, 1) else {}
|
||||
if channel == 0 and kind == 5:
|
||||
self.device_name = one(fields, 4, b"").decode()
|
||||
self._send(0, 6, discovery_response())
|
||||
self._send(0, 6, discovery_response(phone_services=self.phone_services))
|
||||
elif channel == 0 and kind == 11:
|
||||
self._send(0, 12, data)
|
||||
elif channel == 0 and kind == 15:
|
||||
@@ -226,7 +240,17 @@ class FakeHeadUnit:
|
||||
self.shutdown_received.set()
|
||||
return
|
||||
elif kind == 7:
|
||||
self.opened.append(channel)
|
||||
self._send(channel, 8, field(1, 0), control=True)
|
||||
elif self.phone_services and channel == SENSOR_CHANNEL and kind == 0x8001:
|
||||
self.sensor_requests.append(int(one(fields, 1, 0) or 0))
|
||||
self._send(SENSOR_CHANNEL, 0x8002, field(1, 0))
|
||||
if one(fields, 1) == 13:
|
||||
self._send(SENSOR_CHANNEL, 0x8003, field(13, field(1, 0))) # driving status: unrestricted
|
||||
elif self.phone_services and channel == BLUETOOTH_CHANNEL and kind == 0x8001:
|
||||
address = one(fields, 1, b"")
|
||||
self.pairing_requests.append((address.decode() if isinstance(address, bytes) else "", int(one(fields, 2, 0) or 0)))
|
||||
self._send(BLUETOOTH_CHANNEL, 0x8002, field(1, 1) + field(2, 0)) # not paired
|
||||
elif channel == 3 and kind == 0x8000:
|
||||
self._send(3, 0x8003, field(1, 2) + field(2, self.window) + field(3, 1))
|
||||
if self.unsolicited_focus: # like the DHU: grant focus before the phone asks
|
||||
|
||||
@@ -12,8 +12,10 @@ import pytest
|
||||
from openpilot.starpilot.system.android_auto import bootstrap as bs
|
||||
from openpilot.starpilot.system.android_auto import hfp, sdp
|
||||
from openpilot.starpilot.system.android_auto.frame_source import FrameConsumer, FrameProducer, FrameRequest, fit_content
|
||||
from openpilot.starpilot.system.android_auto.session import (AuthenticationRejected, ProjectionSession, VideoMode, choose_video_mode)
|
||||
from openpilot.starpilot.system.android_auto.tests.fake_head_unit import FakeHeadUnit, make_identity, rfcomm_head_unit
|
||||
from openpilot.starpilot.system.android_auto.session import (AuthenticationRejected, ProjectionSession, START_CODE, VideoMode, choose_video_mode,
|
||||
wire_frame)
|
||||
from openpilot.starpilot.system.android_auto.tests.fake_head_unit import (BLUETOOTH_CHANNEL, SENSOR_CHANNEL, FakeHeadUnit, make_identity,
|
||||
rfcomm_head_unit)
|
||||
from openpilot.starpilot.system.android_auto.wire import field, one, parse_fields, signed
|
||||
|
||||
|
||||
@@ -569,6 +571,86 @@ def test_session_sends_codec_config_before_each_epoch(identity, ack_codec_config
|
||||
assert hu.codec_configs == [(1, sps_pps, 0), (2, sps_pps, 4)] # one per epoch, before its keyframe
|
||||
|
||||
|
||||
def test_wire_frame_drops_aud_and_optionally_config():
|
||||
aud, sps, pps, idr = b"\x00\x00\x00\x01\x09\xf0", b"\x00\x00\x00\x01\x67\x42", b"\x00\x00\x00\x01\x68\xce", b"\x00\x00\x00\x01\x65\x88"
|
||||
assert wire_frame(aud + sps + pps + idr, drop_config=True) == idr
|
||||
assert wire_frame(aud + sps + pps + idr, drop_config=False) == sps + pps + idr
|
||||
assert wire_frame(b"junk", drop_config=True) == b"junk"
|
||||
|
||||
|
||||
def test_session_sends_picture_units_only_when_asked(identity):
|
||||
hu = FakeHeadUnit(identity)
|
||||
session = connect(hu, identity)
|
||||
session.phone_frames = True
|
||||
session.authenticate()
|
||||
session.start("StarPilot", "comma.ai")
|
||||
pump_until(session, lambda: session.focused)
|
||||
session.send_frame(b"\x00\x00\x00\x01\x09\xf0\x00\x00\x00\x01\x67\x42\x00\x00\x00\x01\x68\xce\x00\x00\x00\x01\x65\x88", 1, keyframe=True)
|
||||
pump_until(session, lambda: session.unacked == 0)
|
||||
session.shutdown()
|
||||
session.peer.close()
|
||||
hu.thread.join(5)
|
||||
assert hu.error is None, hu.error
|
||||
for _, frame in hu.frames:
|
||||
assert frame and all(frame[m.end()] & 31 not in (7, 8, 9) for m in START_CODE.finditer(frame))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("max_window, offered, used", [(2, 30, 2), (30, 30, 30), (30, 100, 30), (30, 4, 4)])
|
||||
def test_session_streams_at_the_window_the_profile_allows(identity, max_window, offered, used):
|
||||
hu = FakeHeadUnit(identity, window=offered)
|
||||
session = connect(hu, identity)
|
||||
session.max_window = max_window
|
||||
session.authenticate()
|
||||
session.start("StarPilot", "comma.ai")
|
||||
assert session.window == used
|
||||
session.shutdown()
|
||||
session.peer.close()
|
||||
hu.thread.join(5)
|
||||
|
||||
|
||||
def test_session_opens_car_data_and_bluetooth_like_a_phone(identity):
|
||||
hu = FakeHeadUnit(identity, phone_services=True)
|
||||
session = connect(hu, identity)
|
||||
session.phone_services = True
|
||||
session.phone_address = "39:90:21:64:07:00"
|
||||
session.authenticate()
|
||||
session.start("StarPilot", "comma.ai")
|
||||
pump_until(session, lambda: session.focused and session.sensor_events)
|
||||
session.shutdown()
|
||||
session.peer.close()
|
||||
hu.thread.join(5)
|
||||
assert hu.error is None
|
||||
assert {SENSOR_CHANNEL, BLUETOOTH_CHANNEL} <= set(hu.opened)
|
||||
assert hu.sensor_requests == [13, 10] # driving status and night only; never location
|
||||
assert hu.pairing_requests == [("39:90:21:64:07:00", 2)]
|
||||
assert session.stats()["sensor_events"] == 1
|
||||
|
||||
|
||||
def test_session_leaves_car_data_alone_by_default(identity):
|
||||
hu = FakeHeadUnit(identity, phone_services=True)
|
||||
session = connect(hu, identity)
|
||||
session.authenticate()
|
||||
session.start("StarPilot", "comma.ai")
|
||||
pump_until(session, lambda: session.focused)
|
||||
session.shutdown()
|
||||
session.peer.close()
|
||||
hu.thread.join(5)
|
||||
assert hu.error is None and SENSOR_CHANNEL not in hu.opened and BLUETOOTH_CHANNEL not in hu.opened and hu.pairing_requests == []
|
||||
|
||||
|
||||
def test_session_projects_without_a_bluetooth_address(identity):
|
||||
hu = FakeHeadUnit(identity, phone_services=True)
|
||||
session = connect(hu, identity)
|
||||
session.phone_services = True
|
||||
session.authenticate()
|
||||
session.start("StarPilot", "comma.ai")
|
||||
pump_until(session, lambda: session.focused)
|
||||
session.shutdown()
|
||||
session.peer.close()
|
||||
hu.thread.join(5)
|
||||
assert hu.error is None and BLUETOOTH_CHANNEL in hu.opened and hu.pairing_requests == []
|
||||
|
||||
|
||||
def test_session_keeps_unsolicited_focus_grant(identity):
|
||||
hu = FakeHeadUnit(identity, unsolicited_focus=True)
|
||||
session = connect(hu, identity)
|
||||
|
||||
@@ -36,6 +36,36 @@ def config(**values):
|
||||
return {"receiver_address": ADAPTER, "car_profile_override": "", "car_fingerprint": {}, **values}
|
||||
|
||||
|
||||
def test_aawireless_tuning_applies_only_when_chosen():
|
||||
chosen = config(car_profile_override="aawireless", receiver_name="AAWireless-4F2A")
|
||||
assert car_profiles.resolve(chosen) == ("aawireless", "override")
|
||||
assert car_profiles.tuning(chosen) == {"connect_delay_s": 0.0, "max_window": 30, "phone_services": True, "phone_frames": True}
|
||||
assert car_profiles.summary(chosen)["car_profile_label"] == "AAWireless v1"
|
||||
for other in (config(receiver_name="AAWireless-4F2A"), config(car_profile_override="default")):
|
||||
assert car_profiles.tuning(other) == car_profiles.DEFAULTS
|
||||
|
||||
|
||||
def test_leaving_the_adapter_setup_clears_the_adapter_model(sup):
|
||||
sup.config["car_type"] = "adapter"
|
||||
sup.set_car_profile("aawireless")
|
||||
sup.set_car_type("builtin")
|
||||
assert sup.config["car_profile_override"] == ""
|
||||
|
||||
|
||||
def test_an_adapter_setup_must_choose_its_model_before_pairing_or_starting(sup):
|
||||
sup.config["car_type"] = "adapter"
|
||||
assert sup.status()["adapter_choice_needed"] is True
|
||||
with pytest.raises(RuntimeError, match="adapter model"):
|
||||
sup.prepare_pairing()
|
||||
with pytest.raises(RuntimeError, match="adapter model"):
|
||||
sup.start()
|
||||
sup.set_car_profile("default")
|
||||
assert sup.status()["adapter_choice_needed"] is False
|
||||
sup.config["car_type"] = "builtin"
|
||||
sup.config["car_profile_override"] = ""
|
||||
assert sup.status()["adapter_choice_needed"] is False
|
||||
|
||||
|
||||
def test_every_car_is_standard_without_rules():
|
||||
assert car_profiles.resolve(config(car_fingerprint={"address": ADAPTER, "car_make": "Hyundai"})) == ("default", "standard")
|
||||
assert car_profiles.tuning(config()) == car_profiles.DEFAULTS
|
||||
@@ -51,7 +81,7 @@ def test_fingerprint_picks_a_profile_only_for_the_chosen_receiver(palisade):
|
||||
|
||||
def test_tuning_keeps_only_known_keys(palisade):
|
||||
tuned = car_profiles.tuning(config(car_profile_override="palisade"))
|
||||
assert tuned == {"connect_delay_s": 10.0}
|
||||
assert tuned == {**car_profiles.DEFAULTS, "connect_delay_s": 10.0}
|
||||
|
||||
|
||||
def test_override_pins_a_profile_or_forces_standard(palisade):
|
||||
@@ -171,3 +201,20 @@ def test_the_new_result_has_a_bugsink_report():
|
||||
attempt.step("status", state="connected", prompt=False, approved=True)
|
||||
event = br.pairing_event({**attempt.record, "result": "paired_not_selected", "duration_s": 5.0})
|
||||
assert event["fingerprint"] == ["android-auto-pairing", "paired_not_selected"] and event["level"] == "warning"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stored, kept", [({"car_type": "adapter"}, True), ({"car_type": "", "companion_address": "AA:BB:CC:DD:EE:02"}, True),
|
||||
({"car_type": "builtin", "companion_address": "AA:BB:CC:DD:EE:02"}, False), ({}, False),
|
||||
({"car_type": "adapter", "car_profile_override": "aawireless"}, True)])
|
||||
def test_adapter_owners_from_before_the_model_choice_keep_connecting(tmp_path, stored, kept):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"config_version": 6, **stored}))
|
||||
config = identity_store.load_config(path)
|
||||
assert (config["car_profile_override"] != "") is kept
|
||||
assert config["car_profile_override"] == stored.get("car_profile_override", "default" if kept else "")
|
||||
|
||||
|
||||
def test_a_new_adapter_setup_is_not_migrated(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"config_version": identity_store.CONFIG_VERSION, "car_type": "adapter"}))
|
||||
assert identity_store.load_config(path)["car_profile_override"] == ""
|
||||
|
||||
Reference in New Issue
Block a user