Skip to content

Config setter ergonomics: auto-sync, n_configured, clear_channel_map, async runlevel#182

Merged
cboulay merged 8 commits intomasterfrom
config-setter-ergonomics
Apr 29, 2026
Merged

Config setter ergonomics: auto-sync, n_configured, clear_channel_map, async runlevel#182
cboulay merged 8 commits intomasterfrom
config-setter-ergonomics

Conversation

@cboulay
Copy link
Copy Markdown
Collaborator

@cboulay cboulay commented Apr 28, 2026

Summary

Closes #177, #178, #181 (with side fix to a CHANSETSPKTHR bug); resolves #179. (#180 was already covered by register_config_callback and is closed.)

Four commits, in order:

  • 2708ca6 — Per-channel CHANSET setters get an auto_sync opt-in. The 9 broadly-scoped setters now take a trailing int auto_sync (C-API) / auto_sync: bool = False (pycbsdk) flag; when true they run sync() first so the local cache reflects in-flight CHANREPs before the read-modify-write. set_channel_spkthrlevel stays narrow. Bundles two firmware-mismatch bug fixes: set_channel_spkopts and set_channel_spike_sorting were emitting CHANSETSPKTHR, which the firmware ignores spkopts on — switched to CHANSETSPK.
  • d0072c0 — Bulk by-type setters self-sync (top + bottom) and return n_configured post-sync; chans=None (UINT32_MAX in C) means "all matching". Always sends to every in-scope channel — no skip-if-already-correct, since a dropped CHANREP could leave the local cache stuck against a concurrent change. Includes the matching CHANSETSPKTHR→CHANSET fix in DeviceSession (STANDALONE path).
  • 89d08e0clear_channel_map() to undo load_channel_map(). Drops the local position+label overlay and pushes default chan{N} labels to the device.
  • 32dde5aregisterRunlevelChangeCallback in C++/C-API + async session.wait_until_running() / await session.wait_for_runlevel(level) in pycbsdk. Resolves once the device-reported runlevel reaches the target; thread-bridged via loop.call_soon_threadsafe. Returns immediately if already past target.

Test plan

  • All 192 non-device C++ unit tests pass (ctest -E '^(device\.|integration\.)')
  • All 145 pycbsdk tests pass against nplay (135 prior + 10 new in TestNConfigured, TestWaitUntilRunning, and 3 new clear_channel_map cases in TestCMP)
  • Hardware-dependent C++ integration tests (device.*, integration.*) — not run locally; needs an NSP/HUB or nplay-on-CI
  • Smoke-test in ezmsg-blackrock against the new await session.wait_until_running() and set_sample_group(chans=None) shape

Breaking API changes

C-API and pycbsdk both:

  • Per-channel setters: trailing auto_sync parameter (callers must pass 0/False to keep prior behavior).
  • Bulk by-type setters (set_sample_group, set_ac_input_coupling, set_spike_sorting, set_spike_extraction): signature change — n_chans becomes chans accepting int | None, return type changes from result-only to int (n_configured).

🤖 Generated with Claude Code

cboulay and others added 8 commits April 27, 2026 17:41
Per-channel C-API setters that emit CHANSET-family packets are
read-modify-write: they seed the outgoing CHANINFO from the local
chaninfo cache.  When a prior config command sent by this process is
still in flight, the seed can be stale and the setter clobbers it back
to the old value.

Add a trailing `int auto_sync` parameter to the affected setters; when
non-zero, run an internal `sync()` first so the cache reflects all
prior in-flight CHANREP packets.  set_channel_spkthrlevel stays
unchanged because CHANSETSPKTHR is "narrow" — the firmware reads only
spkthrlevel and the setter overwrites it fully.

Also fix two long-standing packet-type bugs flagged while auditing the
firmware-side handlers:

- set_channel_spkopts emitted CHANSETSPKTHR; the firmware ignores
  spkopts on that type and only reads spkthrlevel, so the change was
  silently dropped.  Use CHANSETSPK (firmware reads spkopts+spkfilter).
- set_channel_spike_sorting had the same bug.  Same fix.

The existing call sites in tests/integration/test_capi_configuration.cpp
are updated to pass auto_sync=0 explicitly and replace the post-call
sleep with an explicit sync() (faster + race-free).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructure setSampleGroup, setACInputCoupling, setSpikeSorting, and
setSpikeExtraction so callers no longer have to manually sync() before
reading state back, and so they get the post-config count of channels
matching the requested configuration.

For each of the four bulk by-type setters in SdkSession:

- Run sync() at entry so the local cache reflects any prior in-flight
  config from this process before we seed the outgoing CHANSET* packets
  (the #177 contract for broadly-scoped setters).
- Always send a CHANSET* packet for every in-scope channel, even if the
  cached value already looks correct.  A dropped CHANREP packet from
  another client would otherwise leave the local cache stuck against a
  concurrent change with no escape path other than re-sending.
- Run sync() at exit so the device has applied our changes before we
  count.
- Re-scan chaninfo to count channels of the requested type whose
  post-config state matches the request, and return that count.
- Change the C++ return type from Result<void> to Result<uint32_t>;
  change nChans from size_t to uint32_t with UINT32_MAX as the
  "all matching" sentinel.

Mirror through:

- C-API: trailing `uint32_t* out_n_configured` out-param on the four
  cbsdk_session_set_* entrypoints; nChans becomes uint32_t.
- pycbsdk: rename `n_chans` to `chans`, accept `int | None` (None →
  UINT32_MAX), return the count.  Drop the "Call sync() before reading
  back state" docstring blocks since the methods are now self-syncing.

Also fixes the STANDALONE-mode CLIENT-of-this-bug companion to commit
2708ca6: DeviceSession::setChannelsSpikeSortingByType emitted
CHANSETSPKTHR while modifying spkopts; the firmware ignored those
changes.  Switch to CHANSET (firmware applies all chaninfo fields).
The matching setChannelsSpikeSortingSync waits for CHANREP instead of
CHANREPSPKTHR.

Tests:
- New TestNConfigured class in pycbsdk/tests/test_configuration.py
  covers chans=None ↔ all-matching and the n_configured return on
  set_sample_group, set_ac_input_coupling, and set_spike_extraction.
- C-API test sites in tests/integration/test_capi_configuration.cpp
  pass &n_configured and assert the count; the now-redundant sleeps
  are dropped (sync is internal).
- tests/unit/test_cbsdk_c_api.cpp signature update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After load_channel_map() began accumulating overlays (one CMP per
headstage, multiple headstages per device), there was no way to clear
them.  Loading "" was the only undo path and was non-obvious.

Add clear_channel_map() at every layer:

- SdkSession::clearChannelMap(): snapshot the chan_ids in
  cmp_entries, drop the overlay map, zero any local position state in
  shmem, and push CHANSETLABEL packets with default "chan{N}" labels
  to the device for every previously-mapped channel so the device-side
  label state matches.  Fire-and-forget.
- C-API: cbsdk_session_clear_channel_map.
- pycbsdk: Session.clear_channel_map.

Tests in pycbsdk/tests/test_configuration.py (TestCMP) cover label
revert, position revert, and the no-op-with-no-map case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Async-aware applications (e.g. ezmsg-blackrock) need to wait for the
device to reach cbRUNLEVEL_RUNNING before issuing config calls, but
the existing primitive — polling session.running — is the SDK
lifecycle flag, not the device runlevel.  Add a proper
runlevel-transition callback at the C++ level and a thread-bridged
asyncio awaitable on top of it in pycbsdk.

C++ (SdkSession):
- New RunlevelCallback type and registerRunlevelChangeCallback().
- New runlevel_callbacks vector in Impl, plus updateRunlevel() helper
  that atomically swaps device_runlevel and fires registered callbacks
  on transition.  Both SYSREP-handling sites (STANDALONE receive
  thread and CLIENT shmem-receive thread) now route through it.
- unregisterCallback() extends to the new vector.

C-API:
- New cbsdk_runlevel_callback_fn typedef and
  cbsdk_session_register_runlevel_callback() entrypoint.

pycbsdk:
- _register_runlevel_callback() helper mirroring
  _register_config_callback (FFI callback + handle/ref tracking).
- async wait_for_runlevel(level, timeout) — returns immediately if
  already at/past `level`, otherwise registers a one-shot listener
  and resolves a thread-bridged future via loop.call_soon_threadsafe.
- async wait_until_running(timeout=10.0) — thin wrapper over
  wait_for_runlevel(RUNLEVEL_RUNNING).
- Module-level RUNLEVEL_* constants mirroring cbRUNLEVEL_* in
  cbproto/types.h.

TestWaitUntilRunning in pycbsdk/tests/test_configuration.py covers
the immediate-return path; the registration path requires fault
injection that isn't worth the test complexity here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend the four bulk by-type setters (set_sample_group,
set_ac_input_coupling, set_spike_sorting, set_spike_extraction) so
callers can pass an explicit list of 1-based channel IDs in addition
to the existing "first N matching" / "all matching" modes.  This
makes disjoint-set configurations possible — e.g. configuring chans
1-32 to one rate and chans 33-64 to another in two calls — without
each call having to manually iterate.

C++ (SdkSession): trailing `const uint32_t* chans = nullptr`
parameter on each of the four setters.  When non-null, the setter
configures exactly the listed chan IDs (no chan_type filter on the
listed entries; caller is trusted).  When null, behavior is
unchanged.  `chan_type` still defines the "others" set for
disable_others and the post-config count.

Implementation also restructures the four setters around a shared
sendBulkPackets path (now a public SdkSession method that picks the
most efficient transport: device_session->sendPackets in STANDALONE
for direct UDP with built-in pacing, per-packet shmem enqueue in
CLIENT).  This fixes a latent ordering issue introduced by an
earlier draft of #181 where STANDALONE-mode bulk sends went
through shmem while sync()'s SYSSETRUNLEV went via direct UDP, so
the SYSREP barrier could fire ahead of queued CHANSET packets.

C-API: trailing `const uint32_t* chans` argument on each of the four
cbsdk_session_set_* entrypoints (positioned right after `n_chans`).

pycbsdk: `chans` accepts `int | list[int] | Iterable[int] | None`.
A new private `_normalize_chans` helper converts the Python value
to (n_chans, ffi-uint32-array) for the C-API.  bool is rejected
explicitly.

Tests: new TestBulkSettersChanList covers disjoint ranges, disjoint
non-contiguous sets, disable_others combined with a list,
explicit-list paths through the other three setters, the empty-list
edge case, and that tuple/range/etc. are accepted alongside list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CApiACCouplingTest and CApiSpikeSortingTest tests asserted that
n_configured == 4 after configuring the first 4 FE channels.  Per
issue #181 the returned count is "FE chans whose post-config state
matches the request", not "chans we just touched" — and the device
defaults all FE chans to AC-coupled with no sorting, so the 4-chan
no-op produced n_configured == n_fe (== 256 on CI), failing the
assertion.

Fix by establishing the inverse state across all FE chans first
(DC for the AC test, AC for the DC test, HOOPSORT for the
spike-sort test), so the 4-chan partial config genuinely produces
exactly 4 chans matching the request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the call sequence reported as hanging indefinitely in
ezmsg-blackrock integration tests:
session.set_sample_group(2, FRONTEND, SR_30kHz, disable_others=True)

Passes locally against vanilla nplay+pycbsdk (3 s), so a hang here
is not reproducible from the SDK alone — likely environmental
(callback interactions, threading, or an upstream call sequence).
Keeping the test in tree so any future regression in the bulk
setter's sync barriers is caught.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the post-sync + count-rescan that #181 added to the four bulk
by-type setters.  The post-sync was structurally tied to the count
return — re-scanning local chaninfo for "post-config matches" only
makes sense if all CHANREPs have arrived first.  Without the count,
the post-sync has no purpose, and a second sync per call is exactly
the kind of response-bound wait we want to minimize: a dropped
SYSREPRUNLEV would stall the call for 5 seconds, and back-to-back
config calls double-pay the round-trip.

What stays:
- Top sync (#177 contract) — ensures local cache is fresh before we
  seed CHANSET* packets from it; required for correctness.
- chans=None | int | list[int] (the #181 follow-up) — orthogonal to
  the count, retains the disjoint-set-of-channels API.
- "Always send to every in-scope channel" idempotency — also
  orthogonal.

C++: bulk setters return Result<void> instead of Result<uint32_t>.
applyBulkSetter helper drops its predicate parameter.
countChannelsMatching is removed (it was only used here).

C-API: drop the `uint32_t* out_n_configured` out-param from the four
cbsdk_session_set_* entrypoints.

pycbsdk: methods return None; drop the int-return type and the
out_n ffi.new buffer.

Tests: TestNConfigured class removed entirely.  TestBulkSettersChanList
tests rewritten to verify state via get_group_channels (with explicit
sync()) instead of asserting on the dropped count.  CApi tests
simplified back to smoke tests after the count assertion was removed.
The ezmsg-blackrock repro test (test_disable_others_30k_no_preamble)
calls sync() explicitly before reading state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cboulay cboulay merged commit 1991c93 into master Apr 29, 2026
16 checks passed
@cboulay cboulay deleted the config-setter-ergonomics branch April 29, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add async-awaitables for running state set_ac_input_coupling should auto-sync() internally

1 participant