Add authenticated WiFi transport with BLE fallback
This commit is contained in:
@@ -1 +1 @@
|
||||
2026-09-29T22:15+08:00
|
||||
2026-10-01T10:37+08:00
|
||||
|
||||
@@ -869,13 +869,17 @@ def create_app(
|
||||
mobile = MobileControl(control, network, templates, animations, resolve_content,
|
||||
apply_resolved_content, effective_default_content, set_default_content,
|
||||
read_device_status, storage_monitor.get_status, library_order=library_order)
|
||||
mobile_sessions = SessionManager(mobile.dispatch, mobile.identity)
|
||||
mobile_sessions = SessionManager(mobile.dispatch, mobile.identity,
|
||||
wifi_address=lambda: network.get_cached_status().get("active", {}).get("ipv4_address")
|
||||
if network.get_cached_status().get("active", {}).get("connected") else None)
|
||||
mobile_runtime = BluezRuntime(mobile_sessions, enabled=os.environ.get("MATRIX_BLE_ENABLED") == "1")
|
||||
app.state.mobile_control = mobile
|
||||
app.state.mobile_sessions = mobile_sessions
|
||||
except Exception:
|
||||
logger.exception("Mobile control initialization failed; display remains available")
|
||||
app.state.mobile_runtime = mobile_runtime
|
||||
from app.mobile.wifi import install_wifi_route
|
||||
install_wifi_route(app)
|
||||
|
||||
@app.get("/api/status")
|
||||
def get_status() -> dict:
|
||||
|
||||
@@ -51,8 +51,8 @@ class MobileControl:
|
||||
def identity(self):
|
||||
record = self.identity_record
|
||||
return dict(device_id=record["device_id"], device_name=record["name"],
|
||||
short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=1,
|
||||
capabilities=["status", "settings", "library", "library_progressive", "playback", "frame", "device_name", "wifi", "wifi_scan"],
|
||||
short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=2,
|
||||
capabilities=["status", "settings", "library", "library_progressive", "playback", "frame", "device_name", "wifi", "wifi_scan", "wifi_transport"],
|
||||
limits=dict(max_message_bytes=65536, library_page_size=50, preview_interval_ms=2000))
|
||||
|
||||
def settings(self):
|
||||
|
||||
@@ -134,7 +134,8 @@ class Handshake:
|
||||
|
||||
|
||||
class Cipher:
|
||||
def __init__(self, material: bytes, transcript: bytes, *, server: bool):
|
||||
def __init__(self, material: bytes, transcript: bytes, *, server: bool, label=LABEL):
|
||||
self.label = label
|
||||
self.tx_direction = 1 if server else 0
|
||||
self.rx_direction = 1 - self.tx_direction
|
||||
self.keys = (AESGCM(material[:32]), AESGCM(material[32:64]))
|
||||
@@ -148,7 +149,7 @@ class Cipher:
|
||||
raise ProtocolError("BAD_REQUEST")
|
||||
seq = struct.pack("!Q", self.tx_sequence)
|
||||
d = self.tx_direction
|
||||
result = seq + self.keys[d].encrypt(self.prefixes[d] + seq, payload, LABEL + self.transcript + bytes([d]) + seq)
|
||||
result = seq + self.keys[d].encrypt(self.prefixes[d] + seq, payload, self.label + self.transcript + bytes([d]) + seq)
|
||||
self.tx_sequence += 1
|
||||
return result
|
||||
|
||||
@@ -157,7 +158,7 @@ class Cipher:
|
||||
if self.failed or not 25 <= len(record) <= MAX_MESSAGE or int.from_bytes(record[:8], "big") != self.rx_sequence:
|
||||
raise ValueError()
|
||||
seq, d = record[:8], self.rx_direction
|
||||
result = self.keys[d].decrypt(self.prefixes[d] + seq, record[8:], LABEL + self.transcript + bytes([d]) + seq)
|
||||
result = self.keys[d].decrypt(self.prefixes[d] + seq, record[8:], self.label + self.transcript + bytes([d]) + seq)
|
||||
self.rx_sequence += 1
|
||||
return result
|
||||
except Exception:
|
||||
|
||||
@@ -23,7 +23,7 @@ def checked_name(value):
|
||||
|
||||
|
||||
class SessionManager:
|
||||
def __init__(self, dispatch, identity, *, clock=time.monotonic):
|
||||
def __init__(self, dispatch, identity, *, clock=time.monotonic, wifi_address=lambda: None):
|
||||
self.dispatch = dispatch
|
||||
self.identity = identity
|
||||
self.clock = clock
|
||||
@@ -37,6 +37,8 @@ class SessionManager:
|
||||
self.connected_at = self.last_activity = 0
|
||||
self.generation = 0
|
||||
self.peer_mtu = 23
|
||||
from .wifi import WifiSession
|
||||
self.wifi = WifiSession(self, wifi_address)
|
||||
|
||||
def connect(self, owner):
|
||||
with self.lock:
|
||||
@@ -55,6 +57,7 @@ class SessionManager:
|
||||
self.opened = False
|
||||
self.last_request = -1
|
||||
self.peer_mtu = 23
|
||||
self.wifi.close()
|
||||
self.generation += 1
|
||||
|
||||
def expired(self):
|
||||
@@ -68,7 +71,8 @@ class SessionManager:
|
||||
def status(self):
|
||||
with self.lock:
|
||||
return dict(connected=self.opened, client_name=self.client_name if self.opened else None,
|
||||
generation=self.generation)
|
||||
generation=self.generation, active_transport=("wifi" if self.wifi.valid() else "ble") if self.opened else None,
|
||||
wifi_counters=dict(self.wifi.counters))
|
||||
|
||||
def accept(self, owner, fragment):
|
||||
with self.lock:
|
||||
@@ -119,6 +123,11 @@ class SessionManager:
|
||||
result = dict(client_name=self.client_name)
|
||||
elif method == "session.open":
|
||||
raise RpcError("CONFLICT", "会话已经建立")
|
||||
elif method == "transport.offer":
|
||||
result = self.wifi.offer()
|
||||
elif method == "transport.close":
|
||||
self.wifi.close()
|
||||
result = dict(closed=True)
|
||||
else:
|
||||
result = self.dispatch(method, params)
|
||||
response.update(ok=True, result=result)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""BLE-owned, single-use encrypted LAN channel. No independent WiFi session."""
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
from .protocol import Cipher, ProtocolError, json_object, MAX_MESSAGE
|
||||
|
||||
LABEL = b'QMS-WIFI-1'
|
||||
|
||||
|
||||
def wifi_cipher(secret, channel_id, *, server):
|
||||
transcript = hashlib.sha256(channel_id.encode('ascii')).digest()
|
||||
material = HKDF(algorithm=hashes.SHA256(), length=72, salt=transcript,
|
||||
info=LABEL + transcript).derive(secret)
|
||||
return Cipher(material, transcript, server=server, label=LABEL)
|
||||
|
||||
|
||||
def encode(value):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode('utf-8')
|
||||
|
||||
|
||||
class WifiSession:
|
||||
# All methods run under the owning SessionManager's reentrant lock.
|
||||
def __init__(self, sessions, address):
|
||||
self.sessions, self.address = sessions, address
|
||||
self.counters = dict(requests=0, rx_bytes=0, tx_bytes=0)
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
self.pending = None
|
||||
self.channel = None
|
||||
self.cipher = None
|
||||
self.last_request = -1
|
||||
self.last_activity = 0
|
||||
|
||||
def valid(self, channel=None):
|
||||
s = self.sessions
|
||||
return bool(self.channel and s.opened and not s.expired() and
|
||||
(channel is None or channel == self.channel) and
|
||||
s.clock() - self.last_activity < 10)
|
||||
|
||||
def offer(self):
|
||||
from .session import RpcError
|
||||
if self.valid():
|
||||
raise RpcError('BUSY', 'WiFi 通道已经启用')
|
||||
self.close()
|
||||
try:
|
||||
host = str(ipaddress.IPv4Address(self.address()))
|
||||
if ipaddress.ip_address(host).is_unspecified or ipaddress.ip_address(host).is_loopback:
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
raise RpcError('NOT_READY', '设备 WiFi 尚未取得地址') from None
|
||||
channel = secrets.token_hex(16)
|
||||
secret = secrets.token_bytes(32)
|
||||
self.pending = (channel, secret, self.sessions.clock() + 10)
|
||||
return dict(channel_id=channel, secret=base64.b64encode(secret).decode('ascii'),
|
||||
host=host, port=8080, path='/ws/mobile', expires_in_ms=10000,
|
||||
device_id=self.sessions.identity()['device_id'])
|
||||
|
||||
def attach(self, packet):
|
||||
if not isinstance(packet, bytes) or not 57 <= len(packet) <= MAX_MESSAGE + 32:
|
||||
raise ProtocolError('BAD_REQUEST')
|
||||
if not self.sessions.opened or self.sessions.expired() or not self.pending or self.valid():
|
||||
raise ProtocolError('NOT_READY')
|
||||
channel, secret, expires = self.pending
|
||||
if packet[:32] != channel.encode('ascii') or self.sessions.clock() >= expires:
|
||||
raise ProtocolError('NOT_READY')
|
||||
cipher = wifi_cipher(secret, channel, server=True)
|
||||
hello = json_object(cipher.decrypt(packet[32:]))
|
||||
device_id = self.sessions.identity()['device_id']
|
||||
if hello != dict(device_id=device_id):
|
||||
raise ProtocolError('BAD_REQUEST')
|
||||
self.pending = None
|
||||
self.channel, self.cipher = channel, cipher
|
||||
self.last_activity = self.sessions.clock()
|
||||
return channel, cipher.encrypt(encode(dict(device_id=device_id, ready=True)))
|
||||
|
||||
def accept(self, channel, packet):
|
||||
from .session import RpcError
|
||||
if not self.valid(channel):
|
||||
raise ProtocolError('NOT_READY')
|
||||
request = json_object(self.cipher.decrypt(packet))
|
||||
rid = request.get('id')
|
||||
if not isinstance(rid, str) or not 1 <= len(rid) <= 64 or not rid.isascii() or not rid.isdecimal():
|
||||
raise ProtocolError('BAD_REQUEST')
|
||||
response = dict(id=rid)
|
||||
try:
|
||||
if int(rid) <= self.last_request:
|
||||
raise RpcError('CONFLICT', '请求编号已使用')
|
||||
self.last_request = int(rid)
|
||||
method, params = request.get('method'), request.get('params')
|
||||
if not isinstance(method, str) or not isinstance(params, dict):
|
||||
raise RpcError('BAD_REQUEST', '请求格式无效')
|
||||
if method == 'transport.ping':
|
||||
result = dict(alive=True)
|
||||
elif method.startswith(('session.', 'transport.', 'wifi.')) or method == 'task.get':
|
||||
raise RpcError('BAD_REQUEST', '此操作须使用蓝牙')
|
||||
else:
|
||||
result = self.sessions.dispatch(method, params)
|
||||
response.update(ok=True, result=result)
|
||||
except RpcError as error:
|
||||
response.update(ok=False, error=dict(code=error.code, message=error.message, retryable=False))
|
||||
except Exception:
|
||||
response.update(ok=False, error=dict(code='DEVICE_ERROR', message='设备操作失败,请刷新状态', retryable=False))
|
||||
self.last_activity = self.sessions.clock()
|
||||
record = self.cipher.encrypt(encode(response))
|
||||
self.counters['requests'] += 1
|
||||
self.counters['rx_bytes'] += len(packet)
|
||||
self.counters['tx_bytes'] += len(record)
|
||||
return record
|
||||
|
||||
|
||||
def install_wifi_route(app):
|
||||
from fastapi import WebSocket
|
||||
|
||||
@app.websocket('/ws/mobile')
|
||||
async def mobile_wifi(socket: WebSocket):
|
||||
sessions = getattr(app.state, 'mobile_sessions', None)
|
||||
if sessions is None:
|
||||
await socket.close(code=1008)
|
||||
return
|
||||
channel = None
|
||||
receive = None
|
||||
await socket.accept()
|
||||
def locked(function, *args):
|
||||
with sessions.lock:
|
||||
return function(*args)
|
||||
try:
|
||||
packet = await asyncio.wait_for(socket.receive_bytes(), 5)
|
||||
channel, reply = await asyncio.to_thread(locked, sessions.wifi.attach, packet)
|
||||
await socket.send_bytes(reply)
|
||||
while await asyncio.to_thread(locked, sessions.wifi.valid, channel):
|
||||
if receive is None:
|
||||
receive = asyncio.create_task(socket.receive_bytes())
|
||||
done, _ = await asyncio.wait([receive], timeout=0.25)
|
||||
if not done:
|
||||
continue
|
||||
packet = receive.result()
|
||||
receive = None
|
||||
reply = await asyncio.to_thread(locked, sessions.wifi.accept, channel, packet)
|
||||
# A BLE disconnect while executing a command must not send stale data.
|
||||
if not await asyncio.to_thread(locked, sessions.wifi.valid, channel):
|
||||
break
|
||||
await socket.send_bytes(reply)
|
||||
except Exception:
|
||||
# Never log records, secrets, peer addresses or normal disconnections.
|
||||
pass
|
||||
finally:
|
||||
if receive is not None:
|
||||
receive.cancel()
|
||||
await asyncio.gather(receive, return_exceptions=True)
|
||||
def release():
|
||||
if channel is not None and sessions.wifi.channel == channel:
|
||||
sessions.wifi.close()
|
||||
await asyncio.to_thread(locked, release)
|
||||
try:
|
||||
await socket.close(code=1000)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -41,7 +41,7 @@
|
||||
<p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p>
|
||||
</div>
|
||||
<div class="topbar-statuses" aria-label="设备实时状态">
|
||||
<span id="topbar-mobile" class="resource-indicator" role="status" aria-live="polite">手机未连接</span>
|
||||
<span id="topbar-mobile" class="resource-indicator" role="status" aria-live="polite">连接设备:未连接</span>
|
||||
<button id="topbar-current-content" class="current-content-indicator" type="button" aria-haspopup="dialog" aria-controls="current-display-dialog" aria-live="polite" aria-atomic="true">
|
||||
当前画面:正在读取…
|
||||
</button>
|
||||
|
||||
@@ -769,8 +769,8 @@ export async function refreshStatus({ source = "manual" } = {}) {
|
||||
const mobile = data.mobile;
|
||||
if (statusEls.mobile) {
|
||||
statusEls.mobile.textContent = mobile?.connected
|
||||
? `手机:${mobile.client_name || "已连接"}`
|
||||
: (mobile?.available === false ? "手机蓝牙暂不可用" : "手机未连接");
|
||||
? `连接设备:${mobile.client_name || "手机"} · ${mobile.active_transport === "wifi" ? "WiFi" : "蓝牙"}`
|
||||
: "连接设备:未连接";
|
||||
const session = `${data.service?.instance_id || ""}:${mobile?.generation || 0}`;
|
||||
if (mobile?.connected && lastMobileSession !== null && session !== lastMobileSession) {
|
||||
announce(`${mobile.client_name || "手机"}已连接`);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"secret": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
||||
"channel_id": "0123456789abcdef0123456789abcdef",
|
||||
"request": "7b226465766963655f6964223a227075626c69632d746573742d646576696365227d",
|
||||
"response": "7b226465766963655f6964223a227075626c69632d746573742d646576696365222c227265616479223a747275657d",
|
||||
"client_record": "0000000000000000d92ca79a81950869034538790ce815813153b6f67fda4c7c925fd2d3e8cca821df751e3a9a8ea0c9365dba2533f004114c73",
|
||||
"server_record": "0000000000000000d38868d91e24a0ce8cdd64ec485ab485312aac9f2c2342131544eb3c1a4c3b2827a823d361f12492a3d5f806004ddf6e097b37d0673ddbf203d07ccbb53981"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Public deterministic interop fixture, never live session material."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from app.mobile.wifi import wifi_cipher
|
||||
|
||||
def generate():
|
||||
secret = bytes(range(32))
|
||||
channel = '0123456789abcdef0123456789abcdef'
|
||||
request = b'{"device_id":"public-test-device"}'
|
||||
response = b'{"device_id":"public-test-device","ready":true}'
|
||||
c, s = wifi_cipher(secret, channel, server=False), wifi_cipher(secret, channel, server=True)
|
||||
return dict(secret=secret.hex(), channel_id=channel, request=request.hex(), response=response.hex(),
|
||||
client_record=c.encrypt(request).hex(), server_record=s.encrypt(response).hex())
|
||||
|
||||
if __name__ == '__main__':
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
raw = json.dumps(generate(), indent=2) + '\n'
|
||||
for path in [root / '核桃派软件源代码/tests/fixtures/protocol-wifi-v1.json',
|
||||
root / '移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-wifi-v1.json']:
|
||||
path.write_text(raw, encoding='utf-8')
|
||||
@@ -50,7 +50,8 @@ def test_progressive_preview_and_revision_cleanup(tmp_path):
|
||||
from PIL import Image
|
||||
app = create_app(project_root=tmp_path, driver_kind='mock')
|
||||
mobile = app.state.mobile_control
|
||||
assert mobile.identity()['protocol_minor'] == 1
|
||||
assert mobile.identity()['protocol_minor'] == 2
|
||||
assert 'wifi_transport' in mobile.identity()['capabilities']
|
||||
assert 'library_progressive' in mobile.identity()['capabilities']
|
||||
created = mobile.templates.create('预览清理', {
|
||||
'version': 1, 'width': 64, 'height': 64,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import json
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from app.mobile.wifi import wifi_cipher, encode, install_wifi_route
|
||||
from app.mobile.protocol import ProtocolError
|
||||
from test_mobile_session import connection, request
|
||||
import base64
|
||||
|
||||
|
||||
def opened(now=None):
|
||||
now = now or [0]
|
||||
manager, ble, calls = connection(lambda: now[0])
|
||||
manager.wifi.address = lambda: '192.0.2.10'
|
||||
request(manager, ble, 1, '1', 'session.open', {'client_name': '手机'})
|
||||
return manager, ble, calls, now
|
||||
|
||||
|
||||
def offer(manager, ble):
|
||||
result = request(manager, ble, 2, '2', 'transport.offer')['result']
|
||||
client = wifi_cipher(base64.b64decode(result['secret']), result['channel_id'], server=False)
|
||||
hello = result['channel_id'].encode() + client.encrypt(encode({'device_id': 'test'}))
|
||||
return result, client, hello
|
||||
|
||||
|
||||
def test_upgrade_rpc_fallback_and_ble_lifetime():
|
||||
m, ble, calls, now = opened()
|
||||
generation = m.generation
|
||||
o, client, hello = offer(m, ble)
|
||||
channel, ack = m.wifi.attach(hello)
|
||||
assert json.loads(client.decrypt(ack)) == {'device_id': 'test', 'ready': True}
|
||||
assert m.status()['active_transport'] == 'wifi' and m.generation == generation
|
||||
for number, method in enumerate(('status.get', 'content.play', 'transport.ping'), 1):
|
||||
now[0] += 5
|
||||
packet = client.encrypt(encode(dict(id=str(number), method=method, params={})))
|
||||
assert json.loads(client.decrypt(m.wifi.accept(channel, packet)))['ok']
|
||||
assert calls == ['status.get', 'content.play']
|
||||
now[0] = 20
|
||||
assert m.expired() # WiFi traffic never refreshes BLE activity.
|
||||
with pytest.raises(ProtocolError): m.wifi.accept(channel, b'bad')
|
||||
m.disconnect('a')
|
||||
assert m.status()['active_transport'] is None
|
||||
|
||||
|
||||
def test_offer_expiry_identity_tamper_and_single_use():
|
||||
m, ble, _, now = opened()
|
||||
o, c, hello = offer(m, ble)
|
||||
with pytest.raises(ProtocolError): m.wifi.attach(hello[:-1] + bytes([hello[-1] ^ 1]))
|
||||
assert m.wifi.pending is not None
|
||||
channel, _ = m.wifi.attach(hello)
|
||||
with pytest.raises(ProtocolError): m.wifi.attach(hello)
|
||||
assert m.wifi.valid(channel) # Attacker cannot evict the valid owner.
|
||||
assert request(m, ble, 3, '3', 'transport.close')['ok']
|
||||
assert m.status()['active_transport'] == 'ble'
|
||||
with pytest.raises(ProtocolError): m.wifi.attach(hello)
|
||||
m, ble, _, now = opened()
|
||||
_, _, hello = offer(m, ble)
|
||||
now[0] = 10
|
||||
with pytest.raises(ProtocolError): m.wifi.attach(hello)
|
||||
|
||||
|
||||
def test_no_wifi_address_and_wrong_identity():
|
||||
m, ble, _, _ = opened()
|
||||
m.wifi.address = lambda: None
|
||||
assert not request(m, ble, 2, '2', 'transport.offer')['ok']
|
||||
m.wifi.address = lambda: '198.51.100.20'
|
||||
o = request(m, ble, 3, '3', 'transport.offer')['result']
|
||||
c = wifi_cipher(base64.b64decode(o['secret']), o['channel_id'], server=False)
|
||||
with pytest.raises(ProtocolError):
|
||||
m.wifi.attach(o['channel_id'].encode() + c.encrypt(encode({'device_id': 'other'})))
|
||||
|
||||
|
||||
def test_duplicate_write_not_replayed_and_ble_only_commands_rejected():
|
||||
m, ble, calls, _ = opened()
|
||||
o, c, hello = offer(m, ble)
|
||||
channel, ack = m.wifi.attach(hello); c.decrypt(ack)
|
||||
for rid, method, expected in [('1', 'content.play', True), ('1', 'content.play', False),
|
||||
('2', 'wifi.set', False), ('3', 'session.ping', False)]:
|
||||
packet = c.encrypt(encode(dict(id=rid, method=method, params={})))
|
||||
assert json.loads(c.decrypt(m.wifi.accept(channel, packet)))['ok'] is expected
|
||||
assert calls == ['content.play']
|
||||
|
||||
|
||||
def test_real_websocket_records_and_disconnect():
|
||||
m, ble, calls, _ = opened()
|
||||
app = FastAPI(); app.state.mobile_sessions = m; install_wifi_route(app)
|
||||
o, c, hello = offer(m, ble)
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect('/ws/mobile') as ws:
|
||||
ws.send_bytes(hello)
|
||||
assert json.loads(c.decrypt(ws.receive_bytes()))['ready']
|
||||
ws.send_bytes(c.encrypt(encode(dict(id='1', method='status.get', params={}))))
|
||||
assert json.loads(c.decrypt(ws.receive_bytes()))['ok']
|
||||
m.disconnect('a')
|
||||
assert ws.receive()['type'] == 'websocket.close'
|
||||
assert calls == ['status.get']
|
||||
|
||||
|
||||
def test_wifi_idle_expiry_and_cross_subnet_not_filtered():
|
||||
m, ble, _, now = opened()
|
||||
m.wifi.address = lambda: '198.51.100.42'
|
||||
o, c, hello = offer(m, ble)
|
||||
assert o['host'] == '198.51.100.42'
|
||||
m.wifi.attach(hello)
|
||||
now[0] = 10
|
||||
assert not m.wifi.valid()
|
||||
assert m.status()['active_transport'] == 'ble'
|
||||
|
||||
|
||||
def test_runtime_preserves_existing_transport_statistics():
|
||||
from app.mobile.bluez import BluezRuntime
|
||||
m, ble, _, _ = opened()
|
||||
_, _, hello = offer(m, ble)
|
||||
m.wifi.attach(hello)
|
||||
status = BluezRuntime(m).status()
|
||||
assert status['active_transport'] == 'wifi'
|
||||
assert isinstance(status['transport'], dict)
|
||||
assert 'rx_bytes' in status['transport']
|
||||
|
||||
|
||||
def test_public_wifi_vector_is_reproducible():
|
||||
from pathlib import Path
|
||||
from generate_wifi_vector import generate
|
||||
assert generate() == json.loads((Path(__file__).parent / 'fixtures/protocol-wifi-v1.json').read_text(encoding='utf-8'))
|
||||
Reference in New Issue
Block a user