mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-09 22:13:43 +08:00
1019 lines
38 KiB
Python
1019 lines
38 KiB
Python
from unittest.mock import Mock
|
|
import socket
|
|
import ssl
|
|
import struct
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
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.wire import field, one, parse_fields, signed
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def identity(tmp_path_factory):
|
|
return make_identity(tmp_path_factory.mktemp("identity"))
|
|
|
|
|
|
# ---------------------------------------------------------------------- wire
|
|
|
|
def test_wire_roundtrip_and_negative_ints():
|
|
data = field(1, 5) + field(2, "abc") + field(3, -1)
|
|
fields = parse_fields(data)
|
|
assert one(fields, 1) == 5 and one(fields, 2) == b"abc" and signed(one(fields, 3)) == -1
|
|
|
|
|
|
def test_wire_rejects_truncation():
|
|
with pytest.raises(ValueError):
|
|
parse_fields(field(2, "abcdef")[:-2])
|
|
|
|
|
|
# ----------------------------------------------------------------------- sdp
|
|
|
|
def sdp_response(attributes: bytes, continuation: bytes = b"") -> bytes:
|
|
params = struct.pack(">H", len(attributes)) + attributes + bytes([len(continuation)]) + continuation
|
|
return struct.pack(">BHH", sdp.PDU_SEARCH_ATTRIBUTE_RESPONSE, 0, len(params)) + params
|
|
|
|
|
|
PROTOCOLS = b"\x35\x20\x09\x00\x04\x35\x0c\x35\x03\x19\x01\x00\x35\x05\x19\x00\x03\x08\x08" # L2CAP + RFCOMM ch 8
|
|
AA_RECORD = PROTOCOLS + b"\x09\x00\x01\x35\x11\x1c" + sdp.AA_WIRELESS_UUID.bytes
|
|
|
|
|
|
class FakeSdpSocket:
|
|
def __init__(self, responses):
|
|
self.responses = list(responses)
|
|
self.requests = []
|
|
|
|
def settimeout(self, _):
|
|
pass
|
|
|
|
def send(self, data):
|
|
self.requests.append(data)
|
|
|
|
def recv(self, _):
|
|
return self.responses.pop(0)
|
|
|
|
|
|
class ClosableSdpSocket(FakeSdpSocket):
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
pass
|
|
|
|
def shutdown(self, *_):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
def test_sdp_request_matches_phone_shape():
|
|
request = sdp.build_request(b"")
|
|
assert request[0] == sdp.PDU_SEARCH_ATTRIBUTE_REQUEST and request[1:3] == b"\x00\x00"
|
|
assert sdp.AA_WIRELESS_UUID.bytes in request and b"\x03\xf0" in request and request.endswith(b"\x00")
|
|
|
|
|
|
def test_sdp_follows_continuation_and_finds_channel():
|
|
first, second = AA_RECORD[:10], AA_RECORD[10:]
|
|
sock = FakeSdpSocket([sdp_response(first, b"\x01\x02"), sdp_response(second)])
|
|
assert sdp.query_channel(sock) == 8
|
|
assert sock.requests[1].endswith(b"\x02\x01\x02")
|
|
|
|
|
|
def test_sdp_missing_service_and_error_response():
|
|
with pytest.raises(sdp.SdpError, match="does not advertise"):
|
|
sdp.query_channel(FakeSdpSocket([sdp_response(b"\x35\x00")]))
|
|
error = struct.pack(">BHHH", sdp.PDU_ERROR_RESPONSE, 0, 2, 3)
|
|
with pytest.raises(sdp.SdpError, match="invalid request syntax"):
|
|
sdp.query_channel(FakeSdpSocket([error]))
|
|
|
|
|
|
def test_sdp_channel_encodings():
|
|
assert sdp.rfcomm_channel(b"\x19\x00\x03\x09\x00\x0c") == 12
|
|
assert sdp.rfcomm_channel(b"\x19\x00\x03\x08\x00" + b"\x19\x00\x03\x08\x05") == 5
|
|
assert sdp.rfcomm_channel(b"\x19\x01\x00") is None
|
|
|
|
|
|
# ----------------------------------------------------------------- bootstrap
|
|
|
|
def unexpected_join(_):
|
|
raise AssertionError("Unexpected join")
|
|
|
|
|
|
def run_bootstrap(start_request_delay=5.0, initial_kick_delay=bs.INITIAL_KICK_SECONDS, **hu_options):
|
|
phone, car = socket.socketpair()
|
|
joined = []
|
|
result_holder = {}
|
|
|
|
def car_side():
|
|
try:
|
|
result_holder["seen"] = rfcomm_head_unit(car, ("192.168.50.1", 5288), **hu_options)
|
|
except BaseException as error:
|
|
result_holder["error"] = error
|
|
|
|
thread = threading.Thread(target=car_side, daemon=True)
|
|
thread.start()
|
|
events = []
|
|
boot = bs.WirelessBootstrap(phone, lambda name, **values: events.append((name, values)), stage_timeout=5.0,
|
|
start_request_delay=start_request_delay, initial_kick_delay=initial_kick_delay)
|
|
|
|
def join(credentials):
|
|
time.sleep(0.3) # the car pings while we join
|
|
joined.append(credentials)
|
|
|
|
result = boot.run(join)
|
|
thread.join(5)
|
|
phone.close()
|
|
car.close()
|
|
assert "error" not in result_holder, result_holder.get("error")
|
|
return result, joined, result_holder["seen"], events
|
|
|
|
|
|
def test_bootstrap_standard_flow():
|
|
result, joined, seen, events = run_bootstrap()
|
|
assert (result.endpoint.ip, result.endpoint.port) == ("192.168.50.1", 5288)
|
|
creds = joined[0]
|
|
assert (creds.ssid, creds.key, creds.bssid, creds.security) == ("HondaAA", "secret-key", "AA:BB:CC:DD:EE:FF", 8)
|
|
assert signed(one(seen["start_response"], 3)) == 0 and 1 not in seen["start_response"]
|
|
assert signed(one(seen["connect_status"], 1)) == 0
|
|
assert all("secret-key" not in repr(values) for _, values in events)
|
|
|
|
|
|
def test_bootstrap_version_first_and_fragmented():
|
|
result, joined, seen, _ = run_bootstrap(version_first=True, byte_by_byte=True)
|
|
assert result.version == (1, 3) and result.head_unit.get("car_make") == "Honda"
|
|
assert one(seen["version_response"], 1) == 1 and signed(one(seen["version_response"], 4)) == 0
|
|
assert joined[0].ssid == "HondaAA"
|
|
|
|
|
|
def test_bootstrap_asks_car_to_start_projection():
|
|
result, joined, seen, events = run_bootstrap(start_request_delay=0.2, version_first=True, wait_for_phone_start=True)
|
|
assert seen["phone_start_request"] == b"" and joined[0].ssid == "HondaAA"
|
|
assert ("bootstrap_tx", {"message": "WifiStartRequest", "bytes": 0}) in events
|
|
|
|
|
|
def test_bootstrap_takes_endpoint_from_start_response():
|
|
# 2025 Honda (session 25): version exchange, phone asks, car answers with a StartResponse.
|
|
result, joined, seen, events = run_bootstrap(start_request_delay=0.1, version_first=True, wait_for_phone_start=True,
|
|
answer_start=True)
|
|
assert (result.endpoint.ip, result.endpoint.port) == ("192.168.50.1", 5288) and joined[0].ssid == "HondaAA"
|
|
assert seen["messages"].count(bs.WIFI_START_REQUEST) == 1
|
|
assert not any(name == "bootstrap_ignored" for name, _ in events)
|
|
|
|
|
|
def test_bootstrap_waits_out_a_busy_start_response():
|
|
# The car says "not ready" (status alone), then sends the endpoint on its own.
|
|
result, joined, _, events = run_bootstrap(start_request_delay=0.1, version_first=True, wait_for_phone_start=True,
|
|
answer_start=True, busy_replies=1, busy_follow_up=True)
|
|
assert result.endpoint.port == 5288 and joined[0].ssid == "HondaAA"
|
|
assert ("bootstrap_start_refused", {"status": -1, "endpoint": False}) in events
|
|
|
|
|
|
def test_bootstrap_asks_again_after_a_busy_start_response():
|
|
result, joined, seen, _ = run_bootstrap(start_request_delay=0.1, version_first=True, wait_for_phone_start=True,
|
|
answer_start=True, busy_replies=2)
|
|
assert result.endpoint.port == 5288 and joined[0].ssid == "HondaAA"
|
|
assert seen["messages"].count(bs.WIFI_START_REQUEST) == 3
|
|
|
|
|
|
def test_bootstrap_start_response_without_endpoint_is_not_an_endpoint(monkeypatch):
|
|
now = [0.0]
|
|
monkeypatch.setattr(bs.time, "monotonic", lambda: now[0])
|
|
boot = bs.WirelessBootstrap(None, lambda *a, **k: None, stage_timeout=1, start_request_delay=0.3)
|
|
sent = []
|
|
monkeypatch.setattr(boot, "send", lambda message, payload=b"": sent.append(message))
|
|
replies = [(bs.WIFI_START_RESPONSE, field(3, 0)), (bs.WIFI_START_RESPONSE, field(1, "10.0.0.1") + field(2, 5288) + field(3, -3))]
|
|
|
|
def receive(timeout):
|
|
now[0] += 0.2
|
|
if replies:
|
|
return replies.pop(0)
|
|
raise bs.BootstrapTimeout("wifi_start", "head unit did not answer in time")
|
|
|
|
monkeypatch.setattr(boot, "next_frame", receive)
|
|
with pytest.raises(bs.BootstrapTimeout):
|
|
boot.run(unexpected_join)
|
|
assert bs.WIFI_START_REQUEST in sent, "a refused start is asked again"
|
|
|
|
|
|
def test_bootstrap_silent_peer_receives_one_prompt():
|
|
_, joined, seen, events = run_bootstrap(initial_kick_delay=0.05, wait_for_phone_start=True, pings=False)
|
|
assert joined[0].ssid == "HondaAA"
|
|
assert seen["messages"].count(bs.WIFI_START_REQUEST) == 1
|
|
assert sum(name == "bootstrap_initial_kick" for name, _ in events) == 1
|
|
|
|
|
|
def test_bootstrap_version_prompt_does_not_get_an_extra_initial_prompt():
|
|
_, _, seen, events = run_bootstrap(initial_kick_delay=0.05, start_request_delay=0.1,
|
|
version_first=True, wait_for_phone_start=True)
|
|
assert seen["messages"].count(bs.WIFI_START_REQUEST) == 1
|
|
assert not any(name == "bootstrap_initial_kick" for name, _ in events)
|
|
|
|
|
|
@pytest.mark.parametrize("stage", ["wifi_start", "wifi_info"])
|
|
def test_bootstrap_stage_deadline_survives_continuous_pings(monkeypatch, stage):
|
|
now = [0.0]
|
|
monkeypatch.setattr(bs.time, "monotonic", lambda: now[0])
|
|
boot = bs.WirelessBootstrap(None, lambda *a, **k: None, stage_timeout=1)
|
|
sent = []
|
|
monkeypatch.setattr(boot, "send", lambda message, payload=b"": sent.append(message))
|
|
first = [True]
|
|
|
|
def receive(timeout):
|
|
now[0] += 0.2
|
|
if first[0] and stage == "wifi_info":
|
|
first[0] = False
|
|
return bs.WIFI_START_REQUEST, field(1, "10.0.0.1") + field(2, 5288)
|
|
return bs.WIFI_PING_REQUEST, b""
|
|
|
|
monkeypatch.setattr(boot, "next_frame", receive)
|
|
with pytest.raises(bs.BootstrapTimeout) as error:
|
|
boot.run(unexpected_join)
|
|
assert error.value.stage == stage and now[0] < 1.5
|
|
assert bs.WIFI_PING_RESPONSE in sent and bs.WIFI_START_REQUEST not in sent
|
|
|
|
|
|
def test_bootstrap_hint_cannot_hide_disconnect():
|
|
phone, car = socket.socketpair()
|
|
try:
|
|
car.sendall(bs.encode_frame(bs.WIFI_SETUP_INFO, field(4, field(1, "10.0.0.1") + field(2, 5288))))
|
|
car.close()
|
|
with pytest.raises(bs.BootstrapError, match="closed"):
|
|
bs.WirelessBootstrap(phone, lambda *a, **k: None).run(unexpected_join)
|
|
finally:
|
|
phone.close()
|
|
car.close()
|
|
|
|
|
|
def test_bootstrap_hint_grace_is_not_extended_by_pings(monkeypatch):
|
|
now = [0.0]
|
|
monkeypatch.setattr(bs.time, "monotonic", lambda: now[0])
|
|
boot = bs.WirelessBootstrap(None, lambda *a, **k: None, stage_timeout=10)
|
|
monkeypatch.setattr(boot, "send", lambda *a: None)
|
|
payload = field(4, field(1, "10.0.0.1") + field(2, 5288)) + field(5, field(1, "test") + field(3, "secret-key") + field(4, 8))
|
|
|
|
def receive(timeout):
|
|
now[0] += 0.25
|
|
return (bs.WIFI_SETUP_INFO, payload) if now[0] == 0.25 else (bs.WIFI_PING_REQUEST, b"")
|
|
|
|
monkeypatch.setattr(boot, "next_frame", receive)
|
|
joined = []
|
|
result = boot.run(joined.append)
|
|
assert result.endpoint.ip == "10.0.0.1" and joined[0].ssid == "test"
|
|
assert 3 <= now[0] < 5
|
|
|
|
|
|
def test_bootstrap_detects_alternate_info_layout():
|
|
_, joined, _, _ = run_bootstrap(oaa_layout=True)
|
|
assert joined[0].key == "secret-key" and joined[0].bssid == "AA:BB:CC:DD:EE:FF"
|
|
|
|
|
|
def test_bootstrap_setup_info_without_start_request():
|
|
result, joined, seen, _ = run_bootstrap(setup_info_only=True, pings=False)
|
|
assert result.endpoint.port == 5288 and joined[0].key == "secret-key"
|
|
assert bs.WIFI_INFO_REQUEST not in seen["messages"]
|
|
|
|
|
|
def test_bootstrap_join_failure_reports_status():
|
|
phone, car = socket.socketpair()
|
|
seen = {}
|
|
|
|
def car_side():
|
|
try:
|
|
seen.update(rfcomm_head_unit(car, ("10.0.0.1", 5000), pings=False))
|
|
except BaseException as error:
|
|
seen["error"] = error
|
|
|
|
thread = threading.Thread(target=car_side, daemon=True)
|
|
thread.start()
|
|
|
|
def join(_):
|
|
raise RuntimeError("wrong key")
|
|
|
|
with pytest.raises(bs.BootstrapError) as error:
|
|
bs.WirelessBootstrap(phone, lambda *a, **k: None, stage_timeout=5.0).run(join)
|
|
assert error.value.stage == "joining_wifi"
|
|
thread.join(5)
|
|
phone.close()
|
|
car.close()
|
|
assert signed(one(seen["connect_status"], 1)) == bs.STATUS_NETWORK_UNAVAILABLE
|
|
|
|
|
|
def test_bootstrap_timeout_names_stage():
|
|
phone, car = socket.socketpair()
|
|
with pytest.raises(bs.BootstrapError) as error:
|
|
bs.WirelessBootstrap(phone, lambda *a, **k: None, stage_timeout=0.3).run(lambda _: None)
|
|
assert error.value.stage == "wifi_start"
|
|
car.close()
|
|
phone.close()
|
|
|
|
|
|
def test_frame_reader_bounds():
|
|
reader = bs.FrameReader()
|
|
assert reader.feed(bs.encode_frame(1, b"ab")[:3]) == []
|
|
assert reader.feed(bs.encode_frame(1, b"ab")[3:] + bs.encode_frame(8)) == [(1, b"ab"), (8, b"")]
|
|
with pytest.raises(ValueError):
|
|
reader.feed(struct.pack(">HH", 60000, 1))
|
|
|
|
|
|
# ----------------------------------------------------------------------- hfp
|
|
|
|
def test_hfp_service_level_connection():
|
|
assert hfp.respond("AT+BRSF=767") == ["+BRSF: 995", "OK"] # a phone's feature set, not "nothing"
|
|
assert hfp.respond("AT+BAC=1,2") == ["OK"]
|
|
assert hfp.respond("AT+BCC") == ["ERROR"] # never start call audio we cannot carry
|
|
assert hfp.respond("AT+BIA=1,1,1,1,1,1,1,0") == ["OK"]
|
|
assert hfp.respond("AT+CIND=?")[0].startswith("+CIND: (\"call\"")
|
|
assert hfp.respond("AT+CIND?") == ["+CIND: 0,0,1,5,0,5,0", "OK"]
|
|
assert hfp.respond("AT+CMER=3,0,0,1") == ["OK"]
|
|
assert hfp.respond("ATD5551234;") == ["ERROR"]
|
|
assert hfp.respond("AT+UNKNOWN") == ["ERROR"]
|
|
|
|
|
|
def test_hfp_serve_over_socket():
|
|
a, b = socket.socketpair()
|
|
stop = threading.Event()
|
|
thread = threading.Thread(target=hfp.serve, args=(a, stop, lambda *x, **y: None), daemon=True)
|
|
thread.start()
|
|
b.sendall(b"AT+BRSF=0\rAT+CIND=?\r")
|
|
time.sleep(0.2)
|
|
data = b.recv(4096)
|
|
assert b"+BRSF: 995" in data and data.count(b"OK") == 2
|
|
b.close()
|
|
thread.join(2)
|
|
stop.set()
|
|
|
|
|
|
# -------------------------------------------------------------- frame source
|
|
|
|
def test_fit_content_letterboxes_compact_ui():
|
|
x, y, w, h = fit_content(536, 240, 1280, 720, 0, 240)
|
|
assert (w, h) == (1072, 480) and x == 104 and y == 120
|
|
assert fit_content(536, 240, 800, 480, 0, 0) == (0, 60, 800, 358)
|
|
|
|
|
|
def test_frame_handoff(tmp_path):
|
|
path = str(tmp_path / "frames")
|
|
consumer = FrameConsumer(path)
|
|
producer = FrameProducer(path)
|
|
request = FrameRequest(64, 32, 0, 0, 100_000)
|
|
consumer.configure(request)
|
|
assert producer.pending_request() is None # no demand yet
|
|
consumer.demand(1.0)
|
|
producer._next_open_check = 0
|
|
pending = producer.pending_request()
|
|
assert pending == request
|
|
now_ns = time.monotonic_ns()
|
|
assert producer.due(pending, now_ns)
|
|
producer.publish(pending, bytes([7]) * (64 * 32 * 4), now_ns)
|
|
assert not producer.due(pending, now_ns + 10_000_000) # paced to 10 fps
|
|
frame = consumer.latest()
|
|
assert frame is not None and frame.data[:4] == b"\x07" * 4 and frame.captured_ns == now_ns
|
|
assert consumer.latest() is None # nothing newer
|
|
consumer.release_demand()
|
|
assert producer.pending_request() is None
|
|
consumer.close()
|
|
|
|
|
|
def test_frame_consumer_rejects_torn_write(tmp_path):
|
|
path = str(tmp_path / "frames")
|
|
consumer = FrameConsumer(path)
|
|
consumer.configure(FrameRequest(16, 16, 0, 0, 50_000))
|
|
consumer.demand()
|
|
producer = FrameProducer(path)
|
|
request = producer.pending_request()
|
|
assert request is not None
|
|
producer.publish(request, bytes(16 * 16 * 4), time.monotonic_ns())
|
|
struct.pack_into("<Q", consumer.mm, 8, 5) # writer mid-update
|
|
assert consumer.latest() is None
|
|
consumer.close()
|
|
|
|
|
|
# ------------------------------------------------------------------- session
|
|
|
|
def test_choose_video_mode_prefers_720p():
|
|
channels = [{"id": 3, "video_configs": [parse_fields(field(1, 1)), parse_fields(field(1, 2) + field(4, 240)),
|
|
parse_fields(field(1, 3))]}]
|
|
assert choose_video_mode(channels) == VideoMode(3, 1, 1280, 720, 30, 0, 240)
|
|
with pytest.raises(ValueError):
|
|
choose_video_mode([{"id": 3, "video_configs": [parse_fields(field(1, 2) + field(10, 7))]}]) # H.265 only
|
|
|
|
|
|
def keyframe_au(tag: int) -> bytes:
|
|
return 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" + bytes([tag])
|
|
|
|
|
|
def delta_au(tag: int) -> bytes:
|
|
return b"\x00\x00\x00\x01\x09\xf0" + b"\x00\x00\x00\x01\x41" + bytes([tag])
|
|
|
|
|
|
def connect(hu: FakeHeadUnit, identity, verify=True) -> ProjectionSession:
|
|
sock = socket.create_connection(("127.0.0.1", hu.port), timeout=5)
|
|
return ProjectionSession(sock, str(identity["phone_cert"]), str(identity["phone_key"]), None,
|
|
str(identity["root"]) if verify else None)
|
|
|
|
|
|
def pump_until(session, predicate, timeout=5.0):
|
|
deadline = time.monotonic() + timeout
|
|
while not predicate():
|
|
assert time.monotonic() < deadline, "condition not reached"
|
|
session.pump(0.05)
|
|
|
|
|
|
def test_head_unit_tls_encode_drain_and_frame_share_send_lock():
|
|
hu = FakeHeadUnit.__new__(FakeHeadUnit)
|
|
hu._send_lock = threading.Lock()
|
|
pending = bytearray()
|
|
packets = []
|
|
|
|
def write(data):
|
|
assert hu._send_lock.locked()
|
|
assert not pending
|
|
pending.extend(data)
|
|
return len(data)
|
|
|
|
def read():
|
|
assert hu._send_lock.locked()
|
|
data = bytes(pending)
|
|
pending.clear()
|
|
return data
|
|
|
|
def sendall(data):
|
|
assert hu._send_lock.locked()
|
|
packets.append(data)
|
|
|
|
hu.tls = Mock(write=Mock(side_effect=write))
|
|
hu.outgoing = Mock(read=Mock(side_effect=read))
|
|
hu.sock = Mock(sendall=Mock(side_effect=sendall))
|
|
start = threading.Barrier(3)
|
|
failures = []
|
|
|
|
def send(kind, body):
|
|
try:
|
|
start.wait(timeout=5)
|
|
hu._send(3, kind, body)
|
|
except BaseException as error:
|
|
failures.append(error)
|
|
|
|
messages = ((0x8004, field(1, 1) + field(2, 1)), (0x8008, field(1, 2) + field(2, 1)))
|
|
threads = [threading.Thread(target=send, args=message) for message in messages]
|
|
for thread in threads:
|
|
thread.start()
|
|
start.wait(timeout=5)
|
|
for thread in threads:
|
|
thread.join(timeout=5)
|
|
assert not thread.is_alive()
|
|
assert not failures
|
|
decoded = []
|
|
for packet in packets:
|
|
channel, flags, size = struct.unpack(">BBH", packet[:4])
|
|
assert channel == 3 and flags == 11 and size == len(packet) - 4 and size > 2
|
|
decoded.append((struct.unpack(">H", packet[4:6])[0], packet[6:]))
|
|
assert sorted(decoded) == sorted(messages)
|
|
assert not pending
|
|
|
|
|
|
def test_session_end_to_end_with_focus_epochs(identity):
|
|
hu = FakeHeadUnit(identity)
|
|
session = connect(hu, identity)
|
|
session.authenticate()
|
|
assert "Honda" in session.head_unit_subject
|
|
mode = session.start("StarPilot", "comma.ai")
|
|
assert (mode.width, mode.height, mode.margin_height) == (1280, 720, 240)
|
|
pump_until(session, lambda: session.focused)
|
|
with pytest.raises(ValueError):
|
|
session.send_frame(delta_au(1), 1, keyframe=False) # a new epoch must start with a keyframe
|
|
session.send_frame(keyframe_au(1), 1, keyframe=True)
|
|
for i in range(2, 6):
|
|
pump_until(session, session.can_send)
|
|
session.send_frame(delta_au(i), i, keyframe=False)
|
|
pump_until(session, lambda: session.acked == 5)
|
|
|
|
hu.set_focus(False) # driver switched to the car's own screen
|
|
pump_until(session, lambda: not session.focused)
|
|
hu.set_focus(True)
|
|
pump_until(session, lambda: session.focused)
|
|
assert session.needs_keyframe and session.session_id == 2
|
|
session.send_frame(keyframe_au(9), 9, keyframe=True)
|
|
pump_until(session, lambda: session.acked == 6)
|
|
session.shutdown()
|
|
session.peer.close()
|
|
hu.thread.join(5)
|
|
assert hu.error is None, hu.error
|
|
assert hu.device_name == "StarPilot" and hu.start_indications == [1, 2]
|
|
assert [sid for sid, _ in hu.frames] == [1, 1, 1, 1, 1, 2] and hu.shutdown_received.is_set()
|
|
|
|
|
|
def test_session_accepts_newer_head_unit_protocol(identity):
|
|
hu = FakeHeadUnit(identity, version=(4, 1)) # 2025 Honda Civic head unit
|
|
session = connect(hu, identity)
|
|
session.authenticate()
|
|
mode = session.start("StarPilot", "comma.ai")
|
|
assert hu.version_reply == (6, 1, 0) and mode.width == 1280
|
|
session.shutdown()
|
|
session.peer.close()
|
|
hu.thread.join(5)
|
|
assert hu.error is None, hu.error
|
|
|
|
|
|
@pytest.mark.parametrize("ack_codec_config", [True, False, 0])
|
|
def test_session_sends_codec_config_before_each_epoch(identity, ack_codec_config):
|
|
hu = FakeHeadUnit(identity, ack_codec_config=ack_codec_config)
|
|
session = connect(hu, identity)
|
|
session.authenticate()
|
|
session.start("StarPilot", "comma.ai")
|
|
pump_until(session, lambda: session.focused)
|
|
session.send_frame(keyframe_au(1), 1, keyframe=True)
|
|
for i in range(2, 5):
|
|
pump_until(session, session.can_send)
|
|
session.send_frame(delta_au(i), i, keyframe=False)
|
|
pump_until(session, lambda: session.acked == 4)
|
|
hu.set_focus(False)
|
|
pump_until(session, lambda: not session.focused)
|
|
hu.set_focus(True)
|
|
pump_until(session, lambda: session.focused)
|
|
session.send_frame(keyframe_au(9), 9, keyframe=True)
|
|
pump_until(session, lambda: session.acked == 5)
|
|
session.shutdown()
|
|
session.peer.close()
|
|
hu.thread.join(5)
|
|
assert hu.error is None, hu.error
|
|
sps_pps = b"\x00\x00\x00\x01\x67\x42\x00\x00\x00\x01\x68\xce"
|
|
assert hu.codec_configs == [(1, sps_pps, 0), (2, sps_pps, 4)] # one per epoch, before its keyframe
|
|
|
|
|
|
def test_session_keeps_unsolicited_focus_grant(identity):
|
|
hu = FakeHeadUnit(identity, unsolicited_focus=True)
|
|
session = connect(hu, identity)
|
|
session.authenticate()
|
|
session.start("StarPilot", "comma.ai")
|
|
pump_until(session, lambda: session.focused, timeout=2.0)
|
|
session.send_frame(keyframe_au(1), 1, keyframe=True)
|
|
pump_until(session, lambda: session.acked == 1)
|
|
session.shutdown()
|
|
session.peer.close()
|
|
hu.thread.join(5)
|
|
assert hu.error is None and hu.start_indications == [1]
|
|
|
|
|
|
HONDA_SCREEN_TAKEBACK_S = 3.0 # the Civic gives its screen back after this long without video
|
|
|
|
|
|
def streaming_session(hu: FakeHeadUnit, identity) -> ProjectionSession:
|
|
session = connect(hu, identity)
|
|
session.authenticate()
|
|
session.start("StarPilot", "comma.ai")
|
|
pump_until(session, lambda: session.focused)
|
|
session.send_frame(keyframe_au(1), 1, keyframe=True)
|
|
pump_until(session, lambda: session.acked == 1 and not session.pending)
|
|
# ACKs are counts, not frame ids: read everything the car sent (the codec config's ACK too) so a late one is not
|
|
# credited to the next frame.
|
|
deadline = time.monotonic() + 5
|
|
while session.pump(0.2):
|
|
assert time.monotonic() < deadline, "the car kept talking"
|
|
return session
|
|
|
|
|
|
def sent_frame(hu: FakeHeadUnit, session: ProjectionSession) -> float:
|
|
"""Send one frame and wait until the car has it; its ACK, if any, is left unread in the socket."""
|
|
received = len(hu.frames)
|
|
session.send_frame(delta_au(2), 2, keyframe=False)
|
|
deadline = time.monotonic() + 5
|
|
while len(hu.frames) == received:
|
|
assert time.monotonic() < deadline, "the car never received the frame"
|
|
time.sleep(0.01)
|
|
time.sleep(0.1)
|
|
return session.pending[0]
|
|
|
|
|
|
def end_session(session: ProjectionSession, hu: FakeHeadUnit) -> None:
|
|
hu.close()
|
|
session.peer.close()
|
|
|
|
|
|
def test_ack_timeout_leaves_room_for_a_stall_but_beats_the_car_taking_its_screen_back():
|
|
assert 1.5 < ProjectionSession.ACK_TIMEOUT < HONDA_SCREEN_TAKEBACK_S
|
|
|
|
|
|
def test_ack_already_received_during_a_loop_stall_keeps_the_session(identity):
|
|
# 2026-10-01: the dashcam and model starting at onroad stalled the comma's loop while the car kept acking.
|
|
hu = FakeHeadUnit(identity)
|
|
session = streaming_session(hu, identity)
|
|
sent = sent_frame(hu, session)
|
|
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.5) # the waiting ACK is read, not timed out
|
|
assert session.acked == 2 and not session.pending
|
|
end_session(session, hu)
|
|
|
|
|
|
def test_car_that_stops_acking_times_out_at_the_new_limit(identity):
|
|
hu = FakeHeadUnit(identity)
|
|
session = streaming_session(hu, identity)
|
|
hu.hold_acks.set()
|
|
sent = sent_frame(hu, session)
|
|
session.check_progress(now=sent + 1.6) # past the old 1.5 s limit: no longer fatal
|
|
with pytest.raises(TimeoutError, match="2.5 s"):
|
|
session.check_progress(now=sent + ProjectionSession.ACK_TIMEOUT + 0.05)
|
|
end_session(session, hu)
|
|
|
|
|
|
def test_session_accepts_a_large_video_window_and_caps_it(identity):
|
|
hu = FakeHeadUnit(identity, window=100) # VW MIB3 offers 100
|
|
session = streaming_session(hu, identity)
|
|
assert session.window == ProjectionSession.MAX_WINDOW
|
|
end_session(session, hu)
|
|
|
|
|
|
def test_session_authentication_rejected(identity):
|
|
hu = FakeHeadUnit(identity, reject_auth=True)
|
|
session = connect(hu, identity)
|
|
with pytest.raises(AuthenticationRejected):
|
|
session.authenticate()
|
|
session.peer.close()
|
|
hu.close()
|
|
|
|
|
|
def test_session_refuses_untrusted_head_unit(identity, tmp_path):
|
|
other = make_identity(tmp_path / "other")
|
|
hu = FakeHeadUnit({**identity, "hu_cert": other["hu_cert"], "hu_key": other["hu_key"]}, require_client_cert=False)
|
|
session = connect(hu, identity)
|
|
with pytest.raises(ssl.SSLError):
|
|
session.authenticate()
|
|
session.peer.close()
|
|
hu.close()
|
|
|
|
|
|
# ---------------------------------------------------------------- supervisor
|
|
|
|
class FakeLease:
|
|
def __init__(self, *_):
|
|
self.local_ip = ""
|
|
self.acquired = []
|
|
self.releases = []
|
|
|
|
def acquire(self, credentials, cancelled=lambda: False, timeout=40.0):
|
|
self.acquired.append(credentials.ssid)
|
|
self.local_ip = "127.0.0.1"
|
|
return self.local_ip
|
|
|
|
def still_connected(self):
|
|
return True
|
|
|
|
def release(self, restore=True):
|
|
self.releases.append(restore)
|
|
|
|
|
|
class FakeBluez:
|
|
def __init__(self, log):
|
|
self.acquired = 0
|
|
self.released = 0
|
|
self.class_restored = 0
|
|
self.hfp_registrations = 0
|
|
self.trusted = []
|
|
self.adapter_ready = True
|
|
self.connected = True
|
|
self.paired_devices = [("AA:BB:CC:DD:EE:01", "Civic", True)]
|
|
|
|
def acquire(self):
|
|
self.acquired += 1
|
|
|
|
def release(self):
|
|
self.released += 1
|
|
|
|
def restore_class(self):
|
|
self.class_restored += 1
|
|
|
|
def register_hfp(self):
|
|
self.hfp_registrations += 1
|
|
|
|
def set_trusted(self, address):
|
|
self.trusted.append(address)
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
def device(self, address):
|
|
return {"address": address, "paired": True, "connected": self.connected, "name": "Civic", "android_auto": True,
|
|
"uuids": ["0000111e-0000-1000-8000-00805f9b34fb"]}
|
|
|
|
def devices(self):
|
|
return [{**self.device(address), "name": name, "android_auto": aa} for address, name, aa in self.paired_devices]
|
|
|
|
def snapshot(self, address):
|
|
return self.adapter_ready, self.device(address)
|
|
|
|
def connect_device(self, address):
|
|
pass
|
|
|
|
|
|
def test_supervisor_full_wireless_session(identity, tmp_path, monkeypatch):
|
|
from openpilot.starpilot.system.android_auto import bt_sockets, hw_encoder, identity as identity_store, supervisor as supervisor_module
|
|
|
|
class TransportEncoder:
|
|
backend = 'fixture'
|
|
supports_nv12 = False
|
|
last_encode_ms = 0.0
|
|
|
|
def __init__(self, width, height):
|
|
self.width, self.height, self.frames = width, height, 0
|
|
|
|
def encode_rgba(self, pixels, *, keyframe=False):
|
|
assert len(pixels) == self.width * self.height * 4
|
|
self.frames += 1
|
|
return (keyframe_au(self.frames), True) if keyframe or self.frames == 1 else (delta_au(self.frames), False)
|
|
|
|
def close(self): pass
|
|
|
|
monkeypatch.setattr(hw_encoder, 'create_encoder',
|
|
lambda width, height, **_kwargs: (TransportEncoder(width, height), 15))
|
|
|
|
data = tmp_path / "aa"
|
|
(data / "identity").mkdir(parents=True)
|
|
for src, name in ((identity["phone_cert"], "phone-cert.pem"), (identity["phone_key"], "phone-key.pem"), (identity["root"], "root-cert.pem")):
|
|
(data / "identity" / name).write_bytes(Path(src).read_bytes())
|
|
(data / "identity" / "phone-key.pem").chmod(0o600)
|
|
monkeypatch.setattr(identity_store, "IDENTITY_DIR", data / "identity")
|
|
monkeypatch.setattr(identity_store, "CONFIG_PATH", data / "config.json")
|
|
monkeypatch.setattr(identity_store, "LOG_DIR", data / "logs")
|
|
monkeypatch.setattr(supervisor_module, "SDP_SETTLE", (0.01,))
|
|
|
|
hu = FakeHeadUnit(identity)
|
|
rfcomm_seen = {}
|
|
|
|
def fake_l2cap(address, psm, timeout=10.0):
|
|
return ClosableSdpSocket([sdp_response(AA_RECORD)])
|
|
|
|
def fake_rfcomm(address, channel, timeout=15.0):
|
|
assert channel == 8
|
|
phone, car = socket.socketpair()
|
|
def car_side():
|
|
with car:
|
|
rfcomm_seen.update(rfcomm_head_unit(car, ("127.0.0.1", hu.port), pings=False))
|
|
time.sleep(1.0)
|
|
threading.Thread(target=car_side, daemon=True).start()
|
|
return phone
|
|
|
|
monkeypatch.setattr(bt_sockets, "connect_l2cap", fake_l2cap)
|
|
monkeypatch.setattr(bt_sockets, "connect_rfcomm", fake_rfcomm)
|
|
|
|
frame_path = str(tmp_path / "frames")
|
|
sup = supervisor_module.Supervisor(bluez_factory=FakeBluez, lease_factory=FakeLease,
|
|
bluetooth_client=type("C", (), {"status": lambda s: type("St", (), {"selected_audio": ""})()})(),
|
|
frame_path=frame_path)
|
|
sup.select_receiver("AA:BB:CC:DD:EE:01", "Civic")
|
|
lease_holder = {}
|
|
original_lease = sup._lease
|
|
sup._lease = Mock(side_effect=lambda: lease_holder.setdefault("lease", original_lease()))
|
|
|
|
stop_producer = threading.Event()
|
|
|
|
def producer_loop():
|
|
producer = FrameProducer(frame_path)
|
|
while not stop_producer.is_set():
|
|
producer._next_open_check = 0
|
|
request = producer.pending_request()
|
|
now_ns = time.monotonic_ns()
|
|
if request is not None and producer.due(request, now_ns):
|
|
producer.publish(request, bytes([now_ns % 251]) * (request.width * request.height * 4), now_ns)
|
|
time.sleep(0.01)
|
|
|
|
threading.Thread(target=producer_loop, daemon=True).start()
|
|
try:
|
|
sup.start()
|
|
deadline = time.monotonic() + 20
|
|
while len(hu.frames) < 5:
|
|
status = sup.status()
|
|
assert time.monotonic() < deadline, (repr(status), repr(hu.error))
|
|
time.sleep(0.05)
|
|
status = sup.status()
|
|
assert status["state"] == "streaming" and status["mode"]["width"] == 1280
|
|
sup.stop()
|
|
finally:
|
|
stop_producer.set()
|
|
hu.close()
|
|
hu.thread.join(5)
|
|
assert hu.error is None, hu.error
|
|
assert hu.shutdown_received.is_set()
|
|
lease = lease_holder["lease"]
|
|
assert lease.acquired == ["HondaAA"] and lease.releases[-1] is True
|
|
assert sup.status()["state"] == "idle" and sup.status()["error"] == ""
|
|
assert signed(one(rfcomm_seen["connect_status"], 1)) == 0
|
|
logs = list((data / "logs").glob("session-*.jsonl"))
|
|
assert logs and "secret-key" not in logs[0].read_text()
|
|
|
|
|
|
def make_supervisor(identity, tmp_path, monkeypatch, rfcomm):
|
|
from openpilot.starpilot.system.android_auto import bt_sockets, identity as identity_store, supervisor as supervisor_module
|
|
data = tmp_path / "aa"
|
|
(data / "identity").mkdir(parents=True)
|
|
for src, name in ((identity["phone_cert"], "phone-cert.pem"), (identity["phone_key"], "phone-key.pem"), (identity["root"], "root-cert.pem")):
|
|
(data / "identity" / name).write_bytes(Path(src).read_bytes())
|
|
(data / "identity" / "phone-key.pem").chmod(0o600)
|
|
monkeypatch.setattr(identity_store, "IDENTITY_DIR", data / "identity")
|
|
monkeypatch.setattr(identity_store, "CONFIG_PATH", data / "config.json")
|
|
monkeypatch.setattr(identity_store, "LOG_DIR", data / "logs")
|
|
monkeypatch.setattr(supervisor_module, "SDP_SETTLE", (0.01,))
|
|
monkeypatch.setattr(supervisor_module, "BACKOFF_SECONDS", (0.2,))
|
|
monkeypatch.setattr(bt_sockets, "connect_l2cap", lambda *a, **k: ClosableSdpSocket([sdp_response(AA_RECORD)] * 4))
|
|
monkeypatch.setattr(bt_sockets, "connect_rfcomm", rfcomm)
|
|
leases = []
|
|
|
|
def lease_factory(*args):
|
|
leases.append(FakeLease())
|
|
return leases[-1]
|
|
|
|
client = type("C", (), {"status": lambda s: type("St", (), {"selected_audio": ""})()})()
|
|
sup = supervisor_module.Supervisor(bluez_factory=FakeBluez, lease_factory=lease_factory, bluetooth_client=client,
|
|
frame_path=str(tmp_path / "frames"))
|
|
sup.select_receiver("AA:BB:CC:DD:EE:01", "Civic")
|
|
return sup, leases
|
|
|
|
|
|
def test_supervisor_retries_after_rejection(identity, tmp_path, monkeypatch):
|
|
cars = []
|
|
threads = []
|
|
failures = []
|
|
stopping = threading.Event()
|
|
|
|
def rfcomm(address, channel, timeout=15.0):
|
|
hu = FakeHeadUnit(identity, reject_auth=True)
|
|
cars.append(hu)
|
|
phone, car = socket.socketpair()
|
|
def car_side():
|
|
try:
|
|
with car:
|
|
rfcomm_head_unit(car, ("127.0.0.1", hu.port), pings=False)
|
|
stopping.wait(1.0)
|
|
except EOFError as error:
|
|
if not stopping.is_set():
|
|
failures.append(error)
|
|
except Exception as error:
|
|
failures.append(error)
|
|
thread = threading.Thread(target=car_side, daemon=True)
|
|
threads.append(thread)
|
|
thread.start()
|
|
return phone
|
|
|
|
sup, leases = make_supervisor(identity, tmp_path, monkeypatch, rfcomm)
|
|
sup.start()
|
|
deadline = time.monotonic() + 15
|
|
while len(cars) < 2:
|
|
assert time.monotonic() < deadline, sup.status()
|
|
time.sleep(0.05)
|
|
assert "rejected" in sup.status()["error"]
|
|
stopping.set()
|
|
sup.stop()
|
|
for hu in cars:
|
|
hu.close()
|
|
for thread in threads:
|
|
thread.join(timeout=5)
|
|
assert not thread.is_alive(), "RFCOMM fixture did not finish after stop"
|
|
assert not failures, failures
|
|
assert False in leases[0].releases and leases[0].releases[-1] is True # retries keep the old Wi-Fi parked
|
|
assert sup.status()["state"] == "idle"
|
|
|
|
|
|
def test_supervisor_stop_while_waiting_for_car(identity, tmp_path, monkeypatch):
|
|
held = []
|
|
|
|
def rfcomm(address, channel, timeout=15.0):
|
|
phone, car = socket.socketpair()
|
|
held.append(car) # the car never answers
|
|
return phone
|
|
|
|
sup, leases = make_supervisor(identity, tmp_path, monkeypatch, rfcomm)
|
|
sup.start()
|
|
deadline = time.monotonic() + 5
|
|
while sup.status()["state"] != "wifi_start":
|
|
assert time.monotonic() < deadline, sup.status()
|
|
time.sleep(0.02)
|
|
started = time.monotonic()
|
|
sup.stop()
|
|
assert time.monotonic() - started < 5
|
|
assert sup.status()["state"] == "idle" and leases[0].releases[-1] is True
|
|
for car in held:
|
|
car.close()
|
|
|
|
|
|
# ------------------------------------------------------------------- network
|
|
|
|
class FakeNetworkManager:
|
|
def __init__(self, fail_with_bssid=False):
|
|
self.fail_with_bssid = fail_with_bssid
|
|
self.active_device_connection = "/active/home"
|
|
self.states = {}
|
|
self.added = []
|
|
self.calls = []
|
|
|
|
def call(self, path, interface, member, signature=None, body: tuple=(), timeout=10.0):
|
|
from openpilot.starpilot.system.android_auto import network
|
|
self.calls.append(member)
|
|
if member == "GetDevices":
|
|
return [["/dev/wlan0"]]
|
|
if member == "AddAndActivateConnection2":
|
|
settings = body[0]
|
|
self.added.append(settings)
|
|
active = f"/active/aa{len(self.added)}"
|
|
locked = "bssid" in settings["802-11-wireless"]
|
|
self.states[active] = network.ACTIVE_STATE_DEACTIVATED if (locked and self.fail_with_bssid) else network.ACTIVE_STATE_ACTIVATED
|
|
self.active_device_connection = active
|
|
return [f"/settings/aa{len(self.added)}", active]
|
|
if member == "GetSettings":
|
|
return [{"connection": {"id": ("s", "home" if path == "/settings/home" else "starpilot-android-auto")}}]
|
|
if member == "ListConnections":
|
|
return [[]]
|
|
return []
|
|
|
|
def get(self, path, interface, name):
|
|
values = {"WirelessEnabled": True, "DeviceType": 2, "Interface": "wlan0", "ActiveConnection": self.active_device_connection,
|
|
"Connection": "/settings/home", "Ip4Config": "/ip4/1"}
|
|
if name == "State":
|
|
return self.states.get(path, 4)
|
|
if name == "AddressData":
|
|
return [{"address": ("s", "192.168.50.23")}]
|
|
return values[name]
|
|
|
|
|
|
def make_lease(nm):
|
|
from openpilot.starpilot.system.android_auto.network import NetworkLease
|
|
lease = NetworkLease(lambda *a, **k: None)
|
|
router = Mock(wraps=type("R", (), {"close": lambda self: None})())
|
|
|
|
def reopen(*args, **kwargs): # the real lease reopens D-Bus lazily on its next call
|
|
lease.router = router
|
|
|
|
def call(*args, **kwargs):
|
|
reopen()
|
|
return nm.call(*args, **kwargs)
|
|
|
|
def get(*args, **kwargs):
|
|
reopen()
|
|
return nm.get(*args, **kwargs)
|
|
|
|
lease._call, lease._get = Mock(side_effect=call), Mock(side_effect=get)
|
|
lease._delete_stale_profiles = Mock(side_effect=lambda: None)
|
|
return lease
|
|
|
|
|
|
def credentials():
|
|
return bs.WifiCredentials(ssid="HondaAA", key="secret-key", bssid="AA:BB:CC:DD:EE:FF", security=8, ap_type=1)
|
|
|
|
|
|
def test_network_lease_settings_keep_internet_route():
|
|
from openpilot.starpilot.system.android_auto.network import connection_settings
|
|
settings = connection_settings(credentials(), "wlan0")
|
|
assert settings["ipv4"]["never-default"] == ("b", True) and settings["ipv4"]["ignore-auto-dns"] == ("b", True)
|
|
assert settings["connection"]["autoconnect"] == ("b", False) and settings["802-11-wireless-security"]["psk"] == ("s", "secret-key")
|
|
assert settings["802-11-wireless"]["bssid"] == ("ay", bytes.fromhex("AABBCCDDEEFF"))
|
|
|
|
|
|
def test_network_lease_retries_without_bssid_and_restores_previous():
|
|
nm = FakeNetworkManager(fail_with_bssid=True)
|
|
lease = make_lease(nm)
|
|
assert lease.acquire(credentials(), timeout=2.0) == "192.168.50.23"
|
|
assert "bssid" in nm.added[0]["802-11-wireless"] and "bssid" not in nm.added[1]["802-11-wireless"]
|
|
lease.release(restore=True)
|
|
assert nm.calls[-1] == "ActivateConnection" # home Wi-Fi comes back
|
|
|
|
|
|
def test_network_lease_waits_for_a_slow_honda_access_point(monkeypatch):
|
|
from openpilot.starpilot.system.android_auto import network
|
|
lease = network.NetworkLease(lambda *a, **k: None)
|
|
budgets = []
|
|
|
|
def attempt(credentials, timeout, cancelled):
|
|
budgets.append((credentials.bssid, timeout))
|
|
raise network.NetworkError("not up yet")
|
|
|
|
monkeypatch.setattr(lease, "_acquire_once", attempt)
|
|
monkeypatch.setattr(lease, "_drop_attempt", lambda: None)
|
|
with pytest.raises(network.NetworkError):
|
|
lease.acquire(credentials())
|
|
assert budgets == [("AA:BB:CC:DD:EE:FF", 90.0), ("", 30.0)]
|
|
|
|
|
|
def test_network_lease_does_not_undo_user_network_change():
|
|
nm = FakeNetworkManager()
|
|
lease = make_lease(nm)
|
|
lease.acquire(credentials(), timeout=2.0)
|
|
nm.active_device_connection = "/active/user-picked" # user chose another network meanwhile
|
|
lease.release(restore=True)
|
|
assert "ActivateConnection" not in nm.calls
|
|
|
|
|
|
def test_network_lease_retry_keeps_previous_for_final_stop():
|
|
nm = FakeNetworkManager()
|
|
lease = make_lease(nm)
|
|
lease.acquire(credentials(), timeout=2.0)
|
|
lease.release(restore=False)
|
|
assert "ActivateConnection" not in nm.calls and lease.previous_connection == "/settings/home"
|
|
lease.acquire(credentials(), timeout=2.0)
|
|
lease.release(restore=True)
|
|
assert nm.calls.count("ActivateConnection") == 1
|