Add bounded X4 Bluetooth wake and record Android recovery proof
This commit is contained in:
@@ -283,6 +283,7 @@ class Inspector:
|
||||
self.report["notifications"].append({"hex": raw.hex(), **timestamp()})
|
||||
try:
|
||||
received.extend(reader.feed(raw))
|
||||
self.report["heartbeat_packets"] = reader.heartbeats
|
||||
except ValueError as error:
|
||||
errors.append(str(error))
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ class PacketReader:
|
||||
|
||||
def __init__(self):
|
||||
self.buffer = bytearray()
|
||||
self.heartbeats = 0
|
||||
|
||||
def feed(self, chunk):
|
||||
if not chunk or len(self.buffer) + len(chunk) > MAX_PACKET * 2:
|
||||
@@ -91,12 +92,21 @@ class PacketReader:
|
||||
result = []
|
||||
while len(self.buffer) >= 4:
|
||||
size = struct.unpack_from("<I", self.buffer)[0]
|
||||
if not 16 <= size <= MAX_PACKET:
|
||||
if size != 7 and not 16 <= size <= MAX_PACKET:
|
||||
raise ValueError("Unknown packet framing")
|
||||
if len(self.buffer) < size:
|
||||
break
|
||||
raw = bytes(self.buffer[:size])
|
||||
del self.buffer[:size]
|
||||
if size == 7:
|
||||
# X4 BLE04 capture; SDK CameraSession::Start registers type5
|
||||
# with OnHeartbeatHandler. This is not an Options response.
|
||||
if raw != b"\x07\x00\x00\x00\x05\x00\x00":
|
||||
raise ValueError("Unsupported short control packet")
|
||||
self.heartbeats += 1
|
||||
if self.heartbeats > 64:
|
||||
raise ValueError("Heartbeat bound exceeded")
|
||||
continue
|
||||
_, kind, reserved, code, content, ident, tail = struct.unpack("<IBHHBIH", raw[:16])
|
||||
if kind != 4 or reserved or tail or content != 2 or ident & 0xC0000000 != 0xC0000000:
|
||||
raise ValueError("Unsupported message header/direction/fragmentation")
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""One bounded X4 wake advertisement; no camera GATT writes or power-off."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if "__file__" in globals():
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from ble_diagnostic import ( # noqa: E402
|
||||
ADAPTER,
|
||||
DEVICE,
|
||||
PROPS,
|
||||
Inspector,
|
||||
assert_no_usb_owner,
|
||||
plain,
|
||||
timestamp,
|
||||
usb_snapshot,
|
||||
)
|
||||
|
||||
ADV = "org.bluez.LEAdvertisement1"
|
||||
ADV_MANAGER = "org.bluez.LEAdvertisingManager1"
|
||||
|
||||
|
||||
def wake_payload(target):
|
||||
if set(target) != {"device_id", "serial", "bluetooth_address", "wakeup_enabled"}:
|
||||
raise ValueError("Exact private target fields are required")
|
||||
serial = target["serial"]
|
||||
if not isinstance(serial, str) or not re.fullmatch(r"[A-Za-z0-9]{7,64}", serial):
|
||||
raise ValueError("Full USB serial is required, never a suffix alone")
|
||||
ident = "instax4_" + hashlib.sha256(serial.encode()).hexdigest()[:32]
|
||||
if target["device_id"] != ident or target["wakeup_enabled"] is not True:
|
||||
raise ValueError("USB identity and enabled camera wakeup must be confirmed")
|
||||
if not re.fullmatch(r"(?:[0-9A-F]{2}:){5}[0-9A-F]{2}", target["bluetooth_address"]):
|
||||
raise ValueError("Prior correlated Bluetooth address is required")
|
||||
# Company ID 0x004c is passed separately to BlueZ. X4 remote spec section1.
|
||||
return (
|
||||
bytes.fromhex("0215094f5242495409ff0f00")
|
||||
+ serial[-6:].encode()
|
||||
+ bytes.fromhex("00000000e401")
|
||||
)
|
||||
|
||||
|
||||
def matching_sdk(samples, expected):
|
||||
return any(
|
||||
d.get("idVendor") == "2e1a"
|
||||
and d.get("idProduct") == "0002"
|
||||
and "instax4_" + hashlib.sha256(d.get("serial", "").encode()).hexdigest()[:32] == expected
|
||||
for d in samples["devices"]
|
||||
)
|
||||
|
||||
|
||||
def run(target, report):
|
||||
payload = wake_payload(target)
|
||||
assert_no_usb_owner(target["device_id"])
|
||||
inspector = Inspector(report)
|
||||
advertisement = None
|
||||
manager = None
|
||||
registered = False
|
||||
attempted = False
|
||||
own_path = "/org/missioncore/x4/wake"
|
||||
initial = {}
|
||||
cleanup = []
|
||||
try:
|
||||
objects = inspector.objects()
|
||||
adapters = [
|
||||
(p, x)
|
||||
for p, x in objects.items()
|
||||
if x.get(ADAPTER, {}).get("Powered") and ADV_MANAGER in x
|
||||
]
|
||||
if len(adapters) != 1:
|
||||
raise RuntimeError("Exactly one powered advertising adapter is required")
|
||||
adapter_path, adapter = adapters[0]
|
||||
if (
|
||||
adapter[ADAPTER].get("Pairable", True)
|
||||
or adapter[ADAPTER].get("Discoverable", True)
|
||||
or adapter[ADV_MANAGER].get("ActiveInstances", -1) != 0
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Require non-pairable/non-discoverable adapter with no advertisements"
|
||||
)
|
||||
if not adapter[ADV_MANAGER].get("SupportedInstances"):
|
||||
raise RuntimeError("No advertising instance is available")
|
||||
initial = {p: plain(x[DEVICE]) for p, x in objects.items() if DEVICE in x}
|
||||
for device in initial.values():
|
||||
if device.get("Address") == target["bluetooth_address"] and any(
|
||||
device.get(k) for k in ("Connected", "Paired", "Bonded")
|
||||
):
|
||||
raise RuntimeError("Preserve existing target connection/bond")
|
||||
report["adapter_before"] = plain(adapter)
|
||||
manager = inspector.iface(adapter_path, ADV_MANAGER)
|
||||
import dbus.service
|
||||
|
||||
class Advertisement(dbus.service.Object):
|
||||
released = False
|
||||
|
||||
@dbus.service.method(PROPS, in_signature="s", out_signature="a{sv}")
|
||||
def GetAll(self, interface):
|
||||
if interface != ADV:
|
||||
return dbus.Dictionary({}, signature="sv")
|
||||
return dbus.Dictionary(
|
||||
{
|
||||
"Type": dbus.String("peripheral"),
|
||||
"ManufacturerData": dbus.Dictionary(
|
||||
{dbus.UInt16(0x004C): dbus.Array(payload, signature="y")},
|
||||
signature="qv",
|
||||
),
|
||||
"Discoverable": dbus.Boolean(True),
|
||||
"DiscoverableTimeout": dbus.UInt16(20),
|
||||
"Timeout": dbus.UInt16(20),
|
||||
# Honored only with BlueZ experimental features. Never enable
|
||||
# that setting here; actual on-air interval is not asserted.
|
||||
"MinInterval": dbus.UInt32(100),
|
||||
"MaxInterval": dbus.UInt32(150),
|
||||
},
|
||||
signature="sv",
|
||||
)
|
||||
|
||||
@dbus.service.method(ADV, in_signature="", out_signature="")
|
||||
def Release(self):
|
||||
self.released = True
|
||||
report["advertisement_released"] = timestamp()
|
||||
|
||||
advertisement = Advertisement(inspector.bus, own_path)
|
||||
result, errors = [], []
|
||||
assert_no_usb_owner(target["device_id"])
|
||||
report["advertisement_intent"] = {
|
||||
"manufacturer_id": 0x004C,
|
||||
"bytes": len(payload),
|
||||
"timeout_seconds": 20,
|
||||
**timestamp(),
|
||||
}
|
||||
attempted = True
|
||||
manager.RegisterAdvertisement(
|
||||
own_path,
|
||||
dbus.Dictionary({}, signature="sv"),
|
||||
reply_handler=lambda: result.append(True),
|
||||
error_handler=lambda e: errors.append(str(e)),
|
||||
timeout=5,
|
||||
)
|
||||
inspector.pump(6, lambda: bool(result or errors))
|
||||
if errors or not result:
|
||||
raise RuntimeError(errors[0] if errors else "Advertisement registration timed out")
|
||||
registered = True
|
||||
report["advertisement_registered"] = timestamp()
|
||||
inspector.pump(20, lambda: matching_sdk(usb_snapshot(), target["device_id"]))
|
||||
report["sdk_usb_returned"] = matching_sdk(usb_snapshot(), target["device_id"])
|
||||
finally:
|
||||
if attempted and advertisement and not advertisement.released:
|
||||
done, errors = [], []
|
||||
try:
|
||||
manager.UnregisterAdvertisement(
|
||||
own_path,
|
||||
reply_handler=lambda: done.append(True),
|
||||
error_handler=lambda e: errors.append(str(e)),
|
||||
timeout=5,
|
||||
)
|
||||
inspector.pump(6, lambda: bool(done or errors or advertisement.released))
|
||||
if (errors and registered) or not (done or errors or advertisement.released):
|
||||
cleanup.append("Advertisement removal did not complete")
|
||||
except Exception as error:
|
||||
cleanup.append(str(error))
|
||||
# A wake beacon is connectable; disconnect only the prior correlated
|
||||
# X4 address when it acquired a connection during our advertisement.
|
||||
try:
|
||||
current = {p: plain(x[DEVICE]) for p, x in inspector.objects().items() if DEVICE in x}
|
||||
for path, device in current.items():
|
||||
if device.get("Connected") and not initial.get(path, {}).get("Connected"):
|
||||
if attempted and device.get("Address") == target["bluetooth_address"]:
|
||||
inspector.iface(path, DEVICE).Disconnect(timeout=5)
|
||||
report["wake_connection_closed"] = True
|
||||
else:
|
||||
cleanup.append("New uncorrelated Bluetooth connection preserved")
|
||||
if manager:
|
||||
report["adapter_after"] = plain(inspector.objects().get(adapter_path, {}))
|
||||
except Exception as error:
|
||||
cleanup.append(str(error))
|
||||
try:
|
||||
if advertisement:
|
||||
advertisement.remove_from_connection()
|
||||
except Exception as error:
|
||||
cleanup.append(str(error))
|
||||
finally:
|
||||
# Private bus owner disappearance also removes its advertisement.
|
||||
inspector.close()
|
||||
report["cleanup_errors"].extend(cleanup)
|
||||
|
||||
|
||||
def main():
|
||||
report = {
|
||||
"schema": "missioncore.insta360.ble-wake/v1",
|
||||
"started": timestamp(),
|
||||
"vendor_writes": 0,
|
||||
"pair_calls": 0,
|
||||
"notify_subscriptions": 0,
|
||||
"state": "running",
|
||||
}
|
||||
try:
|
||||
raw = sys.stdin.buffer.read(4097)
|
||||
if len(raw) > 4096:
|
||||
raise ValueError("Private wake target exceeds limit")
|
||||
run(json.loads(raw), report)
|
||||
report["state"] = "complete" if report.get("sdk_usb_returned") else "no_sdk_return"
|
||||
except Exception as error:
|
||||
report.update(state="error", error=str(error))
|
||||
if report.get("cleanup_errors"):
|
||||
report["state"] = "error"
|
||||
report["finished"] = timestamp()
|
||||
print(json.dumps(report), flush=True)
|
||||
return 0 if report["state"] == "complete" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,53 +1,62 @@
|
||||
"""Package the exact diagnostic entry point; never install host prerequisites."""
|
||||
"""Package exact model diagnostics; never install host prerequisites."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ENTRY = """import hashlib, json, sys, zipfile
|
||||
ENTRY = """import hashlib, json, sys, types, zipfile
|
||||
from pathlib import Path
|
||||
with zipfile.ZipFile(sys.argv[0]) as archive:
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
source = archive.read("ble_diagnostic.py")
|
||||
codec = archive.read("ble_options.py")
|
||||
if hashlib.sha256(source).hexdigest() != manifest["source_sha256"]:
|
||||
raise RuntimeError("Diagnostic source hash mismatch")
|
||||
if hashlib.sha256(codec).hexdigest() != manifest["codec_sha256"]:
|
||||
raise RuntimeError("Diagnostic codec hash mismatch")
|
||||
ident = hashlib.sha256(source + b"\\0" + codec).hexdigest()[:24]
|
||||
if Path(sys.argv[0]).name != "mission-core-x4-ble-" + ident + ".pyz":
|
||||
raw = archive.read("manifest.json")
|
||||
manifest = json.loads(raw)
|
||||
names = ("ble_options", "ble_diagnostic", "ble_wake")
|
||||
modules = {name: archive.read(name + ".py") for name in names}
|
||||
ident = hashlib.sha256(raw).hexdigest()[:24]
|
||||
entry = manifest["entrypoint"]
|
||||
if entry not in {"ble_diagnostic", "ble_wake"}:
|
||||
raise RuntimeError("Unknown diagnostic entry point")
|
||||
prefix = "mission-core-x4-wake-" if entry == "ble_wake" else "mission-core-x4-ble-"
|
||||
if Path(sys.argv[0]).name != prefix + ident + ".pyz":
|
||||
raise RuntimeError("Diagnostic artifact name mismatch")
|
||||
import types
|
||||
module = types.ModuleType("ble_options")
|
||||
exec(compile(codec, "ble_options.py", "exec"), module.__dict__)
|
||||
sys.modules["ble_options"] = module
|
||||
exec(compile(source, "ble_diagnostic.py", "exec"), {"__name__": "__main__"})
|
||||
for name, source in modules.items():
|
||||
if hashlib.sha256(source).hexdigest() != manifest["modules"][name]:
|
||||
raise RuntimeError("Diagnostic source hash mismatch")
|
||||
for name, source in modules.items():
|
||||
module = types.ModuleType(name)
|
||||
sys.modules[name] = module
|
||||
exec(compile(source, name + ".py", "exec"), module.__dict__)
|
||||
raise SystemExit(sys.modules[entry].main())
|
||||
"""
|
||||
|
||||
|
||||
def build():
|
||||
source = (ROOT / "packaging/ble_diagnostic.py").read_bytes()
|
||||
codec = (ROOT / "packaging/ble_options.py").read_bytes()
|
||||
sha = hashlib.sha256(source).hexdigest()
|
||||
def build(wake=False):
|
||||
modules = {
|
||||
name: (ROOT / "packaging" / (name + ".py")).read_bytes()
|
||||
for name in ("ble_options", "ble_diagnostic", "ble_wake")
|
||||
}
|
||||
manifest = {
|
||||
"schema": "missioncore.insta360.ble-diagnostic-artifact/v1",
|
||||
"source_sha256": sha,
|
||||
"codec_sha256": hashlib.sha256(codec).hexdigest(),
|
||||
"schema": "missioncore.insta360.ble-diagnostic-artifact/v2",
|
||||
"entrypoint": "ble_wake" if wake else "ble_diagnostic",
|
||||
"modules": {name: hashlib.sha256(data).hexdigest() for name, data in modules.items()},
|
||||
"dependencies": ["bluez", "python3-dbus", "python3-gi"],
|
||||
"persistent_system_changes": False,
|
||||
"vendor_commands": [8],
|
||||
"scope": "bounded-discovery-and-explicit-identity-options-read",
|
||||
"vendor_commands": [] if wake else [8],
|
||||
"scope": "single-addressed-wake-advertisement"
|
||||
if wake
|
||||
else "bounded-discovery-and-explicit-identity-options-read",
|
||||
}
|
||||
ident = hashlib.sha256(source + b"\0" + codec).hexdigest()[:24]
|
||||
output = ROOT / "build" / ("mission-core-x4-ble-" + ident + ".pyz")
|
||||
raw = json.dumps(manifest, sort_keys=True).encode()
|
||||
ident = hashlib.sha256(raw).hexdigest()[:24]
|
||||
prefix = "mission-core-x4-wake-" if wake else "mission-core-x4-ble-"
|
||||
output = ROOT / "build" / (prefix + ident + ".pyz")
|
||||
with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive:
|
||||
for name, data in {
|
||||
"__main__.py": ENTRY.encode(),
|
||||
"ble_diagnostic.py": source,
|
||||
"ble_options.py": codec,
|
||||
"manifest.json": json.dumps(manifest, sort_keys=True).encode(),
|
||||
"manifest.json": raw,
|
||||
**{name + ".py": data for name, data in modules.items()},
|
||||
}.items():
|
||||
info = zipfile.ZipInfo(name, date_time=(2026, 9, 10, 0, 0, 0))
|
||||
info.external_attr = 0o600 << 16
|
||||
@@ -56,10 +65,12 @@ def build():
|
||||
return {
|
||||
"artifact": str(output),
|
||||
"sha256": hashlib.sha256(output.read_bytes()).hexdigest(),
|
||||
"source_sha256": sha,
|
||||
"bytes": output.stat().st_size,
|
||||
"modules": manifest["modules"],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(build()))
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--wake", action="store_true")
|
||||
print(json.dumps(build(parser.parse_args().wake)))
|
||||
|
||||
@@ -16,7 +16,7 @@ sys.path.insert(0, str(REPOSITORY / "scripts/packaging"))
|
||||
from debian import package # noqa: E402
|
||||
from fetch_sdk import verify # noqa: E402
|
||||
|
||||
VERSION = "0.1.3-4"
|
||||
VERSION = "0.1.3-6"
|
||||
WHEELS = {
|
||||
"aiohappyeyeballs",
|
||||
"aiohttp",
|
||||
@@ -159,6 +159,7 @@ def build(output):
|
||||
"prepare.py",
|
||||
"ble_diagnostic.py",
|
||||
"ble_options.py",
|
||||
"ble_wake.py",
|
||||
):
|
||||
files.append(
|
||||
("usr/lib/mission-core-node/insta360/" + name, (PACKAGING / name).read_bytes(), 0o644)
|
||||
|
||||
@@ -23,7 +23,7 @@ data = subprocess.check_output(["/usr/bin/dpkg-deb", "--fsys-tarfile", str(packa
|
||||
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
|
||||
payload = archive.extractfile("usr/share/mission-core-node/insta360/payload.zip").read()
|
||||
bundle = json.load(archive.extractfile("usr/share/mission-core-node/insta360/bundle.json"))
|
||||
for name in ("ble_diagnostic.py", "ble_options.py"):
|
||||
for name in ("ble_diagnostic.py", "ble_options.py", "ble_wake.py"):
|
||||
packaged = archive.extractfile("usr/lib/mission-core-node/insta360/" + name).read()
|
||||
if packaged != (ROOT / "packaging" / name).read_bytes():
|
||||
raise ValueError("Packaged BLE module differs from the qualified source")
|
||||
|
||||
@@ -53,6 +53,30 @@ class OptionsTests(unittest.TestCase):
|
||||
{"acknowledged": True, "present": False, "value": None},
|
||||
)
|
||||
|
||||
def test_captured_heartbeat_is_not_an_options_response(self):
|
||||
heartbeat = bytes.fromhex("07000000050000")
|
||||
raw = reply(field(1, 95) + field(2, field(95, 1)))
|
||||
reader = codec.PacketReader()
|
||||
self.assertEqual(reader.feed(heartbeat[:4]), [])
|
||||
self.assertEqual(reader.feed(heartbeat[4:]), [])
|
||||
self.assertEqual(reader.heartbeats, 1)
|
||||
packets = reader.feed(heartbeat + raw[:9])
|
||||
self.assertEqual(packets, [])
|
||||
packets = reader.feed(raw[9:] + heartbeat)
|
||||
self.assertEqual(len(packets), 1)
|
||||
self.assertEqual(reader.heartbeats, 3)
|
||||
self.assertEqual(codec.decode_options_response(packets[0], 1, [95])[95]["value"], 1)
|
||||
|
||||
def test_short_control_packets_are_exact_and_bounded(self):
|
||||
for raw in ("07000000040000", "07000000050100", "0800000005000000"):
|
||||
with self.assertRaises(ValueError):
|
||||
codec.PacketReader().feed(bytes.fromhex(raw))
|
||||
reader = codec.PacketReader()
|
||||
for _ in range(64):
|
||||
self.assertEqual(reader.feed(bytes.fromhex("07000000050000")), [])
|
||||
with self.assertRaises(ValueError):
|
||||
reader.feed(bytes.fromhex("07000000050000"))
|
||||
|
||||
def test_explicit_zero_and_packed_option_list(self):
|
||||
packet = codec.PacketReader().feed(reply(field(1, b"\x5f") + field(2, field(95, 0))))[0]
|
||||
self.assertEqual(codec.decode_options_response(packet, 1, [95])[95]["value"], 0)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Synthetic identity and exact wake payload; no host Bluetooth access."""
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
directory = Path(__file__).resolve().parents[1] / "packaging"
|
||||
sys.path.insert(0, str(directory))
|
||||
spec = importlib.util.spec_from_file_location("wake_under_test", directory / "ble_wake.py")
|
||||
wake = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(wake)
|
||||
sys.path.pop(0)
|
||||
|
||||
|
||||
def target(serial="SYNTHETIC34UQG5"):
|
||||
return {
|
||||
"serial": serial,
|
||||
"device_id": "instax4_" + hashlib.sha256(serial.encode()).hexdigest()[:32],
|
||||
"bluetooth_address": "02:00:00:00:00:01",
|
||||
"wakeup_enabled": True,
|
||||
}
|
||||
|
||||
|
||||
class WakeTests(unittest.TestCase):
|
||||
def test_spec_vector_has_one_company_id_and_fits_legacy_advertisement(self):
|
||||
data = wake.wake_payload(target())
|
||||
self.assertEqual(data.hex(), "0215094f5242495409ff0f0033345551473500000000e401")
|
||||
self.assertEqual(len(data) + 7, 31)
|
||||
|
||||
def test_unknown_wakeup_suffix_only_and_wrong_identity_are_rejected(self):
|
||||
for value in (
|
||||
target("34UQG5"),
|
||||
dict(target(), device_id="instax4_" + "0" * 32),
|
||||
dict(target(), wakeup_enabled=False),
|
||||
dict(target(), bluetooth_address="unknown"),
|
||||
):
|
||||
with self.assertRaises(ValueError):
|
||||
wake.wake_payload(value)
|
||||
|
||||
def test_sdk_identity_requires_full_serial_and_correct_mode(self):
|
||||
value = target()
|
||||
device = {"idVendor": "2e1a", "idProduct": "0002", "serial": value["serial"]}
|
||||
self.assertTrue(wake.matching_sdk({"devices": [device]}, value["device_id"]))
|
||||
for changed in (
|
||||
dict(device, serial="34UQG5"),
|
||||
dict(device, idVendor="070a", idProduct="4026"),
|
||||
):
|
||||
self.assertFalse(wake.matching_sdk({"devices": [changed]}, value["device_id"]))
|
||||
|
||||
def test_present_sdk_owner_blocks_before_any_bluetooth_access(self):
|
||||
with (
|
||||
patch.object(wake, "assert_no_usb_owner", side_effect=RuntimeError("owned")),
|
||||
patch.object(wake, "Inspector") as inspector,
|
||||
):
|
||||
with self.assertRaisesRegex(RuntimeError, "owned"):
|
||||
wake.run(target(), {})
|
||||
inspector.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user