feat: add profiled DualSense PCM haptics experiment
This commit is contained in:
parent
3b1366067b
commit
0c626fb3b3
29 changed files with 4469 additions and 5 deletions
24
tests/haptics_experiment_native_stubs/btstack.h
Normal file
24
tests/haptics_experiment_native_stubs/btstack.h
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
struct btstack_timer_source_t {
|
||||
void (*process)(btstack_timer_source_t*) = nullptr;
|
||||
void* context = nullptr;
|
||||
uint64_t timeout_us = 0;
|
||||
};
|
||||
|
||||
constexpr uint8_t ERROR_CODE_SUCCESS = 0;
|
||||
enum gap_connection_type_t {
|
||||
GAP_CONNECTION_INVALID, GAP_CONNECTION_ACL, GAP_CONNECTION_LE, GAP_CONNECTION_SCO
|
||||
};
|
||||
gap_connection_type_t gap_get_connection_type(uint16_t handle);
|
||||
|
||||
void btstack_run_loop_set_timer_handler(
|
||||
btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*));
|
||||
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms);
|
||||
void btstack_run_loop_add_timer(btstack_timer_source_t* timer);
|
||||
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer);
|
||||
uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid);
|
||||
uint8_t l2cap_request_can_send_now_event(uint16_t cid);
|
||||
uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size);
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#pragma once
|
||||
|
||||
#include <cassert>
|
||||
|
||||
struct critical_section_t {
|
||||
bool initialized = false;
|
||||
};
|
||||
|
||||
inline unsigned native_haptics_lock_depth = 0;
|
||||
|
||||
inline void critical_section_init(critical_section_t* section) {
|
||||
assert(!section->initialized);
|
||||
section->initialized = true;
|
||||
}
|
||||
inline void critical_section_enter_blocking(critical_section_t* section) {
|
||||
assert(section->initialized);
|
||||
assert(native_haptics_lock_depth == 0);
|
||||
++native_haptics_lock_depth;
|
||||
}
|
||||
inline void critical_section_exit(critical_section_t*) {
|
||||
assert(native_haptics_lock_depth == 1);
|
||||
--native_haptics_lock_depth;
|
||||
}
|
||||
9
tests/haptics_experiment_native_stubs/pico/stdlib.h
Normal file
9
tests/haptics_experiment_native_stubs/pico/stdlib.h
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
uint64_t time_us_64();
|
||||
|
||||
// Match SDK section spelling so the RAM build compiles the real annotations.
|
||||
#define __not_in_flash(group) __attribute__((section(".time_critical." group)))
|
||||
#define __time_critical_func(name) __not_in_flash(#name) name
|
||||
47
tests/haptics_experiment_native_stubs/uni.h
Normal file
47
tests/haptics_experiment_native_stubs/uni.h
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#pragma once
|
||||
|
||||
#include <btstack.h>
|
||||
|
||||
struct uni_hid_device_s;
|
||||
typedef struct uni_hid_device_s uni_hid_device_t;
|
||||
|
||||
struct uni_report_parser_t {
|
||||
void (*play_dual_rumble)(uni_hid_device_t*, uint16_t, uint16_t,
|
||||
uint8_t, uint8_t) = nullptr;
|
||||
};
|
||||
|
||||
enum uni_bt_conn_protocol_t {
|
||||
UNI_BT_CONN_PROTOCOL_NONE,
|
||||
UNI_BT_CONN_PROTOCOL_BR_EDR,
|
||||
UNI_BT_CONN_PROTOCOL_BLE,
|
||||
};
|
||||
|
||||
struct uni_bt_conn_t {
|
||||
uint16_t handle = 0;
|
||||
uint16_t control_cid = 0;
|
||||
uint16_t interrupt_cid = 0;
|
||||
bool connected = false;
|
||||
uni_bt_conn_protocol_t protocol = UNI_BT_CONN_PROTOCOL_NONE;
|
||||
};
|
||||
|
||||
struct uni_circular_buffer_t {
|
||||
unsigned queued = 0;
|
||||
};
|
||||
uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer);
|
||||
|
||||
struct uni_hid_device_s {
|
||||
uint16_t vendor_id = 0;
|
||||
uint16_t product_id = 0;
|
||||
uni_report_parser_t report_parser;
|
||||
uni_bt_conn_t conn;
|
||||
uni_circular_buffer_t outgoing_buffer;
|
||||
|
||||
// Fake parser/link state. The module only sees the real fields above.
|
||||
uint16_t remote_mtu = 143;
|
||||
gap_connection_type_t connection_type = GAP_CONNECTION_ACL;
|
||||
bool parser_rumble_active = false;
|
||||
bool parser_rumble_delayed = false;
|
||||
btstack_timer_source_t parser_timer;
|
||||
bool notification_pending = false;
|
||||
bool credit = true;
|
||||
};
|
||||
716
tests/haptics_experiment_test.cpp
Normal file
716
tests/haptics_experiment_test.cpp
Normal file
|
|
@ -0,0 +1,716 @@
|
|||
#include "input/haptics_experiment.h"
|
||||
#include "input/haptics_transport_probe.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
#include <btstack.h>
|
||||
#include <pico/critical_section.h>
|
||||
#include <uni.h>
|
||||
|
||||
namespace {
|
||||
|
||||
enum class Delivery { kImmediate, kDeferred, kNever };
|
||||
enum class GenericKind { kCompatibility, kLed };
|
||||
|
||||
struct Pcm {
|
||||
uint64_t at_us;
|
||||
uint16_t cid;
|
||||
std::array<uint8_t, 143> bytes;
|
||||
};
|
||||
struct Generic {
|
||||
uni_hid_device_t* device;
|
||||
uint64_t at_us;
|
||||
GenericKind kind;
|
||||
uint8_t weak;
|
||||
uint8_t strong;
|
||||
};
|
||||
|
||||
uint64_t now_us = 10000123;
|
||||
std::array<uni_hid_device_t, 4> devices;
|
||||
std::vector<btstack_timer_source_t*> timers;
|
||||
std::vector<Pcm> pcm;
|
||||
std::vector<Generic> generic_sent;
|
||||
std::vector<Generic> generic_queue;
|
||||
Delivery delivery = Delivery::kImmediate;
|
||||
uni_hid_device_t* permission = nullptr;
|
||||
unsigned request_depth = 0;
|
||||
unsigned max_request_depth = 0;
|
||||
unsigned request_calls = 0;
|
||||
unsigned send_calls = 0;
|
||||
unsigned timer_calls = 0;
|
||||
unsigned fail_requests = 0;
|
||||
unsigned fail_sends = 0;
|
||||
uint32_t send_cost_us = 0;
|
||||
uint32_t request_cost_us = 0;
|
||||
bool reenter_send = false;
|
||||
|
||||
void no_lock() {
|
||||
assert(native_haptics_lock_depth == 0);
|
||||
}
|
||||
|
||||
HapticsExperimentDiagnostics snapshot() {
|
||||
HapticsExperimentDiagnostics out;
|
||||
haptics_experiment_snapshot(&out);
|
||||
return out;
|
||||
}
|
||||
|
||||
uni_hid_device_t* device_for_cid(uint16_t cid) {
|
||||
for (auto& device : devices) {
|
||||
if (device.conn.interrupt_cid == cid) {
|
||||
return &device;
|
||||
}
|
||||
}
|
||||
assert(false && "stale or unknown L2CAP CID");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void emit_generic(uni_hid_device_t* device, GenericKind kind,
|
||||
uint8_t weak = 0, uint8_t strong = 0) {
|
||||
no_lock();
|
||||
Generic report{device, now_us, kind, weak, strong};
|
||||
if (device->credit) {
|
||||
generic_sent.push_back(report);
|
||||
} else {
|
||||
generic_queue.push_back(report);
|
||||
++device->outgoing_buffer.queued;
|
||||
}
|
||||
}
|
||||
|
||||
void parser_off(btstack_timer_source_t* timer) {
|
||||
auto* device = static_cast<uni_hid_device_t*>(timer->context);
|
||||
assert(device->parser_rumble_active);
|
||||
device->parser_rumble_active = false;
|
||||
emit_generic(device, GenericKind::kCompatibility);
|
||||
}
|
||||
|
||||
void parser_delayed_on(btstack_timer_source_t* timer) {
|
||||
auto* device = static_cast<uni_hid_device_t*>(timer->context);
|
||||
assert(device->parser_rumble_delayed);
|
||||
device->parser_rumble_delayed = false;
|
||||
emit_generic(device, GenericKind::kCompatibility, 17, 23);
|
||||
}
|
||||
|
||||
// Behavioral fake of the relevant Bluepad32 DS5 parser contract: duration=0
|
||||
// does not emit anything if already disabled; duration>0 forces compatibility
|
||||
// and installs a timer-off. This catches a "restore" which is actually a no-op.
|
||||
void play_rumble(uni_hid_device_t* device, uint16_t delay_ms,
|
||||
uint16_t duration_ms, uint8_t weak, uint8_t strong) {
|
||||
no_lock();
|
||||
const bool was_active = device->parser_rumble_active ||
|
||||
device->parser_rumble_delayed;
|
||||
if (was_active) {
|
||||
btstack_run_loop_remove_timer(&device->parser_timer);
|
||||
}
|
||||
device->parser_rumble_active = false;
|
||||
device->parser_rumble_delayed = false;
|
||||
device->parser_timer.context = device;
|
||||
if (delay_ms != 0) {
|
||||
device->parser_rumble_delayed = true;
|
||||
device->parser_timer.process = parser_delayed_on;
|
||||
btstack_run_loop_set_timer(&device->parser_timer, delay_ms);
|
||||
btstack_run_loop_add_timer(&device->parser_timer);
|
||||
} else if (duration_ms != 0) {
|
||||
emit_generic(device, GenericKind::kCompatibility, weak, strong);
|
||||
device->parser_rumble_active = true;
|
||||
device->parser_timer.process = parser_off;
|
||||
btstack_run_loop_set_timer(&device->parser_timer, duration_ms);
|
||||
btstack_run_loop_add_timer(&device->parser_timer);
|
||||
} else if (was_active) {
|
||||
emit_generic(device, GenericKind::kCompatibility);
|
||||
}
|
||||
}
|
||||
|
||||
bool dispatch(uni_hid_device_t* device, uint16_t cid) {
|
||||
no_lock();
|
||||
if (cid == device->conn.interrupt_cid) {
|
||||
device->notification_pending = false;
|
||||
}
|
||||
permission = device->credit ? device : nullptr;
|
||||
const bool consumed = haptics_experiment_on_can_send_now(device, cid);
|
||||
if (!consumed && device->credit) {
|
||||
const auto it = std::find_if(generic_queue.begin(), generic_queue.end(),
|
||||
[device](const Generic& report) {
|
||||
return report.device == device;
|
||||
});
|
||||
if (it != generic_queue.end()) {
|
||||
Generic report = *it;
|
||||
report.at_us = now_us;
|
||||
generic_sent.push_back(report);
|
||||
generic_queue.erase(it);
|
||||
--device->outgoing_buffer.queued;
|
||||
}
|
||||
}
|
||||
permission = nullptr;
|
||||
return consumed;
|
||||
}
|
||||
|
||||
void run_until(uint64_t target_us) {
|
||||
assert(target_us >= now_us);
|
||||
unsigned iterations = 0;
|
||||
while (!timers.empty()) {
|
||||
const auto it = std::min_element(
|
||||
timers.begin(), timers.end(),
|
||||
[](const auto* a, const auto* b) { return a->timeout_us < b->timeout_us; });
|
||||
btstack_timer_source_t* timer = *it;
|
||||
if (timer->timeout_us > target_us) {
|
||||
break;
|
||||
}
|
||||
now_us = std::max(now_us, timer->timeout_us);
|
||||
timers.erase(it);
|
||||
assert(++iterations < 3000 && "recursive or permanently polling timer");
|
||||
++timer_calls;
|
||||
timer->process(timer);
|
||||
}
|
||||
now_us = std::max(now_us, target_us);
|
||||
}
|
||||
|
||||
void reset(uint64_t at_us = 10000123) {
|
||||
for (auto& device : devices) {
|
||||
haptics_experiment_detach(&device);
|
||||
}
|
||||
if (snapshot().state == HapticsExperimentState::kPending) {
|
||||
assert(haptics_experiment_request(0, snapshot().slot));
|
||||
haptics_experiment_poll();
|
||||
}
|
||||
timers.clear();
|
||||
devices = {};
|
||||
pcm.clear();
|
||||
generic_sent.clear();
|
||||
generic_queue.clear();
|
||||
now_us = at_us;
|
||||
delivery = Delivery::kImmediate;
|
||||
permission = nullptr;
|
||||
request_depth = max_request_depth = request_calls = send_calls = timer_calls = 0;
|
||||
fail_requests = fail_sends = 0;
|
||||
send_cost_us = request_cost_us = 0;
|
||||
reenter_send = false;
|
||||
for (unsigned slot = 0; slot < devices.size(); ++slot) {
|
||||
auto& device = devices[slot];
|
||||
device.vendor_id = 0x054c;
|
||||
device.product_id = 0x0ce6;
|
||||
device.conn.handle = static_cast<uint16_t>(slot);
|
||||
// Bluepad32's Classic path does not initialize this cached field.
|
||||
device.conn.protocol = UNI_BT_CONN_PROTOCOL_NONE;
|
||||
device.conn.connected = true;
|
||||
device.conn.interrupt_cid = static_cast<uint16_t>(0x40 + slot * 2);
|
||||
device.conn.control_cid = device.conn.interrupt_cid + 1;
|
||||
device.report_parser.play_dual_rumble = play_rumble;
|
||||
haptics_experiment_attach(static_cast<uint8_t>(slot), 100 + slot, &device);
|
||||
}
|
||||
assert(pcm.empty() && generic_sent.empty()); // No pairing/boot tone.
|
||||
}
|
||||
|
||||
uint64_t start(uint8_t slot = 0) {
|
||||
const uint32_t previous_id = snapshot().run_id;
|
||||
assert(haptics_experiment_request(1, slot));
|
||||
const auto pending = snapshot();
|
||||
assert(pending.run_id == previous_id + 1);
|
||||
assert(pending.slot == slot && pending.state == HapticsExperimentState::kPending);
|
||||
assert(!haptics_experiment_request(1, slot));
|
||||
assert(!haptics_experiment_request(1, static_cast<uint8_t>((slot + 1) % 4)));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kRunning);
|
||||
assert(haptics_experiment_owns(&devices[slot]));
|
||||
assert(!haptics_experiment_owns(&devices[(slot + 1) % 4]));
|
||||
return now_us;
|
||||
}
|
||||
|
||||
uint64_t due(uint64_t started, uint32_t packet) {
|
||||
return started + (static_cast<uint64_t>(packet) * 64000 + 2) / 3;
|
||||
}
|
||||
|
||||
void verify_block(const Pcm& packet, uint32_t index, bool forced_silence = false) {
|
||||
const auto& b = packet.bytes;
|
||||
assert(b[0] == 0xa2 && b[1] == 0x32 && b[2] == 0);
|
||||
unsigned sample_offset = 10;
|
||||
unsigned frames = 64;
|
||||
if (b[3] == 0x90) {
|
||||
assert(b[4] == 63);
|
||||
for (unsigned i = 5; i < 68; ++i) assert(b[i] == 0);
|
||||
assert(b[68] == 0x92 && b[69] == 64);
|
||||
sample_offset = 70;
|
||||
frames = 32;
|
||||
} else {
|
||||
assert(b[3] == 0x91 && b[4] == 3 && b[5] == 0x62);
|
||||
assert(b[6] == 16 && b[8] == 0xd2 && b[9] == 64);
|
||||
}
|
||||
for (unsigned i = sample_offset + frames * 2; i < 139; ++i) {
|
||||
assert(b[i] == 0);
|
||||
}
|
||||
const bool pattern_tone = index >= 48 && index < 240 &&
|
||||
((index - 48) / 12) % 2 == 0;
|
||||
const bool tone = pattern_tone && !forced_silence;
|
||||
const unsigned phase = tone ? ((index - 48) / 12) % 4 : 0;
|
||||
const unsigned side = phase == 0 ? 0 : 1;
|
||||
const double hz = phase == 0 ? 100.0 : 200.0;
|
||||
for (unsigned frame = 0; frame < frames; ++frame) {
|
||||
for (unsigned channel = 0; channel < 2; ++channel) {
|
||||
const int value = static_cast<int8_t>(b[sample_offset + frame * 2 + channel]);
|
||||
if (!tone || channel != side) {
|
||||
assert(value == 0);
|
||||
} else {
|
||||
const unsigned sample = ((index - 48) % 12) * 64 + frame;
|
||||
const int expected = static_cast<int>(std::lround(
|
||||
32.0 * std::sin(2.0 * 3.14159265358979323846 * hz * sample / 3000.0)));
|
||||
assert(std::abs(value - expected) <= 1);
|
||||
assert(std::abs(value) <= 32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void nominal_run(const char* corpus_path) {
|
||||
reset();
|
||||
const uint64_t started = start();
|
||||
// This is the BTstack synchronous reentry reproduction: the very first
|
||||
// request sends one block before request_can_send_now() returns.
|
||||
assert(pcm.size() == 1 && snapshot().synchronous_callbacks == 1);
|
||||
assert(!devices[0].notification_pending);
|
||||
const uint64_t early_tick = due(started, 1) / 1000 * 1000;
|
||||
run_until(early_tick);
|
||||
assert(pcm.size() == 1); // SDK early millisecond wake must not send early.
|
||||
run_until(started + 6148000);
|
||||
const auto done = snapshot();
|
||||
assert(done.state == HapticsExperimentState::kCompleted);
|
||||
assert(done.sent_packets == 288 && done.generated_packets == 288);
|
||||
assert(done.skipped_packets == 0 && done.send_failures == 0);
|
||||
assert(done.can_send_requests == 288 && done.synchronous_callbacks == 288);
|
||||
assert(max_request_depth == 1 && request_calls == 288);
|
||||
assert(timer_calls < 1250); // No permanent 1 ms poll for this 6.144 s run.
|
||||
assert(pcm.size() == 288);
|
||||
assert(done.first_tone_due_us == static_cast<uint32_t>(started + 1024000));
|
||||
assert(done.first_tone_sent_us == static_cast<uint32_t>(pcm[48].at_us));
|
||||
assert(done.last_sent_us == static_cast<uint32_t>(pcm.back().at_us));
|
||||
assert(done.max_send_gap_us <= 22000 && done.max_lateness_us < 1000);
|
||||
assert(done.elapsed_us >= 6147000 && done.elapsed_us < 6148000);
|
||||
assert(done.max_generate_us == 0);
|
||||
assert(!haptics_experiment_owns(&devices[0]) && timers.empty());
|
||||
assert(generic_sent.size() == 2); // Forced compatibility, then parser off.
|
||||
assert(generic_sent.front().at_us >= started + 6144000);
|
||||
assert(!devices[0].parser_rumble_active);
|
||||
for (const auto& report : generic_sent) {
|
||||
assert(report.kind == GenericKind::kCompatibility);
|
||||
assert(report.weak == 0 && report.strong == 0);
|
||||
}
|
||||
std::ofstream corpus(corpus_path, std::ios::binary);
|
||||
assert(corpus.is_open());
|
||||
for (uint32_t i = 0; i < pcm.size(); ++i) {
|
||||
assert(pcm[i].at_us >= due(started, i));
|
||||
assert(pcm[i].at_us - due(started, i) < 1000);
|
||||
if (i != 0) assert(pcm[i].bytes[7] == static_cast<uint8_t>(i * 2));
|
||||
verify_block(pcm[i], i);
|
||||
corpus.write(reinterpret_cast<const char*>(pcm[i].bytes.data()), 143);
|
||||
}
|
||||
// Reference-sized 0x10 native-mode state followed by a silent PCM block.
|
||||
// Independent known answer computed with Python zlib over the A2 prefix.
|
||||
assert(pcm[0].bytes[3] == 0x90);
|
||||
assert(pcm[0].bytes[139] == 0x00 && pcm[0].bytes[140] == 0x41 &&
|
||||
pcm[0].bytes[141] == 0xfd && pcm[0].bytes[142] == 0x53);
|
||||
corpus.close();
|
||||
run_until(now_us + 200000);
|
||||
assert(snapshot().elapsed_us == done.elapsed_us && pcm.size() == 288);
|
||||
}
|
||||
|
||||
void stalled_deadlines() {
|
||||
reset();
|
||||
const uint64_t started = start();
|
||||
run_until(due(started, 102) + 1000);
|
||||
const unsigned before = static_cast<unsigned>(pcm.size());
|
||||
const uint32_t next = snapshot().sent_packets;
|
||||
now_us += 250000; // The main loop did not run at all during this stall.
|
||||
const uint32_t current = static_cast<uint32_t>((now_us - started) * 3 / 64000);
|
||||
run_until(now_us);
|
||||
assert(pcm.size() == before + 1); // No catch-up replay burst.
|
||||
assert(snapshot().skipped_packets == current - next);
|
||||
verify_block(pcm.back(), current);
|
||||
assert(snapshot().max_send_gap_us >= 250000);
|
||||
run_until(started + 6148000);
|
||||
const auto done = snapshot();
|
||||
assert(done.state == HapticsExperimentState::kCompleted);
|
||||
assert(done.sent_packets + done.skipped_packets == 288);
|
||||
assert(done.elapsed_us < 6148000);
|
||||
}
|
||||
|
||||
void deferred_and_missing_callbacks() {
|
||||
reset();
|
||||
delivery = Delivery::kDeferred;
|
||||
const uint64_t started = start();
|
||||
assert(pcm.empty() && devices[0].notification_pending);
|
||||
run_until(started + 2300000);
|
||||
assert(request_calls == 1 && timer_calls == 0);
|
||||
assert(!dispatch(&devices[1], devices[1].conn.interrupt_cid));
|
||||
assert(dispatch(&devices[0], devices[0].conn.control_cid));
|
||||
assert(pcm.empty() && devices[0].notification_pending);
|
||||
assert(dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(pcm.size() == 1);
|
||||
assert(snapshot().max_request_wait_us == 2300000);
|
||||
assert(snapshot().synchronous_callbacks == 0);
|
||||
assert(snapshot().skipped_packets == 107);
|
||||
verify_block(pcm.back(), 107);
|
||||
delivery = Delivery::kImmediate;
|
||||
run_until(started + 6148000);
|
||||
assert(snapshot().state == HapticsExperimentState::kCompleted);
|
||||
assert(snapshot().sent_packets + snapshot().skipped_packets == 288);
|
||||
|
||||
reset();
|
||||
delivery = Delivery::kNever;
|
||||
const uint64_t missing_start = start();
|
||||
run_until(missing_start + 6248000);
|
||||
const auto missing = snapshot();
|
||||
assert(missing.state == HapticsExperimentState::kError);
|
||||
assert(missing.last_error == 4 && missing.send_failures == 1);
|
||||
assert(missing.sent_packets == 0 && missing.skipped_packets == 288);
|
||||
assert(missing.max_request_wait_us >= 6244000);
|
||||
assert(request_calls == 1 && timer_calls < 10 && timers.empty());
|
||||
assert(!haptics_experiment_owns(&devices[0]));
|
||||
assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(pcm.empty());
|
||||
}
|
||||
|
||||
void stop_preemption_and_restore() {
|
||||
reset();
|
||||
const uint64_t started = start();
|
||||
run_until(due(started, 98) + 1000);
|
||||
delivery = Delivery::kDeferred;
|
||||
run_until(due(started, 99) + 1000);
|
||||
assert(devices[0].notification_pending);
|
||||
const auto before = snapshot();
|
||||
assert(!haptics_experiment_request(0, 1));
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
run_until(now_us + 10000);
|
||||
const unsigned sent_before = static_cast<unsigned>(pcm.size());
|
||||
assert(dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(pcm.size() == sent_before + 1);
|
||||
verify_block(pcm.back(), 99, true); // Pending tone permission now sends silence.
|
||||
assert(!haptics_experiment_request(1, 0)); // Compatibility is still settling.
|
||||
run_until(now_us + 4000);
|
||||
assert(snapshot().state == HapticsExperimentState::kStopped);
|
||||
assert(snapshot().run_id == before.run_id);
|
||||
assert(!devices[0].parser_rumble_active && timers.empty());
|
||||
assert(generic_sent.size() == 2 && generic_sent[0].at_us >= pcm.back().at_us);
|
||||
const auto stopped_elapsed = snapshot().elapsed_us;
|
||||
run_until(now_us + 7000000);
|
||||
assert(pcm.size() == sent_before + 1 && snapshot().elapsed_us == stopped_elapsed);
|
||||
|
||||
reset();
|
||||
const uint32_t previous = snapshot().run_id;
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
const uint32_t pending_generation = snapshot().connection_generation;
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kStopped);
|
||||
assert(snapshot().run_id == previous + 1 && pcm.empty() && timers.empty());
|
||||
assert(snapshot().connection_generation == pending_generation);
|
||||
|
||||
// Disconnect in the ownership-settling window, immediately after restore.
|
||||
// Bluepad32 deletes the instance without removing private parser timers.
|
||||
reset();
|
||||
start();
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(haptics_experiment_owns(&devices[0]));
|
||||
assert(!devices[0].parser_rumble_active);
|
||||
haptics_experiment_detach(&devices[0]);
|
||||
const auto compatibility_count = generic_sent.size();
|
||||
devices[0] = {}; // Simulate upstream zeroing/reusing the parser instance.
|
||||
run_until(now_us + 10000);
|
||||
assert(snapshot().state == HapticsExperimentState::kDisconnected);
|
||||
assert(timers.empty() && generic_sent.size() == compatibility_count);
|
||||
|
||||
reset();
|
||||
delivery = Delivery::kNever;
|
||||
devices[0].credit = false;
|
||||
start();
|
||||
const uint64_t stopped_at = now_us;
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
// Repeated stops must not perpetually extend the lifecycle watchdog.
|
||||
run_until(stopped_at + 50000);
|
||||
assert(haptics_experiment_request(0, 0));
|
||||
haptics_experiment_poll();
|
||||
run_until(stopped_at + 202000);
|
||||
assert(snapshot().state == HapticsExperimentState::kError);
|
||||
assert(snapshot().last_error == 4 && snapshot().send_failures >= 1);
|
||||
assert(snapshot().sent_packets == 0 && !haptics_experiment_owns(&devices[0]));
|
||||
assert(timers.empty() && generic_sent.empty() && generic_queue.size() == 2);
|
||||
devices[0].credit = true;
|
||||
assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(!dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(generic_queue.empty() && generic_sent.size() == 2 && pcm.empty());
|
||||
}
|
||||
|
||||
void compatibility_queue_and_parser_timers() {
|
||||
reset();
|
||||
devices[0].credit = false;
|
||||
emit_generic(&devices[0], GenericKind::kLed);
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kError && snapshot().last_error == 6);
|
||||
assert(pcm.empty() && generic_queue.size() == 1 && !haptics_experiment_owns(&devices[0]));
|
||||
devices[0].credit = true;
|
||||
assert(!dispatch(&devices[0], devices[0].conn.control_cid));
|
||||
assert(generic_sent.size() == 1 && generic_sent[0].kind == GenericKind::kLed);
|
||||
start(); // Retry only after the unrelated report was delivered, not discarded.
|
||||
assert(pcm.size() == 1);
|
||||
devices[0].credit = false;
|
||||
emit_generic(&devices[0], GenericKind::kLed);
|
||||
devices[0].credit = true;
|
||||
const auto sent_count = pcm.size();
|
||||
assert(dispatch(&devices[0], devices[0].conn.control_cid));
|
||||
assert(dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(pcm.size() == sent_count && generic_queue.size() == 1);
|
||||
|
||||
reset();
|
||||
play_rumble(&devices[0], 3000, 100, 17, 23);
|
||||
const uint64_t started = start();
|
||||
run_until(started + 6148000);
|
||||
assert(snapshot().state == HapticsExperimentState::kCompleted);
|
||||
for (const auto& report : generic_sent) {
|
||||
assert(report.weak == 0 && report.strong == 0);
|
||||
assert(report.at_us <= started || report.at_us >= started + 6144000);
|
||||
}
|
||||
assert(!devices[0].parser_rumble_active && !devices[0].parser_rumble_delayed);
|
||||
|
||||
// Canceling an already running parser can itself queue a stop report.
|
||||
// Reject before the first native packet if that compatibility report blocks.
|
||||
reset();
|
||||
play_rumble(&devices[0], 0, 3000, 17, 23);
|
||||
devices[0].credit = false;
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().last_error == 6 && pcm.empty());
|
||||
assert(!devices[0].parser_rumble_active && timers.empty());
|
||||
}
|
||||
|
||||
void reconnect_and_pending_generation() {
|
||||
reset();
|
||||
delivery = Delivery::kDeferred;
|
||||
start();
|
||||
const uint16_t old_cid = devices[0].conn.interrupt_cid;
|
||||
haptics_experiment_detach(&devices[0]);
|
||||
const auto detached = snapshot();
|
||||
assert(detached.state == HapticsExperimentState::kDisconnected);
|
||||
assert(detached.last_error == 3 && timers.empty() && generic_sent.empty());
|
||||
assert(!dispatch(&devices[0], old_cid));
|
||||
assert(pcm.empty());
|
||||
devices[0].conn.interrupt_cid = 0x70;
|
||||
haptics_experiment_attach(0, 101, &devices[0]);
|
||||
start();
|
||||
assert(snapshot().connection_generation == 101);
|
||||
assert(dispatch(&devices[0], old_cid)); // Never permission for the new CID.
|
||||
assert(pcm.empty());
|
||||
assert(dispatch(&devices[0], devices[0].conn.interrupt_cid));
|
||||
assert(pcm.size() == 1);
|
||||
haptics_experiment_attach(0, 102, &devices[0]);
|
||||
assert(snapshot().state == HapticsExperimentState::kDisconnected && timers.empty());
|
||||
|
||||
reset();
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
const uint32_t requested_generation = snapshot().connection_generation;
|
||||
haptics_experiment_detach(&devices[0]);
|
||||
haptics_experiment_attach(0, requested_generation + 1, &devices[0]);
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kDisconnected);
|
||||
assert(snapshot().connection_generation == requested_generation && pcm.empty());
|
||||
// The rejected request did not reserve the slot permanently.
|
||||
start();
|
||||
assert(snapshot().connection_generation == requested_generation + 1);
|
||||
}
|
||||
|
||||
void support_and_transport_errors() {
|
||||
reset();
|
||||
assert(!haptics_experiment_request(2, 0));
|
||||
assert(!haptics_experiment_request(1, 4));
|
||||
devices[0].remote_mtu = 142;
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().state == HapticsExperimentState::kUnsupported);
|
||||
assert(snapshot().last_error == 2 && pcm.empty() && !haptics_experiment_owns(&devices[0]));
|
||||
devices[0].remote_mtu = 143;
|
||||
devices[0].connection_type = GAP_CONNECTION_LE;
|
||||
assert(haptics_experiment_request(1, 0));
|
||||
haptics_experiment_poll();
|
||||
assert(snapshot().last_error == 1 && pcm.empty());
|
||||
devices[0].connection_type = GAP_CONNECTION_ACL;
|
||||
devices[0].product_id = 0x0df2;
|
||||
start(); // Exact MTU boundary and DualSense Edge.
|
||||
assert(pcm.size() == 1);
|
||||
|
||||
reset();
|
||||
start(3);
|
||||
assert(pcm.size() == 1 && pcm.front().cid == devices[3].conn.interrupt_cid);
|
||||
assert(snapshot().connection_generation == 103);
|
||||
|
||||
reset();
|
||||
fail_requests = 1;
|
||||
start();
|
||||
assert(pcm.empty());
|
||||
run_until(now_us + 24000);
|
||||
assert(pcm.size() == 1 && snapshot().send_failures == 1);
|
||||
assert(snapshot().skipped_packets == 1);
|
||||
fail_sends = 1;
|
||||
run_until(now_us + 24000);
|
||||
assert(snapshot().send_failures == 2);
|
||||
run_until(static_cast<uint64_t>(snapshot().start_us) + 6148000);
|
||||
assert(snapshot().state == HapticsExperimentState::kError);
|
||||
assert(snapshot().last_error == 5);
|
||||
assert(snapshot().generated_packets == snapshot().sent_packets + 1);
|
||||
assert(max_request_depth == 1 && timers.empty());
|
||||
}
|
||||
|
||||
void timing_cost_reentrancy_and_wrap() {
|
||||
reset();
|
||||
send_cost_us = 500;
|
||||
request_cost_us = 200;
|
||||
reenter_send = true;
|
||||
start();
|
||||
const uint64_t started = snapshot().start_us;
|
||||
run_until(started + 6148000);
|
||||
const auto done = snapshot();
|
||||
assert(done.state == HapticsExperimentState::kCompleted);
|
||||
assert(done.sent_packets == 288 && send_calls == 288);
|
||||
assert(done.max_generate_us == 0); // Neither request nor send is generation.
|
||||
assert(done.max_request_wait_us == 200 && max_request_depth == 1);
|
||||
for (unsigned i = 0; i < pcm.size(); ++i) {
|
||||
assert(pcm[i].at_us >= due(started, i));
|
||||
assert(pcm[i].at_us - due(started, i) < 1200);
|
||||
}
|
||||
|
||||
reset((uint64_t{1} << 32) - 1000123);
|
||||
const uint64_t wrap_start = start();
|
||||
run_until(wrap_start + 6148000);
|
||||
const auto wrapped = snapshot();
|
||||
assert(wrapped.state == HapticsExperimentState::kCompleted);
|
||||
assert(wrapped.start_us == static_cast<uint32_t>(wrap_start));
|
||||
assert(wrapped.first_tone_due_us == static_cast<uint32_t>(wrap_start + 1024000));
|
||||
assert(wrapped.first_tone_sent_us == static_cast<uint32_t>(pcm[48].at_us));
|
||||
assert(wrapped.last_sent_us == static_cast<uint32_t>(pcm.back().at_us));
|
||||
assert(wrapped.elapsed_us >= 6147000 && wrapped.elapsed_us < 6148000);
|
||||
assert(wrapped.max_send_gap_us <= 22000 && wrapped.sent_packets == 288);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Transport attribution has its own native fixture; this fixture isolates PCM
|
||||
// scheduling and packet content from the optional measurement backend.
|
||||
void haptics_transport_probe_prepare() {}
|
||||
void haptics_transport_probe_begin(uint32_t, uint32_t, uint16_t) {}
|
||||
void haptics_transport_probe_end() {}
|
||||
void haptics_transport_probe_timer(uint32_t) {}
|
||||
void haptics_transport_probe_permission(uint32_t) {}
|
||||
void haptics_transport_probe_send(uint32_t, uint32_t, bool) {}
|
||||
|
||||
uint64_t time_us_64() {
|
||||
return now_us;
|
||||
}
|
||||
|
||||
void btstack_run_loop_set_timer_handler(
|
||||
btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)) {
|
||||
no_lock();
|
||||
timer->process = handler;
|
||||
}
|
||||
|
||||
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms) {
|
||||
no_lock();
|
||||
// Exactly pico_btstack/btstack_run_loop_async_context.c, including +1.
|
||||
timer->timeout_us = (now_us / 1000 + timeout_ms + 1) * 1000;
|
||||
}
|
||||
|
||||
void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
|
||||
no_lock();
|
||||
assert(timer->process != nullptr);
|
||||
assert(std::find(timers.begin(), timers.end(), timer) == timers.end());
|
||||
timers.push_back(timer);
|
||||
}
|
||||
|
||||
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) {
|
||||
no_lock();
|
||||
const auto it = std::find(timers.begin(), timers.end(), timer);
|
||||
if (it == timers.end()) {
|
||||
return 0;
|
||||
}
|
||||
timers.erase(it);
|
||||
return 1;
|
||||
}
|
||||
|
||||
gap_connection_type_t gap_get_connection_type(uint16_t handle) {
|
||||
no_lock();
|
||||
return handle < devices.size() ? devices[handle].connection_type
|
||||
: GAP_CONNECTION_INVALID;
|
||||
}
|
||||
|
||||
uint16_t l2cap_get_remote_mtu_for_local_cid(uint16_t cid) {
|
||||
no_lock();
|
||||
return device_for_cid(cid)->remote_mtu;
|
||||
}
|
||||
|
||||
uint8_t uni_circular_buffer_is_empty(const uni_circular_buffer_t* buffer) {
|
||||
return buffer->queued == 0;
|
||||
}
|
||||
|
||||
uint8_t l2cap_request_can_send_now_event(uint16_t cid) {
|
||||
no_lock();
|
||||
++request_calls;
|
||||
if (fail_requests != 0) {
|
||||
--fail_requests;
|
||||
return 0x44;
|
||||
}
|
||||
auto* device = device_for_cid(cid);
|
||||
assert(!device->notification_pending);
|
||||
device->notification_pending = true;
|
||||
++request_depth;
|
||||
max_request_depth = std::max(max_request_depth, request_depth);
|
||||
now_us += request_cost_us;
|
||||
if (delivery == Delivery::kImmediate && device->credit) {
|
||||
dispatch(device, cid); // Real BTstack can call here, BEFORE return.
|
||||
}
|
||||
--request_depth;
|
||||
return ERROR_CODE_SUCCESS;
|
||||
}
|
||||
|
||||
uint8_t l2cap_send(uint16_t cid, const uint8_t* data, uint16_t size) {
|
||||
no_lock();
|
||||
auto* device = device_for_cid(cid);
|
||||
assert(permission == device && device->credit);
|
||||
assert(size == 143);
|
||||
++send_calls;
|
||||
const uint64_t submitted_us = now_us;
|
||||
now_us += send_cost_us;
|
||||
if (reenter_send) {
|
||||
assert(haptics_experiment_on_can_send_now(device, cid));
|
||||
const auto concurrent_snapshot = snapshot();
|
||||
assert(concurrent_snapshot.state == HapticsExperimentState::kPending ||
|
||||
concurrent_snapshot.state == HapticsExperimentState::kRunning);
|
||||
}
|
||||
if (fail_sends != 0) {
|
||||
--fail_sends;
|
||||
return 0x55;
|
||||
}
|
||||
Pcm packet{submitted_us, cid, {}};
|
||||
std::copy(data, data + size, packet.bytes.begin());
|
||||
pcm.push_back(packet);
|
||||
return ERROR_CODE_SUCCESS;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
haptics_experiment_prepare();
|
||||
assert(snapshot().state == HapticsExperimentState::kIdle);
|
||||
nominal_run(argv[1]);
|
||||
stalled_deadlines();
|
||||
deferred_and_missing_callbacks();
|
||||
stop_preemption_and_restore();
|
||||
compatibility_queue_and_parser_timers();
|
||||
reconnect_and_pending_generation();
|
||||
support_and_transport_errors();
|
||||
timing_cost_reentrancy_and_wrap();
|
||||
std::cout << "haptics experiment behavioral regressions passed\n";
|
||||
}
|
||||
21
tests/haptics_transport_probe_native_stubs/btstack.h
Normal file
21
tests/haptics_transport_probe_native_stubs/btstack.h
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
constexpr uint8_t HCI_EVENT_PACKET = 4;
|
||||
constexpr uint8_t HCI_EVENT_NUMBER_OF_COMPLETED_PACKETS = 0x13;
|
||||
using hci_con_handle_t = uint16_t;
|
||||
struct hci_connection_t {
|
||||
uint8_t num_packets_sent = 0;
|
||||
};
|
||||
struct btstack_packet_callback_registration_t {
|
||||
void* item = nullptr;
|
||||
void (*callback)(uint8_t, uint16_t, uint8_t*, uint16_t) = nullptr;
|
||||
};
|
||||
extern "C" {
|
||||
void btstack_run_loop_base_poll_data_sources();
|
||||
void btstack_run_loop_poll_data_sources_from_irq();
|
||||
void hci_add_event_handler(btstack_packet_callback_registration_t* registration);
|
||||
hci_connection_t* hci_connection_for_handle(hci_con_handle_t handle);
|
||||
int hci_number_free_acl_slots_for_handle(hci_con_handle_t handle);
|
||||
}
|
||||
13
tests/haptics_transport_probe_native_stubs/cyw43.h
Normal file
13
tests/haptics_transport_probe_native_stubs/cyw43.h
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
int cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length);
|
||||
int cyw43_bluetooth_hci_read(uint8_t* buffer, uint32_t capacity, uint32_t* length);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
#pragma once
|
||||
|
||||
#include <cassert>
|
||||
|
||||
struct critical_section_t {
|
||||
bool initialized = false;
|
||||
};
|
||||
inline unsigned native_probe_lock_depth = 0;
|
||||
inline void critical_section_init(critical_section_t* section) {
|
||||
assert(!section->initialized);
|
||||
section->initialized = true;
|
||||
}
|
||||
inline void critical_section_enter_blocking(critical_section_t* section) {
|
||||
assert(section->initialized && native_probe_lock_depth == 0);
|
||||
++native_probe_lock_depth;
|
||||
}
|
||||
inline void critical_section_exit(critical_section_t*) {
|
||||
assert(native_probe_lock_depth == 1);
|
||||
--native_probe_lock_depth;
|
||||
}
|
||||
4
tests/haptics_transport_probe_native_stubs/pico/stdlib.h
Normal file
4
tests/haptics_transport_probe_native_stubs/pico/stdlib.h
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
uint64_t time_us_64();
|
||||
19
tests/haptics_transport_probe_native_stubs/sdk.cpp
Normal file
19
tests/haptics_transport_probe_native_stubs/sdk.cpp
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#include <btstack.h>
|
||||
#include <cyw43.h>
|
||||
|
||||
extern int (*native_write)(uint8_t*, size_t);
|
||||
extern int (*native_read)(uint8_t*, uint32_t, uint32_t*);
|
||||
extern void (*native_poll)();
|
||||
|
||||
// Keep definitions separate from test callers, just like the real SDK. Tests
|
||||
// exercise GNU ld --wrap, not direct calls to the __wrap_ implementation.
|
||||
extern "C" int cyw43_bluetooth_hci_write(uint8_t* buffer, size_t length) {
|
||||
return native_write(buffer, length);
|
||||
}
|
||||
extern "C" int cyw43_bluetooth_hci_read(uint8_t* buffer, uint32_t capacity,
|
||||
uint32_t* length) {
|
||||
return native_read(buffer, capacity, length);
|
||||
}
|
||||
extern "C" void btstack_run_loop_base_poll_data_sources() {
|
||||
native_poll();
|
||||
}
|
||||
470
tests/haptics_transport_probe_test.cpp
Normal file
470
tests/haptics_transport_probe_test.cpp
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
#include "input/haptics_transport_probe.h"
|
||||
|
||||
#include <btstack.h>
|
||||
#include <cyw43.h>
|
||||
#include <pico/critical_section.h>
|
||||
|
||||
#include <cassert>
|
||||
#include <climits>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint16_t kHandle = 0x123;
|
||||
uint64_t clock_us = 1000;
|
||||
hci_connection_t connection{2};
|
||||
bool connected = true;
|
||||
int free_slots = 7;
|
||||
unsigned registrations = 0;
|
||||
void (*event_handler)(uint8_t, uint16_t, uint8_t*, uint16_t) = nullptr;
|
||||
unsigned writes = 0, reads = 0, polls = 0;
|
||||
uint32_t write_delay = 0, read_delay = 0, poll_delay = 0;
|
||||
int write_result = 0, read_result = 0;
|
||||
uint32_t read_length = 8;
|
||||
uint8_t* last_buffer = nullptr;
|
||||
size_t last_write_length = 0;
|
||||
uint32_t last_capacity = 0;
|
||||
uint32_t* last_length = nullptr;
|
||||
void (*write_action)() = nullptr;
|
||||
void (*read_action)() = nullptr;
|
||||
void (*poll_action)() = nullptr;
|
||||
uint8_t buffer[16]{};
|
||||
bool poll_requested = false;
|
||||
unsigned queued_input = 0;
|
||||
unsigned serviced_input = 0;
|
||||
|
||||
HapticsTransportProbe snapshot() {
|
||||
HapticsTransportProbe result;
|
||||
haptics_transport_probe_snapshot(&result);
|
||||
return result;
|
||||
}
|
||||
|
||||
void unchanged(const HapticsTransportProbe& expected) {
|
||||
const auto actual = snapshot();
|
||||
static_assert(sizeof(actual) == 128);
|
||||
assert(std::memcmp(&actual, &expected, sizeof(actual)) == 0);
|
||||
}
|
||||
|
||||
void run_action(void (*&action)()) {
|
||||
const auto callback = action;
|
||||
action = nullptr;
|
||||
if (callback != nullptr) callback();
|
||||
}
|
||||
|
||||
int fake_write(uint8_t* data, size_t length) {
|
||||
assert(native_probe_lock_depth == 0);
|
||||
(void)snapshot(); // Real calls may synchronously reenter snapshot readers.
|
||||
++writes;
|
||||
last_buffer = data;
|
||||
last_write_length = length;
|
||||
clock_us += write_delay;
|
||||
run_action(write_action);
|
||||
if (data != nullptr && length != 0) data[0] = 0xa5;
|
||||
return write_result;
|
||||
}
|
||||
|
||||
int fake_read(uint8_t* data, uint32_t capacity, uint32_t* length) {
|
||||
assert(native_probe_lock_depth == 0);
|
||||
(void)snapshot();
|
||||
++reads;
|
||||
last_buffer = data;
|
||||
last_capacity = capacity;
|
||||
last_length = length;
|
||||
clock_us += read_delay;
|
||||
run_action(read_action);
|
||||
if (data != nullptr && capacity != 0) data[0] = 0x5a;
|
||||
if (read_result == 0 && length != nullptr) *length = read_length;
|
||||
return read_result;
|
||||
}
|
||||
|
||||
void fake_poll() {
|
||||
assert(native_probe_lock_depth == 0);
|
||||
(void)snapshot();
|
||||
++polls;
|
||||
clock_us += poll_delay;
|
||||
run_action(poll_action);
|
||||
}
|
||||
|
||||
void begin(uint32_t run = 1, uint16_t handle = kHandle) {
|
||||
haptics_transport_probe_end();
|
||||
clock_us = 1000;
|
||||
connected = true;
|
||||
connection.num_packets_sent = 2;
|
||||
free_slots = 7;
|
||||
writes = reads = polls = 0;
|
||||
write_delay = read_delay = poll_delay = 0;
|
||||
write_result = read_result = 0;
|
||||
read_length = 8;
|
||||
write_action = read_action = poll_action = nullptr;
|
||||
poll_requested = false;
|
||||
haptics_transport_probe_begin(run, 9, handle);
|
||||
assert(registrations == 1);
|
||||
}
|
||||
|
||||
void complete(uint16_t count) {
|
||||
uint8_t event[] = {0x13, 5, 1, 0x23, 0x01,
|
||||
static_cast<uint8_t>(count), static_cast<uint8_t>(count >> 8)};
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event));
|
||||
}
|
||||
|
||||
void test_inactive() {
|
||||
haptics_transport_probe_prepare();
|
||||
haptics_transport_probe_prepare();
|
||||
const auto before = snapshot();
|
||||
write_result = -7;
|
||||
read_result = 17;
|
||||
uint32_t length = 99;
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -7);
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 17);
|
||||
assert(length == 99);
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
haptics_transport_probe_timer(123);
|
||||
haptics_transport_probe_permission(456);
|
||||
haptics_transport_probe_send(789, 1000, true);
|
||||
unchanged(before);
|
||||
assert(writes == 1 && reads == 1 && polls == 1 && registrations == 0);
|
||||
}
|
||||
|
||||
void test_delay_attribution() {
|
||||
begin();
|
||||
poll_action = [] {
|
||||
clock_us += 50; // Generation is outside the caller-supplied send time.
|
||||
const uint32_t send_start = static_cast<uint32_t>(clock_us);
|
||||
clock_us += 7;
|
||||
write_delay = 3000;
|
||||
write_action = [] { connection.num_packets_sent = 5; free_slots = 1; };
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0);
|
||||
clock_us += 11;
|
||||
haptics_transport_probe_send(static_cast<uint32_t>(clock_us) - send_start,
|
||||
static_cast<uint32_t>(clock_us), true);
|
||||
clock_us += 9;
|
||||
uint32_t length = 0;
|
||||
read_delay = 80;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
read_delay = 90;
|
||||
read_length = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
read_delay = 70;
|
||||
read_result = -9;
|
||||
length = 99; // Stale output length on failure is not a packet.
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == -9);
|
||||
clock_us += 30;
|
||||
};
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
auto result = snapshot();
|
||||
assert(result.send_calls == 1 && result.total_send_us == 3018);
|
||||
assert(result.max_send_us == 3018 && result.first_tone_send_return_us == 4068);
|
||||
assert(result.write_calls == 1 && result.total_write_us == 3000);
|
||||
assert(result.max_write_us == 3000);
|
||||
assert(result.read_calls == 3 && result.read_packets == 1);
|
||||
assert(result.total_read_us == 240 && result.max_read_us == 90);
|
||||
assert(result.poll_calls == 1 && result.total_poll_us == 3347);
|
||||
assert(result.max_poll_us == 3347 && result.max_poll_gap_us == 0);
|
||||
assert(result.max_outstanding_acl == 5 && result.min_free_acl == 1);
|
||||
clock_us += 600;
|
||||
poll_delay = 100;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
result = snapshot();
|
||||
assert(result.poll_calls == 2 && result.total_poll_us == 3447);
|
||||
assert(result.max_poll_gap_us == 3947);
|
||||
}
|
||||
|
||||
void test_nested_boundaries() {
|
||||
begin();
|
||||
write_result = -23;
|
||||
write_delay = 10;
|
||||
write_action = [] {
|
||||
write_delay = 30;
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23);
|
||||
clock_us += 7;
|
||||
};
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23);
|
||||
assert(writes == 2 && snapshot().write_calls == 1);
|
||||
assert(snapshot().total_write_us == 47);
|
||||
read_delay = 11;
|
||||
read_action = [] {
|
||||
read_delay = 19;
|
||||
uint32_t length = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
clock_us += 3;
|
||||
};
|
||||
uint32_t length = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
assert(reads == 2 && snapshot().read_calls == 1);
|
||||
assert(snapshot().read_packets == 1 && snapshot().total_read_us == 33);
|
||||
poll_delay = 5;
|
||||
poll_action = [] {
|
||||
poll_delay = 13;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
write_delay = 10;
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == -23);
|
||||
read_delay = 7;
|
||||
read_length = 0;
|
||||
uint32_t count = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &count) == 0);
|
||||
clock_us += 2;
|
||||
};
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
const auto result = snapshot();
|
||||
assert(polls == 2 && result.poll_calls == 1 && result.total_poll_us == 37);
|
||||
assert(result.write_calls == 2 && result.total_write_us == 57);
|
||||
assert(result.read_calls == 2 && result.total_read_us == 40 && result.read_packets == 1);
|
||||
}
|
||||
|
||||
void test_returns_and_outputs() {
|
||||
begin();
|
||||
write_delay = 2;
|
||||
read_delay = 3;
|
||||
const int results[] = {0, -5, 17, INT_MIN, INT_MAX};
|
||||
for (int status : results) {
|
||||
write_result = read_result = status;
|
||||
buffer[0] = 0;
|
||||
assert(cyw43_bluetooth_hci_write(buffer, SIZE_MAX) == status);
|
||||
assert(last_buffer == buffer && last_write_length == SIZE_MAX && buffer[0] == 0xa5);
|
||||
uint32_t length = 0xfeed;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, UINT32_MAX, &length) == status);
|
||||
assert(last_buffer == buffer && last_capacity == UINT32_MAX && last_length == &length);
|
||||
assert(buffer[0] == 0x5a && length == (status == 0 ? read_length : 0xfeed));
|
||||
}
|
||||
auto result = snapshot();
|
||||
assert(writes == 5 && reads == 5 && result.write_calls == 5 && result.read_calls == 5);
|
||||
assert(result.total_write_us == 10 && result.total_read_us == 15 && result.read_packets == 1);
|
||||
haptics_transport_probe_end();
|
||||
result = snapshot();
|
||||
assert(cyw43_bluetooth_hci_write(nullptr, 0) == INT_MAX);
|
||||
assert(cyw43_bluetooth_hci_read(nullptr, 0, nullptr) == INT_MAX);
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
complete(8);
|
||||
unchanged(result);
|
||||
}
|
||||
|
||||
void test_selected_completions() {
|
||||
begin();
|
||||
uint8_t event[] = {0x13, 9, 2, 0x22, 0, 4, 0, 0x23, 0x01, 3, 0};
|
||||
clock_us = 6000;
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event));
|
||||
assert(snapshot().completion_events == 1 && snapshot().completed_packets == 3);
|
||||
assert(snapshot().max_completion_gap_us == 0);
|
||||
clock_us = 16000;
|
||||
uint8_t other[] = {0x13, 5, 1, 0x22, 0, 7, 0};
|
||||
event_handler(HCI_EVENT_PACKET, 0, other, sizeof(other));
|
||||
clock_us = 30000;
|
||||
complete(2);
|
||||
const auto before = snapshot();
|
||||
assert(before.completion_events == 2 && before.completed_packets == 5);
|
||||
assert(before.max_completion_gap_us == 24000);
|
||||
event_handler(HCI_EVENT_PACKET, 0, nullptr, 7);
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, 2);
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event) - 1);
|
||||
event_handler(2, 0, event, sizeof(event));
|
||||
event[1] = 8; // Inconsistent event parameter length.
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event));
|
||||
event[1] = 9;
|
||||
event[2] = 255; // Handle count exceeds bounded packet storage.
|
||||
event_handler(HCI_EVENT_PACKET, 0, event, sizeof(event));
|
||||
unchanged(before);
|
||||
|
||||
begin(2, 0xffff);
|
||||
complete(3);
|
||||
assert(snapshot().completion_events == 0);
|
||||
assert(snapshot().max_outstanding_acl == 0 && snapshot().min_free_acl == 0);
|
||||
}
|
||||
|
||||
void test_delayed_completion_and_disconnect() {
|
||||
begin();
|
||||
connection.num_packets_sent = 5;
|
||||
free_slots = 1;
|
||||
write_delay = 7;
|
||||
write_action = [] {
|
||||
clock_us += 80000;
|
||||
connection.num_packets_sent = 0;
|
||||
free_slots = 6;
|
||||
complete(5); // Completion delivery reenters an in-flight write.
|
||||
connected = false;
|
||||
connection.num_packets_sent = 255; // The former object is now invalid.
|
||||
};
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0);
|
||||
haptics_transport_probe_end();
|
||||
const auto result = snapshot();
|
||||
assert(result.write_calls == 1 && result.total_write_us == 80007);
|
||||
assert(result.completion_events == 1 && result.completed_packets == 5);
|
||||
assert(result.max_outstanding_acl == 5 && result.min_free_acl == 1);
|
||||
complete(7);
|
||||
unchanged(result);
|
||||
}
|
||||
|
||||
void test_run_changes_during_calls() {
|
||||
begin(7);
|
||||
write_delay = 100;
|
||||
write_action = [] { haptics_transport_probe_end(); };
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0);
|
||||
assert(snapshot().active == 0 && snapshot().write_calls == 0);
|
||||
|
||||
begin(7);
|
||||
read_delay = 100;
|
||||
read_action = [] {
|
||||
haptics_transport_probe_end();
|
||||
haptics_transport_probe_begin(7, 9, kHandle); // Even identical public IDs.
|
||||
haptics_transport_probe_permission(23);
|
||||
};
|
||||
uint32_t length = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
auto result = snapshot();
|
||||
assert(result.active == 1 && result.run_id == 7 && result.connection_generation == 9);
|
||||
assert(result.connection_handle == kHandle && result.read_calls == 0 && result.read_packets == 0);
|
||||
assert(result.permission_callbacks == 1 && result.total_permission_wait_us == 23);
|
||||
|
||||
poll_delay = 100;
|
||||
poll_action = [] {
|
||||
haptics_transport_probe_begin(8, 10, kHandle);
|
||||
haptics_transport_probe_timer(17);
|
||||
};
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
result = snapshot();
|
||||
assert(result.run_id == 8 && result.connection_generation == 10 && result.poll_calls == 0);
|
||||
assert(result.timer_wakes == 1 && result.max_timer_lateness_us == 17);
|
||||
assert(result.permission_callbacks == 0 && result.total_permission_wait_us == 0);
|
||||
clock_us += 500;
|
||||
poll_delay = 20;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
assert(snapshot().poll_calls == 1 && snapshot().total_poll_us == 20);
|
||||
assert(snapshot().max_poll_gap_us == 0);
|
||||
|
||||
haptics_transport_probe_end();
|
||||
poll_action = [] { haptics_transport_probe_begin(9, 11, kHandle); };
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
assert(snapshot().run_id == 9 && snapshot().poll_calls == 0);
|
||||
assert(registrations == 1);
|
||||
}
|
||||
|
||||
void test_saturation_and_clock_wrap() {
|
||||
begin();
|
||||
clock_us = uint64_t{UINT32_MAX} - 20;
|
||||
write_delay = 40;
|
||||
assert(cyw43_bluetooth_hci_write(buffer, sizeof(buffer)) == 0);
|
||||
assert(snapshot().total_write_us == 40);
|
||||
haptics_transport_probe_timer(UINT32_MAX - 2);
|
||||
haptics_transport_probe_timer(10);
|
||||
haptics_transport_probe_permission(UINT32_MAX - 3);
|
||||
haptics_transport_probe_permission(10);
|
||||
haptics_transport_probe_send(UINT32_MAX - 5, 0, true);
|
||||
haptics_transport_probe_send(10, 123, true);
|
||||
const auto result = snapshot();
|
||||
assert(result.timer_wakes == 2 && result.total_timer_lateness_us == UINT32_MAX);
|
||||
assert(result.max_timer_lateness_us == UINT32_MAX - 2);
|
||||
assert(result.permission_callbacks == 2 && result.total_permission_wait_us == UINT32_MAX);
|
||||
assert(result.max_permission_wait_us == UINT32_MAX - 3);
|
||||
assert(result.send_calls == 2 && result.total_send_us == UINT32_MAX);
|
||||
assert(result.max_send_us == UINT32_MAX - 5 && result.first_tone_send_return_us == 0);
|
||||
clock_us = uint64_t{UINT32_MAX} - 5;
|
||||
complete(1);
|
||||
clock_us += 20;
|
||||
complete(1);
|
||||
assert(snapshot().max_completion_gap_us == 20);
|
||||
clock_us = uint64_t{UINT32_MAX} - 5;
|
||||
poll_delay = 5;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
clock_us += 40;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
assert(snapshot().max_poll_gap_us == 45);
|
||||
}
|
||||
|
||||
void receive_one() {
|
||||
read_length = queued_input != 0 ? 8 : 0;
|
||||
uint32_t length = 0;
|
||||
assert(cyw43_bluetooth_hci_read(buffer, sizeof(buffer), &length) == 0);
|
||||
if (length != 0) {
|
||||
--queued_input;
|
||||
++serviced_input;
|
||||
}
|
||||
poll_action = receive_one;
|
||||
}
|
||||
|
||||
void test_bounded_receive_progress() {
|
||||
begin();
|
||||
haptics_transport_probe_end(); // Input must progress outside an active run.
|
||||
const auto before = snapshot();
|
||||
queued_input = 3;
|
||||
serviced_input = 0;
|
||||
poll_action = receive_one;
|
||||
poll_requested = true;
|
||||
unsigned timer_opportunities = 0;
|
||||
while (poll_requested && timer_opportunities < 10) {
|
||||
poll_requested = false;
|
||||
const unsigned handled_before = serviced_input;
|
||||
btstack_run_loop_base_poll_data_sources();
|
||||
assert(serviced_input - handled_before <= 1);
|
||||
++timer_opportunities; // SDK services timers before the next poll.
|
||||
}
|
||||
assert(queued_input == 0 && serviced_input == 3);
|
||||
assert(timer_opportunities == 4 && !poll_requested);
|
||||
unchanged(before);
|
||||
}
|
||||
|
||||
void test_advertised_capacity_capture() {
|
||||
begin();
|
||||
haptics_transport_probe_end();
|
||||
uint8_t response[] = {
|
||||
0, 0, 0, HCI_EVENT_PACKET, 0x0e, 11, 1, 0x05, 0x10,
|
||||
0, 0xfd, 3, 0, 10, 0, 0, 0,
|
||||
};
|
||||
const auto before = snapshot();
|
||||
uint32_t length = 0;
|
||||
read_length = sizeof(response) - 1;
|
||||
assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0);
|
||||
unchanged(before);
|
||||
read_length = sizeof(response);
|
||||
response[9] = 1; // Failed command must not replace advertised capacity.
|
||||
assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0);
|
||||
unchanged(before);
|
||||
response[9] = 0;
|
||||
assert(cyw43_bluetooth_hci_read(response, sizeof(response), &length) == 0);
|
||||
assert(snapshot().controller_acl_packet_bytes == 1021);
|
||||
assert(snapshot().controller_acl_packet_count == 10);
|
||||
begin(45);
|
||||
assert(snapshot().controller_acl_packet_count == 10);
|
||||
assert(snapshot().controller_acl_packet_bytes == 1021);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int (*native_write)(uint8_t*, size_t) = fake_write;
|
||||
int (*native_read)(uint8_t*, uint32_t, uint32_t*) = fake_read;
|
||||
void (*native_poll)() = fake_poll;
|
||||
|
||||
uint64_t time_us_64() { return clock_us; }
|
||||
|
||||
extern "C" void btstack_run_loop_poll_data_sources_from_irq() {
|
||||
assert(native_probe_lock_depth == 0);
|
||||
poll_requested = true;
|
||||
}
|
||||
|
||||
extern "C" void hci_add_event_handler(btstack_packet_callback_registration_t* registration) {
|
||||
assert(native_probe_lock_depth == 0 && ++registrations == 1);
|
||||
event_handler = registration->callback;
|
||||
}
|
||||
|
||||
extern "C" hci_connection_t* hci_connection_for_handle(hci_con_handle_t handle) {
|
||||
assert(native_probe_lock_depth == 0);
|
||||
return connected && handle == kHandle ? &connection : nullptr;
|
||||
}
|
||||
|
||||
extern "C" int hci_number_free_acl_slots_for_handle(hci_con_handle_t handle) {
|
||||
assert(native_probe_lock_depth == 0 && connected && handle == kHandle);
|
||||
return free_slots;
|
||||
}
|
||||
|
||||
int main() {
|
||||
test_inactive();
|
||||
test_delay_attribution();
|
||||
test_nested_boundaries();
|
||||
test_returns_and_outputs();
|
||||
test_selected_completions();
|
||||
test_delayed_completion_and_disconnect();
|
||||
test_run_changes_during_calls();
|
||||
test_saturation_and_clock_wrap();
|
||||
test_bounded_receive_progress();
|
||||
test_advertised_capacity_capture();
|
||||
std::puts("haptics transport probe tests passed");
|
||||
}
|
||||
|
|
@ -2115,3 +2115,604 @@ def test_find_requires_selector_for_multiple_picos(
|
|||
):
|
||||
config_manager.find_pico(None, None)
|
||||
assert config_manager.find_pico(1, 8) is second
|
||||
|
||||
|
||||
def haptics_response(
|
||||
state: int = 0, *, run_id: int = 0, slot: int = 0xFF,
|
||||
last_error: int = 0, sent_packets: int = 0,
|
||||
first_tone_due_us: int = 0, first_tone_sent_us: int = 0,
|
||||
elapsed_us: int = 0, connection_generation: int = 9,
|
||||
) -> bytes:
|
||||
return make_response(
|
||||
config_manager.OP_HAPTICS_EXPERIMENT,
|
||||
struct.pack(
|
||||
"<17I4B", run_id, connection_generation, 100, 105,
|
||||
sent_packets, 2, 3, 109, 4,
|
||||
123, 22000, 11001, 9876, first_tone_due_us, first_tone_sent_us,
|
||||
0x76543210, elapsed_us, state, slot, last_error, 0,
|
||||
),
|
||||
schema=2, generation=run_id,
|
||||
)
|
||||
|
||||
|
||||
class HapticsDevice(FakeDevice):
|
||||
def __init__(
|
||||
self, responses: list[bytes | Exception], *,
|
||||
transport_response: bytes | Exception | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.haptics_responses = responses
|
||||
self.haptics_reads = 0
|
||||
self.haptics_reads_at_out: list[int] = []
|
||||
self.transport_response = transport_response
|
||||
|
||||
def ctrl_transfer(
|
||||
self, bm_request_type: int, request: int, value: int, index: int,
|
||||
data_or_w_length: object, timeout: int,
|
||||
) -> bytes | int:
|
||||
if request == config_manager.OP_HAPTICS_TRANSPORT_PROBE:
|
||||
assert bm_request_type == 0xC0
|
||||
assert value == config_manager.REQUEST_VALUE
|
||||
assert index == config_manager.REQUEST_INDEX
|
||||
self.requests.append(request)
|
||||
assert self.transport_response is not None
|
||||
if isinstance(self.transport_response, Exception):
|
||||
raise self.transport_response
|
||||
return self.transport_response
|
||||
if request != config_manager.OP_HAPTICS_EXPERIMENT:
|
||||
return super().ctrl_transfer(
|
||||
bm_request_type, request, value, index, data_or_w_length, timeout
|
||||
)
|
||||
assert value == config_manager.REQUEST_VALUE
|
||||
assert index == config_manager.REQUEST_INDEX
|
||||
self.requests.append(request)
|
||||
if bm_request_type == 0xC0:
|
||||
self.haptics_reads += 1
|
||||
response = self.haptics_responses[0]
|
||||
if len(self.haptics_responses) > 1:
|
||||
self.haptics_responses.pop(0)
|
||||
if isinstance(response, Exception):
|
||||
raise response
|
||||
return response
|
||||
assert bm_request_type == 0x40
|
||||
encoded = bytes(data_or_w_length)
|
||||
magic, version, operation, flags, reserved, size, schema, crc = (
|
||||
struct.unpack_from("<4sBBBBHHI", encoded)
|
||||
)
|
||||
payload = encoded[16:]
|
||||
assert (magic, version, operation, flags, reserved, size, schema) == (
|
||||
b"SPMG", 1, 0x40, 0, 0, 2, 0,
|
||||
)
|
||||
assert crc == zlib.crc32(payload) & 0xFFFFFFFF
|
||||
self.out_requests.append((request, payload, encoded))
|
||||
self.haptics_reads_at_out.append(self.haptics_reads)
|
||||
return len(encoded)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def haptics_clock(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
||||
clock = [0.0]
|
||||
|
||||
def sleep(seconds: float) -> None:
|
||||
clock[0] += seconds
|
||||
|
||||
monkeypatch.setattr(config_manager.time, "monotonic", lambda: clock[0])
|
||||
monkeypatch.setattr(config_manager.time, "sleep", sleep)
|
||||
return clock
|
||||
|
||||
|
||||
def test_haptics_schema_timing_and_wraparound() -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(
|
||||
2, run_id=17, slot=2, sent_packets=101,
|
||||
first_tone_due_us=0xFFFFFFF0, first_tone_sent_us=0x30,
|
||||
elapsed_us=1100000,
|
||||
),
|
||||
])
|
||||
snapshot = config_manager.read_haptics_experiment(device)
|
||||
assert snapshot.state_name == "running"
|
||||
assert snapshot.run_id == 17 and snapshot.slot == 2
|
||||
assert snapshot.sent_packets == 101
|
||||
assert snapshot.generated_packets == 105 and snapshot.skipped_packets == 2
|
||||
assert snapshot.send_failures == 3 and snapshot.can_send_requests == 109
|
||||
assert snapshot.synchronous_callbacks == 4
|
||||
assert snapshot.max_generate_us == 123
|
||||
assert snapshot.max_send_gap_us == 22000
|
||||
assert snapshot.max_lateness_us == 11001
|
||||
assert snapshot.max_request_wait_us == 9876
|
||||
assert snapshot.first_tone_submission_delay_us == 64
|
||||
assert snapshot.last_sent_us == 0x76543210
|
||||
assert snapshot.elapsed_us == 1100000
|
||||
assert snapshot.to_json_object()["first_tone_submission_delay_us"] == 64
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "message"),
|
||||
[
|
||||
("schema", "unsupported haptics experiment schema"),
|
||||
("size", "payload size"),
|
||||
("state", "state"),
|
||||
("slot", "slot"),
|
||||
("active_without_slot", "slot"),
|
||||
("reserved", "reserved"),
|
||||
("flags", "reserved"),
|
||||
],
|
||||
)
|
||||
def test_haptics_rejects_malformed_diagnostics(mutation: str, message: str) -> None:
|
||||
payload = bytearray(haptics_response(2, slot=0)[20:])
|
||||
schema, flags = 2, 0
|
||||
if mutation == "schema":
|
||||
schema = 1
|
||||
elif mutation == "size":
|
||||
payload.pop()
|
||||
elif mutation == "state":
|
||||
payload[68] = 8
|
||||
elif mutation == "slot":
|
||||
payload[69] = 4
|
||||
elif mutation == "active_without_slot":
|
||||
payload[69] = 0xFF
|
||||
elif mutation == "reserved":
|
||||
payload[71] = 1
|
||||
else:
|
||||
flags = 1
|
||||
response = make_response(
|
||||
0x40, bytes(payload), schema=schema, flags=flags,
|
||||
)
|
||||
with pytest.raises(config_manager.ConfigManagerError, match=message):
|
||||
config_manager.read_haptics_experiment(HapticsDevice([response]))
|
||||
|
||||
|
||||
def test_haptics_disabled_firmware_is_readable_but_cannot_start(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = HapticsDevice([haptics_response(6)])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "status", "--json"]) == 0
|
||||
captured = capsys.readouterr()
|
||||
status = json.loads(captured.out)
|
||||
assert status["state_name"] == "unsupported"
|
||||
assert status["firmware_supported"] is False
|
||||
assert "SWITCH_PICO_HAPTICS_EXPERIMENT=ON" in captured.err
|
||||
assert config_manager.main(["haptics-experiment", "start"]) == 1
|
||||
assert "unsupported" in capsys.readouterr().err
|
||||
assert device.out_requests == []
|
||||
|
||||
|
||||
def test_haptics_old_firmware_stall_is_actionable_without_hiding_disconnect(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
config_manager.usb.core.USBError("Pipe error", error_code=-9, errno=32),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "start"]) == 1
|
||||
assert "SWITCH_PICO_HAPTICS_EXPERIMENT=ON" in capsys.readouterr().err
|
||||
assert device.out_requests == []
|
||||
disconnected = config_manager.usb.core.USBError(
|
||||
"No such device", error_code=-4, errno=19,
|
||||
)
|
||||
with pytest.raises(config_manager.usb.core.USBError) as raised:
|
||||
config_manager.read_haptics_experiment(HapticsDevice([disconnected]))
|
||||
assert raised.value is disconnected
|
||||
|
||||
|
||||
def test_haptics_start_waits_for_firmware_not_usb_ack(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float],
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(3, run_id=40, slot=0),
|
||||
haptics_response(1, run_id=41, slot=0),
|
||||
haptics_response(2, run_id=41, slot=0, sent_packets=1),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "start"]) == 0
|
||||
captured = capsys.readouterr()
|
||||
assert device.haptics_reads_at_out == [1]
|
||||
assert device.out_requests[0][1] == b"\x01\x00"
|
||||
assert device.haptics_reads == 3
|
||||
assert haptics_clock[0] >= 0.1
|
||||
assert "pending firmware confirmation" in captured.out
|
||||
assert "Haptics experiment: running" in captured.out
|
||||
assert "not physical actuator" in captured.out
|
||||
assert "1.024 s" in captured.out and "6.144 s" in captured.out
|
||||
|
||||
|
||||
def test_haptics_start_watch_captures_correlated_measurement_series(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float],
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(),
|
||||
haptics_response(1, run_id=1, slot=3),
|
||||
haptics_response(
|
||||
2, run_id=1, slot=3, sent_packets=101,
|
||||
first_tone_due_us=0xFFFFFFF0, first_tone_sent_us=0x30,
|
||||
elapsed_us=1100000,
|
||||
),
|
||||
haptics_response(3, run_id=1, slot=3, sent_packets=574, elapsed_us=6144000),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main([
|
||||
"haptics-experiment", "start", "--slot", "3", "--watch", "--json",
|
||||
]) == 0
|
||||
captured = capsys.readouterr()
|
||||
series = [json.loads(line) for line in captured.out.splitlines()]
|
||||
assert [row["state_name"] for row in series] == [
|
||||
"pending", "running", "completed",
|
||||
]
|
||||
assert [row["sent_packets"] for row in series] == [0, 101, 574]
|
||||
assert series[1]["first_tone_submission_delay_us"] == 64
|
||||
assert series[-1]["elapsed_us"] == 6144000
|
||||
assert series[-1]["pattern"]["duration_us"] == 6144000
|
||||
assert series[0]["host_monotonic_s"] < series[-1]["host_monotonic_s"]
|
||||
assert "pending firmware confirmation" in captured.err
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "error", "description"),
|
||||
[
|
||||
(6, 1, "unsupported"),
|
||||
(6, 2, "MTU"),
|
||||
(5, 3, "connection"),
|
||||
(7, 4, "timed out"),
|
||||
(7, 5, "send failed"),
|
||||
(7, 6, "wait for prior output to drain"),
|
||||
],
|
||||
)
|
||||
def test_haptics_start_reports_asynchronous_rejection(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float], state: int, error: int, description: str,
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(),
|
||||
haptics_response(1, run_id=1, slot=0),
|
||||
haptics_response(state, run_id=1, slot=0, last_error=error),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "start"]) == 1
|
||||
captured = capsys.readouterr()
|
||||
assert description in captured.err
|
||||
assert f"last_error={error}" in captured.err
|
||||
assert "Haptics experiment: running" not in captured.out
|
||||
|
||||
|
||||
def test_haptics_status_watch_reports_connection_loss_after_running(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float],
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(2, run_id=7, slot=0, sent_packets=11),
|
||||
haptics_response(5, run_id=7, slot=0, sent_packets=13, last_error=3),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main([
|
||||
"haptics-experiment", "status", "--watch", "--json",
|
||||
]) == 1
|
||||
captured = capsys.readouterr()
|
||||
assert [json.loads(line)["state_name"] for line in captured.out.splitlines()] == [
|
||||
"running", "disconnected",
|
||||
]
|
||||
assert "connection missing or lost" in captured.err
|
||||
|
||||
|
||||
def test_haptics_start_does_not_mistake_stale_completion_for_new_run(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float],
|
||||
) -> None:
|
||||
device = HapticsDevice([haptics_response(3, run_id=8, slot=0)])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main([
|
||||
"--timeout", "0.2", "haptics-experiment", "start",
|
||||
]) == 1
|
||||
assert "not confirmed before --timeout" in capsys.readouterr().err
|
||||
assert haptics_clock[0] == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_haptics_watch_is_bounded_and_rejects_run_replacement(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float],
|
||||
) -> None:
|
||||
device = HapticsDevice([haptics_response(2, run_id=1, slot=0)])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main([
|
||||
"--timeout", "0.2", "haptics-experiment", "status", "--watch",
|
||||
]) == 1
|
||||
assert "did not reach a terminal state" in capsys.readouterr().err
|
||||
device.haptics_responses = [
|
||||
haptics_response(2, run_id=1, slot=0),
|
||||
haptics_response(3, run_id=2, slot=0),
|
||||
]
|
||||
assert config_manager.main([
|
||||
"haptics-experiment", "status", "--watch",
|
||||
]) == 1
|
||||
assert "run changed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_haptics_busy_start_and_wrong_slot_stop_do_not_mutate_active_run(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = HapticsDevice([haptics_response(2, run_id=1, slot=3)])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "start"]) == 1
|
||||
assert "already running" in capsys.readouterr().err
|
||||
assert config_manager.main(["haptics-experiment", "stop"]) == 1
|
||||
assert "not requested slot 0" in capsys.readouterr().err
|
||||
assert device.out_requests == []
|
||||
|
||||
|
||||
def test_haptics_stop_waits_for_service_completion(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
haptics_clock: list[float],
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(2, run_id=1, slot=2),
|
||||
haptics_response(2, run_id=1, slot=2),
|
||||
haptics_response(4, run_id=1, slot=2),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main([
|
||||
"haptics-experiment", "stop", "--slot", "2", "--json",
|
||||
]) == 0
|
||||
captured = capsys.readouterr()
|
||||
assert json.loads(captured.out)["state_name"] == "stopped"
|
||||
assert device.out_requests[0][1] == b"\x00\x02"
|
||||
assert device.haptics_reads == 3
|
||||
|
||||
|
||||
def test_haptics_invalid_slot_is_rejected_before_discovery(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def unexpected_discovery(*args: object) -> None:
|
||||
pytest.fail("invalid haptics slot reached USB discovery")
|
||||
|
||||
monkeypatch.setattr(config_manager, "find_pico", unexpected_discovery)
|
||||
with pytest.raises(SystemExit) as raised:
|
||||
config_manager.main(["haptics-experiment", "start", "--slot", "4"])
|
||||
assert raised.value.code == 2
|
||||
|
||||
|
||||
def test_haptics_retry_after_unsupported_controller_is_not_disabled_firmware(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(6, run_id=7, slot=0, last_error=1),
|
||||
haptics_response(2, run_id=8, slot=0),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "start", "--json"]) == 0
|
||||
assert json.loads(capsys.readouterr().out)["run_id"] == 8
|
||||
assert len(device.out_requests) == 1
|
||||
|
||||
|
||||
def test_haptics_start_correlates_rollover_and_rejects_superseded_run(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(3, run_id=0xFFFFFFFF, slot=0),
|
||||
haptics_response(2, run_id=0, slot=0),
|
||||
])
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "start", "--json"]) == 0
|
||||
assert json.loads(capsys.readouterr().out)["run_id"] == 0
|
||||
device.haptics_responses = [
|
||||
haptics_response(3, run_id=10, slot=0),
|
||||
haptics_response(3, run_id=12, slot=0),
|
||||
]
|
||||
assert config_manager.main(["haptics-experiment", "start"]) == 1
|
||||
assert "run changed" in capsys.readouterr().err
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout", ["nan", "inf"])
|
||||
def test_haptics_timeout_must_be_bounded_before_discovery(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
timeout: str,
|
||||
) -> None:
|
||||
def unexpected_discovery(*args: object) -> None:
|
||||
pytest.fail("unbounded timeout reached USB discovery")
|
||||
|
||||
monkeypatch.setattr(config_manager, "find_pico", unexpected_discovery)
|
||||
assert config_manager.main([
|
||||
"--timeout", timeout, "haptics-experiment", "status", "--watch",
|
||||
]) == 2
|
||||
assert "must be finite" in capsys.readouterr().err
|
||||
|
||||
|
||||
def transport_response(
|
||||
*, run_id: int = 17, connection_generation: int = 9,
|
||||
connection_handle: int = 0x1234, active: int = 0,
|
||||
) -> bytes:
|
||||
return make_response(
|
||||
config_manager.OP_HAPTICS_TRANSPORT_PROBE,
|
||||
struct.pack(
|
||||
"<32I", run_id, connection_generation, connection_handle,
|
||||
4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
|
||||
20, 21, 22, 23, 24, 0xFFFFFFF0, active, 27, 0xFFFFFFFF, 29, 30,
|
||||
1021, 10,
|
||||
),
|
||||
schema=2, generation=run_id,
|
||||
)
|
||||
|
||||
|
||||
def test_haptics_profile_decodes_exact_wire_order_and_correlates_live_run(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(2, run_id=17, slot=0, sent_packets=10),
|
||||
haptics_response(2, run_id=17, slot=0, sent_packets=11),
|
||||
], transport_response=transport_response(active=1))
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 0
|
||||
row = json.loads(capsys.readouterr().out)
|
||||
assert row["run_id"] == 17 and row["connection_generation"] == 9
|
||||
assert row["sent_packets"] == 11
|
||||
transport = row["transport"]
|
||||
transport.pop("evidence_note")
|
||||
assert transport == {
|
||||
"schema_version": 2,
|
||||
"run_id": 17,
|
||||
"connection_generation": 9,
|
||||
"connection_handle": 0x1234,
|
||||
"timer_wakes": 4,
|
||||
"max_timer_lateness_us": 5,
|
||||
"total_timer_lateness_us": 6,
|
||||
"send_calls": 7,
|
||||
"max_send_us": 8,
|
||||
"total_send_us": 9,
|
||||
"write_calls": 10,
|
||||
"max_write_us": 11,
|
||||
"total_write_us": 12,
|
||||
"read_calls": 13,
|
||||
"read_packets": 14,
|
||||
"max_read_us": 15,
|
||||
"total_read_us": 16,
|
||||
"poll_calls": 17,
|
||||
"max_poll_us": 18,
|
||||
"total_poll_us": 19,
|
||||
"completion_events": 20,
|
||||
"completed_packets": 21,
|
||||
"max_completion_gap_us": 22,
|
||||
"max_outstanding_acl": 23,
|
||||
"min_free_acl": 24,
|
||||
"first_tone_send_return_us": 0xFFFFFFF0,
|
||||
"active": True,
|
||||
"max_permission_wait_us": 27,
|
||||
"total_permission_wait_us": 0xFFFFFFFF,
|
||||
"permission_callbacks": 29,
|
||||
"max_poll_gap_us": 30,
|
||||
"controller_acl_packet_bytes": 1021,
|
||||
"controller_acl_packet_count": 10,
|
||||
}
|
||||
assert transport["active"] is True
|
||||
assert device.out_requests == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "message"),
|
||||
[
|
||||
("schema", "unsupported haptics transport probe schema"),
|
||||
("short", "payload size"),
|
||||
("long", "payload size"),
|
||||
("flags", "reserved flags"),
|
||||
("active", "active boolean"),
|
||||
("handle", "connection handle"),
|
||||
("envelope_run", "envelope run ID mismatch"),
|
||||
("status", "device busy"),
|
||||
("crc", "CRC mismatch"),
|
||||
],
|
||||
)
|
||||
def test_haptics_profile_rejects_malformed_transport(
|
||||
mutation: str, message: str,
|
||||
) -> None:
|
||||
payload = bytearray(transport_response()[20:])
|
||||
schema, flags, generation, status = 2, 0, 17, config_manager.STATUS_OK
|
||||
if mutation == "schema":
|
||||
schema = 1
|
||||
elif mutation == "short":
|
||||
payload.pop()
|
||||
elif mutation == "long":
|
||||
payload.extend(b"\0\0\0\0")
|
||||
elif mutation == "flags":
|
||||
flags = 1
|
||||
elif mutation == "active":
|
||||
struct.pack_into("<I", payload, 100, 2)
|
||||
elif mutation == "handle":
|
||||
struct.pack_into("<I", payload, 8, 0x10000)
|
||||
elif mutation == "envelope_run":
|
||||
generation = 18
|
||||
elif mutation == "status":
|
||||
status = 7
|
||||
response = make_response(
|
||||
0x41, bytes(payload), schema=schema, flags=flags,
|
||||
generation=generation, status=status,
|
||||
)
|
||||
if mutation == "crc":
|
||||
response = response[:-1] + bytes((response[-1] ^ 1,))
|
||||
device = HapticsDevice(
|
||||
[haptics_response(3, run_id=17, slot=0)], transport_response=response,
|
||||
)
|
||||
with pytest.raises(config_manager.ConfigManagerError, match=message):
|
||||
config_manager.read_haptics_experiment_profile(device)
|
||||
|
||||
|
||||
def test_haptics_profile_accepts_retained_failed_run_with_invalid_handle(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = HapticsDevice(
|
||||
[haptics_response(5, run_id=17, slot=0, last_error=3)],
|
||||
transport_response=transport_response(connection_handle=0xFFFF),
|
||||
)
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 0
|
||||
row = json.loads(capsys.readouterr().out)
|
||||
assert row["state_name"] == "disconnected" and row["last_error"] == 3
|
||||
assert row["transport"]["connection_handle"] == 0xFFFF
|
||||
assert row["transport"]["active"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unsupported", ["status", "stall"])
|
||||
def test_haptics_profile_unsupported_keeps_legacy_status_readable(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
unsupported: str,
|
||||
) -> None:
|
||||
response: bytes | Exception = make_response(
|
||||
0x41, status=config_manager.STATUS_UNSUPPORTED_SCHEMA, schema=1,
|
||||
)
|
||||
if unsupported == "stall":
|
||||
response = config_manager.usb.core.USBError(
|
||||
"Pipe error", error_code=-9, errno=32,
|
||||
)
|
||||
device = HapticsDevice(
|
||||
[haptics_response(3, run_id=17, slot=0)], transport_response=response,
|
||||
)
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 1
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert "0x41" in captured.err
|
||||
assert "SWITCH_PICO_HAPTICS_EXPERIMENT=ON" in captured.err
|
||||
assert config_manager.main(["haptics-experiment", "status", "--json"]) == 0
|
||||
assert json.loads(capsys.readouterr().out)["run_id"] == 17
|
||||
assert device.out_requests == []
|
||||
|
||||
|
||||
def test_haptics_profile_does_not_hide_usb_disconnect_as_unsupported() -> None:
|
||||
disconnected = config_manager.usb.core.USBError(
|
||||
"No such device", error_code=-4, errno=19,
|
||||
)
|
||||
device = HapticsDevice(
|
||||
[haptics_response(3, run_id=17, slot=0)], transport_response=disconnected,
|
||||
)
|
||||
with pytest.raises(config_manager.usb.core.USBError) as raised:
|
||||
config_manager.read_haptics_experiment_profile(device)
|
||||
assert raised.value is disconnected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("after_run", "after_generation", "probe_run", "probe_generation"),
|
||||
[
|
||||
(17, 9, 16, 9), # A stale probe must not attach to the current run.
|
||||
(18, 9, 17, 9), # A new run starts after reading the probe.
|
||||
(18, 9, 18, 9), # A new run starts before reading the probe.
|
||||
(17, 10, 17, 9), # A connection changes after reading the probe.
|
||||
(17, 10, 17, 10), # A connection changes before reading the probe.
|
||||
(17, 9, 17, 8), # Matching run IDs cannot mask stale connection data.
|
||||
],
|
||||
)
|
||||
def test_haptics_profile_never_publishes_cross_run_metrics(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
after_run: int, after_generation: int, probe_run: int, probe_generation: int,
|
||||
) -> None:
|
||||
device = HapticsDevice([
|
||||
haptics_response(2, run_id=17, slot=0),
|
||||
haptics_response(
|
||||
2, run_id=after_run, slot=0, connection_generation=after_generation,
|
||||
),
|
||||
], transport_response=transport_response(
|
||||
run_id=probe_run, connection_generation=probe_generation,
|
||||
))
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [device])
|
||||
assert config_manager.main(["haptics-experiment", "profile", "--json"]) == 1
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert "cannot attribute measurements" in captured.err
|
||||
|
|
|
|||
44
tests/test_haptics_experiment_native.py
Normal file
44
tests/test_haptics_experiment_native.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import shutil
|
||||
import subprocess
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ram", [0, 1], ids=["flash", "sram"])
|
||||
def test_haptics_experiment_native(tmp_path: Path, ram: int) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
executable = tmp_path / "haptics_experiment_test"
|
||||
corpus = tmp_path / "native_reports.bin"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-O2",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
"-DSWITCH_PICO_HAPTICS_EXPERIMENT=1",
|
||||
f"-DSWITCH_PICO_HAPTICS_EXPERIMENT_RAM={ram}",
|
||||
f"-I{root / 'tests' / 'haptics_experiment_native_stubs'}",
|
||||
f"-I{root / 'src' / 'firmware'}",
|
||||
str(root / "tests" / "haptics_experiment_test.cpp"),
|
||||
str(root / "src" / "firmware" / "input" / "haptics_experiment.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable), str(corpus)], check=True, cwd=root)
|
||||
reports = corpus.read_bytes()
|
||||
assert len(reports) == 288 * 143
|
||||
# Independent standard-library CRC across real module-generated packets:
|
||||
# A2 is covered once, CRC itself excluded, and stored little-endian.
|
||||
for offset in range(0, len(reports), 143):
|
||||
report = reports[offset : offset + 143]
|
||||
assert int.from_bytes(report[-4:], "little") == zlib.crc32(report[:-4])
|
||||
35
tests/test_haptics_transport_probe_native.py
Normal file
35
tests/test_haptics_transport_probe_native.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_haptics_transport_probe_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
executable = tmp_path / "haptics_transport_probe_test"
|
||||
stubs = root / "tests" / "haptics_transport_probe_native_stubs"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-O2",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-I{stubs}",
|
||||
f"-I{root / 'src' / 'firmware'}",
|
||||
str(root / "tests" / "haptics_transport_probe_test.cpp"),
|
||||
str(stubs / "sdk.cpp"),
|
||||
str(root / "src" / "firmware" / "input" / "haptics_transport_probe.cpp"),
|
||||
"-Wl,--wrap=cyw43_bluetooth_hci_write",
|
||||
"-Wl,--wrap=cyw43_bluetooth_hci_read",
|
||||
"-Wl,--wrap=btstack_run_loop_base_poll_data_sources",
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
|
@ -2,9 +2,14 @@ import shutil
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_usb_configuration_management_native(tmp_path: Path) -> None:
|
||||
|
||||
@pytest.mark.parametrize("experiment_enabled", [False, True])
|
||||
def test_usb_configuration_management_native(
|
||||
tmp_path: Path, experiment_enabled: bool,
|
||||
) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
|
|
@ -18,6 +23,7 @@ def test_usb_configuration_management_native(tmp_path: Path) -> None:
|
|||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
*(["-DSWITCH_PICO_HAPTICS_EXPERIMENT=1"] if experiment_enabled else []),
|
||||
f"-I{root / 'tests' / 'usb_management_native_stubs'}",
|
||||
f"-I{root / 'src' / 'firmware'}",
|
||||
str(root / "tests" / "usb_configuration_management_test.cpp"),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
|
||||
#include <tusb.h>
|
||||
#include "usb/usb_output_driver.h"
|
||||
#include "input/haptics_experiment.h"
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
#include "input/haptics_transport_probe.h"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
|
|
@ -58,6 +62,11 @@ uint32_t profile_metadata_transaction_id = 0;
|
|||
uint8_t profile_metadata_index = 0;
|
||||
std::string profile_metadata_value;
|
||||
bool identify_requested = false;
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
HapticsExperimentDiagnostics current_haptics{};
|
||||
uint32_t haptics_request_count = 0;
|
||||
HapticsTransportProbe current_transport{};
|
||||
#endif
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
|
|
@ -712,6 +721,258 @@ void test_profile_vendor_requests() {
|
|||
"short profile selection request was accepted");
|
||||
}
|
||||
|
||||
std::vector<uint8_t> read_haptics_payload() {
|
||||
using namespace UsbConfigurationManagement;
|
||||
tusb_control_request_t request = setup_request(
|
||||
Operation::kHapticsExperiment, TUSB_DIR_IN, kMaximumResponseSize);
|
||||
require(usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_SETUP, &request),
|
||||
"experiment diagnostics IN was rejected");
|
||||
require(control_payload.size() == kResponseHeaderSize + 72 &&
|
||||
control_payload[5] == 0x40 &&
|
||||
control_payload[6] == static_cast<uint8_t>(Status::kOk) &&
|
||||
control_payload[7] == 0 &&
|
||||
read_u16(control_payload, 8) == 72 &&
|
||||
read_u16(control_payload, 10) == 2,
|
||||
"experiment schema-2 envelope is invalid");
|
||||
std::vector<uint8_t> payload(
|
||||
control_payload.begin() + kResponseHeaderSize, control_payload.end());
|
||||
require(read_u32(control_payload, 16) ==
|
||||
configuration_crc32(payload.data(), payload.size()),
|
||||
"experiment response CRC is invalid");
|
||||
return payload;
|
||||
}
|
||||
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
void perform_haptics_out(uint8_t action, uint8_t slot, bool accepted = true) {
|
||||
using namespace UsbConfigurationManagement;
|
||||
next_out_payload = make_request(Operation::kHapticsExperiment, {action, slot});
|
||||
tusb_control_request_t request = setup_request(
|
||||
Operation::kHapticsExperiment, TUSB_DIR_OUT,
|
||||
static_cast<uint16_t>(next_out_payload.size()));
|
||||
require(usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_SETUP, &request),
|
||||
"experiment OUT setup was rejected");
|
||||
require(usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_DATA, &request) == accepted,
|
||||
"experiment must reject invalid or busy requests before status ACK");
|
||||
const uint32_t requests_after_data = haptics_request_count;
|
||||
if (accepted) {
|
||||
require(!usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_DATA, &request) &&
|
||||
haptics_request_count == requests_after_data,
|
||||
"duplicate DATA replayed experiment control");
|
||||
}
|
||||
require(usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_ACK, &request) == accepted,
|
||||
"experiment ACK did not preserve DATA-stage result");
|
||||
require(!usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_ACK, &request) &&
|
||||
haptics_request_count == requests_after_data,
|
||||
"ACK replayed experiment control");
|
||||
}
|
||||
#endif
|
||||
|
||||
void test_haptics_experiment_requests() {
|
||||
using namespace UsbConfigurationManagement;
|
||||
for (uint16_t payload_size : {0, 1, 3}) {
|
||||
tusb_control_request_t request = setup_request(
|
||||
Operation::kHapticsExperiment, TUSB_DIR_OUT,
|
||||
kRequestHeaderSize + payload_size);
|
||||
require(!usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_SETUP, &request),
|
||||
"malformed experiment control size was accepted");
|
||||
}
|
||||
std::vector<uint8_t> expected(72, 0);
|
||||
expected[69] = 0xff;
|
||||
#ifndef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
expected[68] = 6;
|
||||
require(read_haptics_payload() == expected,
|
||||
"disabled firmware must expose only the unsupported snapshot");
|
||||
for (uint8_t action : {0, 1}) {
|
||||
next_out_payload = make_request(Operation::kHapticsExperiment, {action, 0});
|
||||
tusb_control_request_t request = setup_request(
|
||||
Operation::kHapticsExperiment, TUSB_DIR_OUT,
|
||||
static_cast<uint16_t>(next_out_payload.size()));
|
||||
require(!usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_SETUP, &request),
|
||||
"disabled firmware accepted experiment control");
|
||||
}
|
||||
#else
|
||||
require(read_haptics_payload() == expected,
|
||||
"enabled firmware must initially be idle without a selected slot");
|
||||
perform_haptics_out(2, 0, false);
|
||||
perform_haptics_out(1, 4, false);
|
||||
perform_haptics_out(0, 0xff, false);
|
||||
require(haptics_request_count == 0,
|
||||
"malformed control reached the experiment service");
|
||||
|
||||
next_out_payload = make_request(Operation::kHapticsExperiment, {1, 2});
|
||||
next_out_payload.back() ^= 1;
|
||||
tusb_control_request_t request = setup_request(
|
||||
Operation::kHapticsExperiment, TUSB_DIR_OUT,
|
||||
static_cast<uint16_t>(next_out_payload.size()));
|
||||
require(usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
!usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_DATA, &request),
|
||||
"bad experiment request CRC was accepted");
|
||||
require(haptics_request_count == 0,
|
||||
"bad CRC control reached the experiment service");
|
||||
|
||||
perform_haptics_out(1, 2);
|
||||
auto payload = read_haptics_payload();
|
||||
require(payload[68] == 1 && payload[69] == 2 &&
|
||||
read_u32(payload, 0) == 1 && read_u32(payload, 16) == 0 &&
|
||||
haptics_request_count == 1,
|
||||
"USB acceptance must remain pending until the service starts");
|
||||
perform_haptics_out(1, 1, false);
|
||||
payload = read_haptics_payload();
|
||||
require(payload[68] == 1 && payload[69] == 2 &&
|
||||
read_u32(payload, 0) == 1,
|
||||
"busy start overwrote the accepted run");
|
||||
|
||||
// Model the independently progressing Core 1 service, not a USB echo.
|
||||
current_haptics = {
|
||||
1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4,
|
||||
123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210,
|
||||
1100000, HapticsExperimentState::kRunning, 2, 0,
|
||||
};
|
||||
const uint32_t fields[] = {
|
||||
1, 0x11223344, 0xffff0000, 103, 101, 2, 3, 106, 4,
|
||||
123, 22000, 11001, 9876, 0xfffffff0, 0x30, 0x76543210, 1100000,
|
||||
};
|
||||
for (size_t index = 0; index < 17; ++index) {
|
||||
write_u32(&expected, index * 4, fields[index]);
|
||||
}
|
||||
expected[68] = 2;
|
||||
expected[69] = 2;
|
||||
require(read_haptics_payload() == expected,
|
||||
"schema-2 timing fields are not in little-endian wire order");
|
||||
|
||||
perform_haptics_out(0, 2);
|
||||
payload = read_haptics_payload();
|
||||
require(payload[68] == 2 && read_u32(payload, 0) == 1,
|
||||
"USB stop ACK must not fabricate terminal completion");
|
||||
current_haptics.state = HapticsExperimentState::kStopped;
|
||||
require(read_haptics_payload()[68] == 4,
|
||||
"service stop transition was not observable");
|
||||
|
||||
next_out_payload = make_request(Operation::kHapticsExperiment, {1, 3});
|
||||
request = setup_request(
|
||||
Operation::kHapticsExperiment, TUSB_DIR_OUT,
|
||||
static_cast<uint16_t>(next_out_payload.size()));
|
||||
const uint32_t requests_before_cancel = haptics_request_count;
|
||||
require(usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_SETUP, &request),
|
||||
"canceled experiment setup was rejected");
|
||||
read_haptics_payload(); // A new SETUP cancels the previous OUT transfer.
|
||||
require(!usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_DATA, &request) &&
|
||||
!usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_ACK, &request) &&
|
||||
haptics_request_count == requests_before_cancel,
|
||||
"canceled experiment request reused stale payload");
|
||||
|
||||
perform_haptics_out(1, 3);
|
||||
current_haptics.state = HapticsExperimentState::kDisconnected;
|
||||
current_haptics.last_error = 3;
|
||||
payload = read_haptics_payload();
|
||||
require(payload[68] == 5 && payload[69] == 3 && payload[70] == 3 &&
|
||||
read_u32(payload, 0) == 2,
|
||||
"asynchronous connection failure lost request correlation");
|
||||
#endif
|
||||
}
|
||||
|
||||
void test_haptics_transport_probe_requests() {
|
||||
using namespace UsbConfigurationManagement;
|
||||
for (uint16_t length : {0, 16, 18, 120}) {
|
||||
tusb_control_request_t request = setup_request(
|
||||
Operation::kHapticsTransportProbe, TUSB_DIR_OUT, length);
|
||||
for (uint8_t stage : {
|
||||
CONTROL_STAGE_SETUP, CONTROL_STAGE_DATA, CONTROL_STAGE_ACK}) {
|
||||
require(!usb_configuration_management_vendor_control(
|
||||
0, stage, &request),
|
||||
"IN-only transport probe accepted an OUT transfer");
|
||||
}
|
||||
}
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
current_transport.run_id = 0x10203040;
|
||||
current_transport.connection_generation = 0x50607080;
|
||||
current_transport.connection_handle = 0xffff;
|
||||
current_transport.timer_wakes = 4;
|
||||
current_transport.max_timer_lateness_us = 5;
|
||||
current_transport.total_timer_lateness_us = 6;
|
||||
current_transport.send_calls = 7;
|
||||
current_transport.max_send_us = 8;
|
||||
current_transport.total_send_us = 9;
|
||||
current_transport.write_calls = 10;
|
||||
current_transport.max_write_us = 11;
|
||||
current_transport.total_write_us = 12;
|
||||
current_transport.read_calls = 13;
|
||||
current_transport.read_packets = 14;
|
||||
current_transport.max_read_us = 15;
|
||||
current_transport.total_read_us = 16;
|
||||
current_transport.poll_calls = 17;
|
||||
current_transport.max_poll_us = 18;
|
||||
current_transport.total_poll_us = 19;
|
||||
current_transport.completion_events = 20;
|
||||
current_transport.completed_packets = 21;
|
||||
current_transport.max_completion_gap_us = 22;
|
||||
current_transport.max_outstanding_acl = 23;
|
||||
current_transport.min_free_acl = 24;
|
||||
current_transport.first_tone_send_return_us = 0xfffffff0;
|
||||
current_transport.active = 1;
|
||||
current_transport.max_permission_wait_us = 27;
|
||||
current_transport.total_permission_wait_us = 0xffffffff;
|
||||
current_transport.permission_callbacks = 29;
|
||||
current_transport.max_poll_gap_us = 30;
|
||||
current_transport.controller_acl_packet_bytes = 1021;
|
||||
current_transport.controller_acl_packet_count = 10;
|
||||
#endif
|
||||
tusb_control_request_t request = setup_request(
|
||||
Operation::kHapticsTransportProbe, TUSB_DIR_IN, kMaximumResponseSize);
|
||||
require(usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_SETUP, &request),
|
||||
"transport probe IN was rejected");
|
||||
require(control_payload.size() >= kResponseHeaderSize,
|
||||
"transport probe response header is truncated");
|
||||
require(control_payload[5] == 0x41 && control_payload[7] == 0 &&
|
||||
read_u16(control_payload, 10) == 2,
|
||||
"transport probe operation, flags, or schema are invalid");
|
||||
const std::vector<uint8_t> payload(
|
||||
control_payload.begin() + kResponseHeaderSize, control_payload.end());
|
||||
require(read_u32(control_payload, 16) ==
|
||||
configuration_crc32(payload.data(), payload.size()),
|
||||
"transport probe response CRC is invalid");
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
require(control_payload.size() == kResponseHeaderSize + 128 &&
|
||||
read_u16(control_payload, 8) == 128 &&
|
||||
control_payload[6] == static_cast<uint8_t>(Status::kOk) &&
|
||||
read_u32(control_payload, 12) == 0x10203040,
|
||||
"transport probe schema-2 envelope is invalid");
|
||||
const uint32_t fields[] = {
|
||||
0x10203040, 0x50607080, 0xffff, 4, 5, 6, 7, 8, 9, 10,
|
||||
11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24,
|
||||
0xfffffff0, 1, 27, 0xffffffff, 29, 30,
|
||||
1021, 10,
|
||||
};
|
||||
std::vector<uint8_t> expected(128);
|
||||
for (size_t index = 0; index < 32; ++index) {
|
||||
write_u32(&expected, index * 4, fields[index]);
|
||||
}
|
||||
require(payload == expected,
|
||||
"transport probe fields are not in explicit little-endian wire order");
|
||||
#else
|
||||
require(control_payload.size() == kResponseHeaderSize &&
|
||||
read_u16(control_payload, 8) == 0 &&
|
||||
read_u32(control_payload, 12) == 0 &&
|
||||
control_payload[6] ==
|
||||
static_cast<uint8_t>(Status::kUnsupportedSchema),
|
||||
"disabled firmware must report the transport probe as unsupported");
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
uint32_t configuration_crc32(const uint8_t* data, size_t size) {
|
||||
|
|
@ -899,6 +1160,33 @@ void bluepad32_input_backend_diagnostics(
|
|||
*out = current_diagnostics;
|
||||
}
|
||||
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
bool haptics_experiment_request(uint8_t action, uint8_t slot) {
|
||||
++haptics_request_count;
|
||||
if (action == 1 &&
|
||||
(current_haptics.state == HapticsExperimentState::kPending ||
|
||||
current_haptics.state == HapticsExperimentState::kRunning)) {
|
||||
return false;
|
||||
}
|
||||
if (action == 1) {
|
||||
const uint32_t run_id = current_haptics.run_id + 1;
|
||||
current_haptics = {};
|
||||
current_haptics.run_id = run_id;
|
||||
current_haptics.slot = slot;
|
||||
current_haptics.state = HapticsExperimentState::kPending;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void haptics_experiment_snapshot(HapticsExperimentDiagnostics* output) {
|
||||
*output = current_haptics;
|
||||
}
|
||||
|
||||
void haptics_transport_probe_snapshot(HapticsTransportProbe* output) {
|
||||
*output = current_transport;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool adapter_reboot_to_bootsel() {
|
||||
bootsel_reboot_requested = true;
|
||||
return true;
|
||||
|
|
@ -941,5 +1229,7 @@ int main() {
|
|||
test_vendor_requests();
|
||||
test_mode_vendor_requests();
|
||||
test_profile_vendor_requests();
|
||||
test_haptics_experiment_requests();
|
||||
test_haptics_transport_probe_requests();
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue