From f18cf22104e5b781a5eb57bd72158ad756c9c169 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:27:31 -0500 Subject: [PATCH] that's not a power button --- launch_env.sh | 2 +- starpilot/system/bluetooth/bluez.py | 54 ++++++++++++++----- starpilot/system/bluetooth/daemon.py | 52 ++++++++++++++++-- .../system/bluetooth/tests/test_bluetooth.py | 13 +++++ .../tests/test_wheel_controlsd.py | 16 ++++++ .../system/wheel_controls/wheel_controlsd.py | 11 ++-- system/hardware/tici/agnos.json | 22 ++++---- system/ui/lib/bluetooth_manager.py | 6 ++- 8 files changed, 140 insertions(+), 36 deletions(-) diff --git a/launch_env.sh b/launch_env.sh index d710cf7cd1..6f6d74ec51 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -21,7 +21,7 @@ fi export QCOM_PRIORITY=12 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="19.6.17" + export AGNOS_VERSION="19.6.19" fi if [ -z "$AGNOS_ACCEPTED_VERSIONS" ]; then diff --git a/starpilot/system/bluetooth/bluez.py b/starpilot/system/bluetooth/bluez.py index f58c95a0d8..b0226a85c0 100644 --- a/starpilot/system/bluetooth/bluez.py +++ b/starpilot/system/bluetooth/bluez.py @@ -106,6 +106,8 @@ class PairingAgent: class BlueZClient: def __init__(self): self.router = DBusRouter(open_dbus_connection(bus="SYSTEM")) + self._request_lock = threading.RLock() + self._closed = threading.Event() self.agent = PairingAgent() self._agent_filter = self.router.filter(MatchRule(type="method_call", interface=AGENT_IFACE, path=AGENT_PATH), bufsize=20) self._agent_queue = self._agent_filter.__enter__() @@ -114,17 +116,28 @@ class BlueZClient: self._register_agent() def close(self) -> None: + if self._closed.is_set(): + return + self._closed.set() + self.agent.clear() try: - self._call("/org/bluez", AGENT_MANAGER_IFACE, "UnregisterAgent", "o", (AGENT_PATH,)) + self._agent_queue.put_nowait(None) except Exception: pass + with self._request_lock: + try: + self._call("/org/bluez", AGENT_MANAGER_IFACE, "UnregisterAgent", "o", (AGENT_PATH,)) + except Exception: + pass self._agent_filter.__exit__(None, None, None) self.router.close() + self._agent_thread.join(timeout=1.0) def _call(self, path: str, interface: str, member: str, signature: str | None = None, body: tuple = (), timeout: float = 15.0): address = DBusAddress(path, bus_name=BLUEZ, interface=interface) message = new_method_call(address, member, signature, body) if signature is not None else new_method_call(address, member) - reply = self.router.send_and_get_reply(message, timeout=timeout) + with self._request_lock: + reply = self.router.send_and_get_reply(message, timeout=timeout) if reply.header.message_type == MessageType.error: error_name = reply.header.fields.get(HeaderFields.error_name, "org.bluez.Error.Failed") detail = reply.body[0] if reply.body else error_name @@ -140,8 +153,10 @@ class BlueZClient: self._call("/org/bluez", AGENT_MANAGER_IFACE, "RequestDefaultAgent", "o", (AGENT_PATH,)) def _agent_loop(self) -> None: - while True: + while not self._closed.is_set(): message = self._agent_queue.get() + if message is None: + break member = message.header.fields.get(HeaderFields.member, "") device_path = str(message.body[0]) if message.body else "" if member in {"RequestPinCode", "RequestPasskey", "RequestConfirmation", "RequestAuthorization", "AuthorizeService"}: @@ -160,11 +175,17 @@ class BlueZClient: self.agent.clear() else: raise RuntimeError(f"Unsupported pairing request: {member}") - self.router.send(new_method_return(message, response_signature, response_body)) + self._send_agent_reply(new_method_return(message, response_signature, response_body)) except PermissionError: - self.router.send(new_error(message, "org.bluez.Error.Rejected", "s", ("Pairing rejected",))) + self._send_agent_reply(new_error(message, "org.bluez.Error.Rejected", "s", ("Pairing rejected",))) except Exception as error: - self.router.send(new_error(message, "org.bluez.Error.Canceled", "s", (str(error),))) + self._send_agent_reply(new_error(message, "org.bluez.Error.Canceled", "s", (str(error),))) + + def _send_agent_reply(self, message: Any) -> None: + try: + self.router.send(message) + except Exception: + self._closed.set() def _handle_agent_request(self, message: Any, member: str, device_path: str) -> None: try: @@ -188,11 +209,11 @@ class BlueZClient: accepted, _ = self.agent.request("authorization", device_path) if not accepted: raise PermissionError - self.router.send(new_method_return(message, response_signature, response_body)) + self._send_agent_reply(new_method_return(message, response_signature, response_body)) except PermissionError: - self.router.send(new_error(message, "org.bluez.Error.Rejected", "s", ("Pairing rejected",))) + self._send_agent_reply(new_error(message, "org.bluez.Error.Rejected", "s", ("Pairing rejected",))) except Exception as error: - self.router.send(new_error(message, "org.bluez.Error.Canceled", "s", (str(error),))) + self._send_agent_reply(new_error(message, "org.bluez.Error.Canceled", "s", (str(error),))) def managed_objects(self) -> dict[str, dict[str, dict[str, Any]]]: body = self._call("/", OBJECT_MANAGER, "GetManagedObjects") @@ -252,20 +273,24 @@ class BlueZClient: def set_powered(self, powered: bool) -> None: path, _ = self.adapter() address = DBusAddress(path, bus_name=BLUEZ, interface=ADAPTER_IFACE) - reply = self.router.send_and_get_reply(Properties(address).set("Powered", "b", powered), timeout=10.0) + with self._request_lock: + reply = self.router.send_and_get_reply(Properties(address).set("Powered", "b", powered), timeout=10.0) if reply.header.message_type == MessageType.error: raise RuntimeError(str(reply.body[0] if reply.body else "Unable to change Bluetooth power")) def set_discoverable(self, discoverable: bool) -> None: path, _ = self.adapter() address = DBusAddress(path, bus_name=BLUEZ, interface=ADAPTER_IFACE) - reply = self.router.send_and_get_reply(Properties(address).set("Pairable", "b", True), timeout=10.0) + with self._request_lock: + reply = self.router.send_and_get_reply(Properties(address).set("Pairable", "b", True), timeout=10.0) if reply.header.message_type == MessageType.error: raise RuntimeError(str(reply.body[0] if reply.body else "Unable to enable Bluetooth pairing")) - reply = self.router.send_and_get_reply(Properties(address).set("DiscoverableTimeout", "u", 0), timeout=10.0) + with self._request_lock: + reply = self.router.send_and_get_reply(Properties(address).set("DiscoverableTimeout", "u", 0), timeout=10.0) if reply.header.message_type == MessageType.error: raise RuntimeError(str(reply.body[0] if reply.body else "Unable to configure Bluetooth discoverability")) - reply = self.router.send_and_get_reply(Properties(address).set("Discoverable", "b", discoverable), timeout=10.0) + with self._request_lock: + reply = self.router.send_and_get_reply(Properties(address).set("Discoverable", "b", discoverable), timeout=10.0) if reply.header.message_type == MessageType.error: raise RuntimeError(str(reply.body[0] if reply.body else "Unable to change Bluetooth discoverability")) @@ -288,7 +313,8 @@ class BlueZClient: def set_device_property(self, address: str, name: str, signature: str, value: Any) -> None: device = self.device_for_address(address) dbus_address = DBusAddress(device["path"], bus_name=BLUEZ, interface=DEVICE_IFACE) - reply = self.router.send_and_get_reply(Properties(dbus_address).set(name, signature, value), timeout=10.0) + with self._request_lock: + reply = self.router.send_and_get_reply(Properties(dbus_address).set(name, signature, value), timeout=10.0) if reply.header.message_type == MessageType.error: raise RuntimeError(str(reply.body[0] if reply.body else f"Unable to set {name}")) diff --git a/starpilot/system/bluetooth/daemon.py b/starpilot/system/bluetooth/daemon.py index 60499bfb38..9851bb40f9 100644 --- a/starpilot/system/bluetooth/daemon.py +++ b/starpilot/system/bluetooth/daemon.py @@ -17,6 +17,9 @@ OFFROAD_COMMANDS = {"set_power", "start_scan", "stop_scan", "pair", "forget", "t SCAN_DURATION = 20.0 AUDIO_TEST_START_DELAY = 3.0 AUDIO_TEST_HOLD_TIME = 3.0 +RECONNECT_INTERVAL_SECONDS = 15.0 +RECONNECT_MAX_BACKOFF_SECONDS = 300.0 +MANUAL_DISCONNECT_SUPPRESSION_SECONDS = 300.0 class BluetoothController: @@ -31,6 +34,8 @@ class BluetoothController: self._pairing_address = "" self._pairing_error = "" self._last_reconnect = 0.0 + self._reconnect_backoff: dict[str, tuple[int, float]] = {} + self._manual_disconnect_until: dict[str, float] = {} self._scan_deadline = 0.0 self._audio_test_deadline = 0.0 self._sleep = sleep @@ -201,15 +206,34 @@ class BluetoothController: raise RuntimeError("Another Bluetooth device is already pairing") self._client().device_for_address(address) self._scan_deadline = 0.0 + self._reconnect_backoff.pop(address.upper(), None) + self._manual_disconnect_until.pop(address.upper(), None) self._pairing_address = address self._pairing_error = "" threading.Thread(target=self._pair_worker, args=(address,), daemon=True).start() elif command == "connect": - self._client().connect(address) + normalized_address = address.upper() + self._reconnect_backoff.pop(normalized_address, None) + self._manual_disconnect_until.pop(normalized_address, None) + with self._lock: + self._client().connect(normalized_address) elif command == "disconnect": - self._client().disconnect(address) + normalized_address = address.upper() + # Mark this before issuing the D-Bus call. A disconnected device can + # report NotConnected, and it must not immediately be auto-reconnected. + self._manual_disconnect_until[normalized_address] = time.monotonic() + MANUAL_DISCONNECT_SUPPRESSION_SECONDS + self._reconnect_backoff.pop(normalized_address, None) + try: + with self._lock: + self._client().disconnect(normalized_address) + except RuntimeError as error: + if "notconnected" not in str(error).replace(" ", "").lower(): + self._manual_disconnect_until.pop(normalized_address, None) + raise elif command == "forget": self._client().remove(address) + self._reconnect_backoff.pop(address.upper(), None) + self._manual_disconnect_until.pop(address.upper(), None) if (self.params.get("BluetoothAudioAddress", encoding="utf-8") or "").upper() == address.upper(): self.params.remove("BluetoothAudioAddress") elif command == "select_audio": @@ -259,18 +283,36 @@ class BluetoothController: continue now = time.monotonic() self._maintain_scan(status, now) - if self._pairing_address or now - self._last_reconnect < 15: + if self._pairing_address or now - self._last_reconnect < RECONNECT_INTERVAL_SECONDS: continue self._last_reconnect = now selected = str(status["selected_audio"]) candidates = [device for device in status["devices"] if device["paired"] and device["trusted"] and not device["connected"]] candidates.sort(key=lambda device: device["address"].upper() != selected.upper()) + candidate_addresses = {device["address"].upper() for device in candidates} + for address in list(self._manual_disconnect_until): + if address not in candidate_addresses or now >= self._manual_disconnect_until[address]: + self._manual_disconnect_until.pop(address, None) + for address in list(self._reconnect_backoff): + if address not in candidate_addresses: + self._reconnect_backoff.pop(address, None) for device in candidates: if device["audio"] or device["controller"]: + address = device["address"].upper() + if now < self._manual_disconnect_until.get(address, 0.0): + continue + _attempts, retry_after = self._reconnect_backoff.get(address, (0, 0.0)) + if now < retry_after: + continue try: - self._client().connect(device["address"]) + with self._lock: + self._client().connect(address) + self._reconnect_backoff.pop(address, None) except Exception: - cloudlog.warning(f"Bluetooth reconnect failed for {device['address']}") + attempts = _attempts + 1 + delay = min(RECONNECT_INTERVAL_SECONDS * (2 ** (attempts - 1)), RECONNECT_MAX_BACKOFF_SECONDS) + self._reconnect_backoff[address] = (attempts, now + delay) + cloudlog.warning(f"Bluetooth reconnect failed for {address}; retrying in {delay:.0f}s") except Exception: cloudlog.exception("Bluetooth connection maintenance failed") diff --git a/starpilot/system/bluetooth/tests/test_bluetooth.py b/starpilot/system/bluetooth/tests/test_bluetooth.py index 6880e51ac6..27cff7f0da 100644 --- a/starpilot/system/bluetooth/tests/test_bluetooth.py +++ b/starpilot/system/bluetooth/tests/test_bluetooth.py @@ -293,6 +293,19 @@ def test_power_pair_audio_and_offroad_enforcement(): assert not params.get_bool("BluetoothEnabled") and radio.stops == 1 and clients[0].closed +def test_disconnect_is_idempotent_and_suppresses_auto_reconnect(): + params = FakeParams(IsOffroad=False, BluetoothEnabled=True) + client = FakeBlueZ() + controller = BluetoothController(params, lambda: client, FakeRadio()) + + controller.handle({"command": "disconnect", "address": client.device["address"]}) + + address = client.device["address"].upper() + assert client.actions == [("disconnect", client.device["address"])] + assert address in controller._manual_disconnect_until + assert controller._manual_disconnect_until[address] > time.monotonic() + + def test_power_off_preserves_saved_audio_selection(): params = FakeParams(IsOffroad=True, BluetoothEnabled=False, BluetoothAudioAddress="00:11:22:33:44:55") controller = BluetoothController(params, FakeBlueZ, FakeRadio()) diff --git a/starpilot/system/wheel_controls/tests/test_wheel_controlsd.py b/starpilot/system/wheel_controls/tests/test_wheel_controlsd.py index ffaf05d862..993f690a2d 100644 --- a/starpilot/system/wheel_controls/tests/test_wheel_controlsd.py +++ b/starpilot/system/wheel_controls/tests/test_wheel_controlsd.py @@ -287,6 +287,22 @@ def test_only_key_down_is_dispatched(monkeypatch): daemon.close() +def test_stale_selector_event_after_controller_disconnect_is_ignored(): + params = FakeParams({"IsOffroad": False}) + memory = FakeParams() + daemon = wheel_controlsd.WheelControlsDaemon(params, memory) + read_fd, write_fd = os.pipe() + os.set_blocking(read_fd, False) + os.write(write_fd, wheel_controlsd.INPUT_EVENT.pack(0, 0, wheel_controlsd.EV_KEY, 30, 1)) + + daemon._read_events(read_fd) + + assert read_fd not in daemon.sources + assert read_fd not in daemon.buffers + os.close(write_fd) + daemon.close() + + def test_learning_is_cancelled_onroad(): params = FakeParams({"IsOffroad": False}) memory = FakeParams({wheel_controlsd.LEARN_SLOT_PARAM: 1}) diff --git a/starpilot/system/wheel_controls/wheel_controlsd.py b/starpilot/system/wheel_controls/wheel_controlsd.py index 3acc398684..d5ddfe630c 100644 --- a/starpilot/system/wheel_controls/wheel_controlsd.py +++ b/starpilot/system/wheel_controls/wheel_controlsd.py @@ -627,9 +627,11 @@ class WheelControlsDaemon: self._remove(fd) return - buffer = self.buffers[fd] + buffer = self.buffers.get(fd) + source = self.sources.get(fd) + if buffer is None or source is None: + return buffer.extend(chunk) - source = self.sources[fd] while len(buffer) >= INPUT_EVENT.size: raw = bytes(buffer[:INPUT_EVENT.size]) del buffer[:INPUT_EVENT.size] @@ -666,7 +668,10 @@ class WheelControlsDaemon: self._scan_devices() self.last_scan = now for key, _mask in self.selector.select(timeout=0.1): - self._read_events(key.fd) + try: + self._read_events(key.fd) + except (KeyError, OSError): + self._remove(key.fd) now = time.monotonic() if now - self.last_status >= STATUS_INTERVAL_SECONDS: self._publish_status(now) diff --git a/system/hardware/tici/agnos.json b/system/hardware/tici/agnos.json index d32542e5cb..6684519d0d 100644 --- a/system/hardware/tici/agnos.json +++ b/system/hardware/tici/agnos.json @@ -56,30 +56,30 @@ }, { "name": "boot", - "url": "https://files.firestar.link/x/wqn7kn631kdx/boot27.img.xz", + "url": "https://files.firestar.link/x/5gjbdh7zoxox/boot28.img.xz", "fallback_urls": [ - "https://files-east.firestar.link/x/0zzizffpkz3a/boot27.img.xz" + "https://files-east.firestar.link/x/9mxc942n97e0/boot28.img.xz" ], - "hash": "173208aec963fa35a6d9a92c7ca77c5f2b49797def096f3cce05591e78b12460", - "hash_raw": "173208aec963fa35a6d9a92c7ca77c5f2b49797def096f3cce05591e78b12460", - "size": 48781312, + "hash": "9d826668e81b51d493947f91bc436bb94d3f276364737760b59a6660dae366d6", + "hash_raw": "9d826668e81b51d493947f91bc436bb94d3f276364737760b59a6660dae366d6", + "size": 49043456, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "bc253532027b4756c09ea235b6fd794641d115b6ddbc05c417d6eb89475a22b8" + "ondevice_hash": "d7b383535d9f8228e871710ff2973e3ccedace4e8a8b79e89d34fd0d0fde00a7" }, { "name": "system", - "url": "https://files.firestar.link/x/877rsvjzkmp8/system27.img.xz", + "url": "https://files.firestar.link/x/pre1xtwygykq/system28.img.xz", "fallback_urls": [ - "https://files-east.firestar.link/x/5ni1fkd2k4hg/system27.img.xz" + "https://files-east.firestar.link/x/hpfu9a8221uj/system28.img.xz" ], - "hash": "205dfc25b21eb236cefaabd081aa4ad6e15ccd1c9f9fc623ec29de0c1c70fb06", - "hash_raw": "6f204321089c7ec4bae2e8b10eda827c502e6b516e6095a3b659016b4a646d38", + "hash": "bb7fe46c2f1f49fefed7226218b8786e5f7e013cef1eaa5f2fb8ee044e087c3a", + "hash_raw": "c967bcb9d5f7d2194a22de68d0ed66c0de44e9fd25cd650f99d9497d4d9a2a57", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "a8edcedcf4e6fb2a9af2e723a54f763f1f542fb63742cd1efc913ed30035c237" + "ondevice_hash": "50e041fd2348dac61003f521f19b9311f0667d7bca443a12439dfd835bf5daa8" } ] diff --git a/system/ui/lib/bluetooth_manager.py b/system/ui/lib/bluetooth_manager.py index 9be2e5ce46..d62c65f490 100644 --- a/system/ui/lib/bluetooth_manager.py +++ b/system/ui/lib/bluetooth_manager.py @@ -86,7 +86,8 @@ class BluetoothManager: def worker(): try: - fn(*args) + with self._client_lock: + fn(*args) except Exception as error: with self._lock: self._operation_error = str(error) @@ -137,7 +138,8 @@ class BluetoothManager: def test_audio(self, address: str) -> None: def worker(): try: - delay = self._client.test_audio(address) + with self._client_lock: + delay = self._client.test_audio(address) with self._lock: self._audio_test_deadline = time.monotonic() + delay except Exception as error: