that's not a power button

This commit is contained in:
firestar5683
2026-09-03 12:27:31 -05:00
parent 352b23ddf5
commit f18cf22104
8 changed files with 140 additions and 36 deletions
+1 -1
View File
@@ -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
+40 -14
View File
@@ -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}"))
+47 -5
View File
@@ -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")
@@ -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())
@@ -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})
@@ -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)
+11 -11
View File
@@ -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"
}
]
+4 -2
View File
@@ -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: