Skip to content

Allow enabling/disabling device sources and known devices#92

Draft
heavyrubberslave wants to merge 21 commits into
mainfrom
feat/device-source-enable-disable
Draft

Allow enabling/disabling device sources and known devices#92
heavyrubberslave wants to merge 21 commits into
mainfrom
feat/device-source-enable-disable

Conversation

@heavyrubberslave

@heavyrubberslave heavyrubberslave commented Jul 5, 2026

Copy link
Copy Markdown
Member

Adds an optional enabled flag (defaults to true) to both deviceSources and knownDevices entries in the settings JSON.

  • DeviceSource / KnownDevice gain an isEnabled() accessor.
  • DeviceProviderManager can now hot-reload: it starts/stops individual device source providers as they are enabled/disabled/added/removed, instead of only loading them once at startup. Reload calls are serialized internally to avoid races when settings change in quick succession (e.g. disable immediately followed by re-enable).
  • SettingsManager's 'settingsChanged' event now triggers DeviceProviderManager.reload(), so editing settings (e.g. via PUT /settings) dynamically starts/stops device sources.
  • SerialDeviceProvider/BleDeviceProvider/ButtplugIoWebsocketDeviceProvider skip connecting to newly detected devices that are known but disabled, and close already-connected devices that become disabled. Devices that were skipped while disabled are retried once they become enabled again (via the new DeviceProvider.onSettingsChanged hook).
  • VirtualDeviceProvider's periodic discovery loop now also honors the enabled flag of virtual known devices.

Closes #70

Summary by CodeRabbit

  • New Features
    • Added enabled controls for known devices and device sources.
    • Device and source changes now take effect dynamically without restarting the application.
    • Added improved support for discovering and managing Buttplug.io devices.
    • Virtual devices now synchronize automatically with current settings.
  • Bug Fixes
    • Improved device cleanup and reconnection when hardware disconnects or settings change.
    • Actuator-only devices no longer refresh unnecessarily.
  • Tests
    • Expanded coverage for device lifecycle, settings changes, and shared hardware connections.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 74067e26-dd91-4926-85eb-ba2186b8e807

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Device and device-source enablement is added to settings. Device detection, provider lifecycle, observer ownership, and application reload flows are generalized around detection metadata and AnyDevice contracts, with unit and integration coverage for dynamic enablement and serialized provider operations.

Changes

Device enablement and provider lifecycle

Layer / File(s) Summary
Settings and device contracts
src/settings/*, src/device/device.ts, src/device/*Updater*, src/repository/*, src/entity/deviceList.ts
Settings support optional enabled flags, while device-facing APIs use generalized AnyDevice and attribute-value contracts.
DeviceManager enablement flow
src/device/deviceManager.ts, tests/unit/device/deviceManager.spec.ts, tests/integration/deviceEvents.spec.ts
Disabled detections and connections are deferred, pending devices are retried after settings changes, and connected devices are reconciled when enablement changes.
Detected-device provider pipeline
src/device/provider/*, src/device/protocol/*, src/device/transport/*, tests/unit/device/transport/*
Providers centralize detection acquisition, device creation, cleanup, observer lifecycle, and reference-counted BLE/serial observer shutdown.
Provider reload and application wiring
src/app.ts, src/serviceProvider/deviceServiceProvider.ts, src/device/provider/deviceProviderManager.ts, tests/unit/device/provider/deviceProviderManager.spec.ts
Provider loading is serialized, settings changes reload sources, and shutdown cascades through provider stopping.
Buttplug integration updates
src/device/protocol/buttplugIo/*, tests/integration/devices/buttplugIoDevice.spec.ts
Buttplug discovery uses the detected-device pipeline, device IDs are centralized, removal handlers are cleaned up, and sensor-less refresh behavior is covered.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested labels: minor

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the main change: making device sources and known devices enable/disable-able.
Linked Issues check ✅ Passed The PR adds enabled defaults and config schema support, reloads providers on settings changes, and disables/enables devices as requested in #70.
Out of Scope Changes check ✅ Passed The changes are broadly aligned with #70, and the supporting refactors appear necessary for the new device and provider lifecycle.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/device-source-enable-disable

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread src/settings/knownDevice.ts Outdated
Comment thread src/device/provider/deviceProviderManager.ts Outdated
Comment thread src/device/provider/deviceProviderManager.ts Outdated
Adds an optional `enabled` flag (defaults to true) to both `deviceSources`
and `knownDevices` entries in the settings JSON.

- DeviceSource / KnownDevice gain an `isEnabled()` accessor.
- DeviceProviderManager can now hot-reload: it starts/stops individual
  device source providers as they are enabled/disabled/added/removed,
  instead of only loading them once at startup. Reload calls are
  serialized internally to avoid races when settings change in quick
  succession (e.g. disable immediately followed by re-enable).
- SettingsManager's 'settingsChanged' event now triggers
  DeviceProviderManager.reload(), so editing settings (e.g. via
  PUT /settings) dynamically starts/stops device sources.
- SerialDeviceProvider/BleDeviceProvider/ButtplugIoWebsocketDeviceProvider
  skip connecting to newly detected devices that are known but disabled,
  and close already-connected devices that become disabled. Devices that
  were skipped while disabled are retried once they become enabled again
  (via the new DeviceProvider.onSettingsChanged hook).
- VirtualDeviceProvider's periodic discovery loop now also honors the
  enabled flag of virtual known devices.

Closes #70
@heavyrubberslave
heavyrubberslave force-pushed the feat/device-source-enable-disable branch from 0ba9ffc to 2586e6f Compare July 16, 2026 20:47
DeviceManager is now the single authoritative place deciding whether a
known device may connect. It owns the enabled check, the pending-retry
map for disabled devices, and closing devices that get disabled at
runtime, exposed via isDeviceEnabled(), addDevice(deviceInfo, device)
and onSettingsChanged().

Providers no longer duplicate this: BLE, serial and buttplug.io all
route detection through announceDetectedDevice()/deviceDetected and just
react to addDevice()'s result. The per-provider onSettingsChanged() hook
is gone.

VirtualDeviceProvider now reacts to settings changes instead of polling,
dropping the scanIntervalMs config entirely.
revokeDetectedDevice() now also drops the device from the pending-retry
map, so a disabled device that physically disappears is not resurrected
when its known device is later re-enabled. Buttplug's removeButtplugIoDevice
now revokes not-yet-connected devices instead of just logging a warning.

VirtualDeviceProvider's detection listener lifecycle now matches the
serial provider (registered in init(), removed in stop()), so the stopped
flag is only needed for the genuinely async device-creation window.
Extract the shared acquire -> create -> addDevice -> release flow that
BLE, serial and buttplug.io all implemented near-identically into a new
DetectedDeviceProvider base. Subclasses now only implement
supportsDeviceInfo() and createDevice(), plus an optional onConnectFailed()
hook, and get the connected-device bookkeeping and stop() for free.

Buttplug.io keys its connected devices by their final DeviceId (via the
factory's computeDeviceId) instead of the Intiface index, so it no longer
needs a separate index map.

Align ButtplugIoDevice.setAttribute with the single-type-parameter shape
used by EStim2bDevice so the concrete device satisfies the base class'
Device constraint.
…ries

Add AnyDevice = Omit<Device, 'setAttribute'> & wide setAttribute. Concrete
devices are not assignable to Device<...> because their narrowing setAttribute
override trips method parameter bivariance; erasing that one method and
re-adding a wide, string-keyed version restores assignability while still
requiring the full remaining Device surface (so non-devices are rejected).

Use AnyDevice everywhere a concrete device type is irrelevant - the device
manager, repository, updater and automation runtime - which lets addDevice
drop its <TAttrs, TNotifications, TConfig> generics and collapses the device
providers from five type parameters to a single D extends AnyDevice. The
concrete devices keep their strict setAttribute for their own call sites.

Removes the now-unused Infer* helper families.
The value parameter and return of a device's setAttribute are always an
AttributeValue, so use that in the erased AnyDevice signature rather than
unknown. Concrete devices remain assignable and callers at the AnyDevice
boundaries (automation runtime, generic updater) now get a real value type.
The AnyDevice erasure made all device families structurally identical
(their transport members are private/protected, so Omit strips their
nominal identity), which let a BleDeviceProvider be parameterized with a
non-BLE device.

Expose a distinguishing public member per family - getPeripheral() on
BleDevice, getTransport() on PeripheralDevice - and define AnyBleDevice /
AnyPeripheralDevice erasing setAttribute like AnyDevice but retaining that
member. Constrain the two providers on those, so the wrong device family
is rejected again while keeping the single-generic, bivariance-free flow.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (1)
src/device/protocol/virtual/virtualDeviceProvider.ts (1)

108-119: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Retain failed virtual-device attempts across discovery cycles.

Releasing the acquisition queue after factory failure lets every later settings event announce and initialize the same broken device again. Keep failed attempts tracked until their configuration is removed or deliberately reset.

Based on learnings, internal virtual-provider maps should track attempted devices, including failed initialization attempts, to prevent repeated work on later discovery cycles.

Also applies to: 164-166

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/device/protocol/virtual/virtualDeviceProvider.ts` around lines 108 - 119,
Update the virtual-device tracking used by the discovery loop around
virtualDevices and connectedDevices so every initialization attempt, including
factory failures, remains marked as attempted. Prevent announceDetectedDevice
from re-announcing attempted devices on later settings or discovery cycles,
while removing the marker only when the device configuration is removed or
explicitly reset.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/device/deviceManager.ts`:
- Around line 197-199: Update src/device/deviceManager.ts lines 197-199 in
registerPendingRetry to retain both the detected device ID and
device.getDeviceId(), and make onSettingsChanged check enablement using the
canonical ID. Update tests/unit/device/deviceManager.spec.ts lines 502-528 to
use distinct detected and canonical IDs and verify no retry occurs until the
canonical device is enabled.

In `@src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts`:
- Around line 70-78: Update ButtplugIoWebsocketDeviceProvider.stop() to
disconnect the Buttplug websocket client and remove its connection listeners
before or during shutdown, preventing handleLostConnection() from reinitializing
the provider after stop. Preserve the existing interval cleanup and super.stop()
device cleanup.

In `@src/device/provider/detectedDeviceProvider.ts`:
- Around line 79-88: Update the connection-failure handling around
onConnectFailed so transport cleanup completes before another provider can claim
the device. Move releaseDetectedDevice into a finally block that runs after
onConnectFailed for both the caught-error and undefined-device paths, preserving
the existing failure notification and return behavior.
- Around line 44-50: Update DetectedDeviceProvider.stop and the device-detection
handler to coordinate shutdown with in-flight work: set a stopping flag before
removing the listener, and after each awaited acquisition or device-creation
step, check that flag and close/release the resulting resource instead of
registering it. Ensure stop closes all currently connected devices and clears
the map, preventing any handler that began before stop from leaving a device
active afterward.
- Around line 96-103: Cancel in-flight device creation before completed devices
are registered. In src/device/provider/detectedDeviceProvider.ts:96-103, require
an active acquisition claim before registering through addDevice and
connectedDevices. In
src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts:169-176,
revoke or invalidate pending creation when the device is removed. In
src/device/protocol/virtual/virtualDeviceProvider.ts:101-119, track configured
devices being created and revoke them on removal; in
src/device/protocol/virtual/virtualDeviceProvider.ts:142-161, revalidate current
settings before adding the completed virtual device.

In `@src/device/provider/deviceProviderManager.ts`:
- Around line 74-80: Update the provider lifecycle methods around
doInitProviders and doStopProviders so provider-map mutations occur only after
successful lifecycle operations: add providers only after init() succeeds and
remove any failed initialization entry so later reloads retry it; in stop
handling, delete each provider only after its stop() succeeds, retain
failed-to-stop entries, and propagate the stop failure instead of clearing the
entire map.

In `@src/settings/serializedTypes.ts`:
- Around line 10-17: Make the enabled property optional in the serialized
payload type declarations, including SerializedDeviceSource and the nearby
serialized type with the same field. Keep the property’s boolean type while
allowing legacy serialized objects to omit it, so consumers handle missing
values explicitly.

In `@tests/unit/device/deviceManager.spec.ts`:
- Around line 502-528: Update the test around DeviceManager.addDevice and
onSettingsChanged to use different detected and canonical device IDs: keep
deviceInfo.id as the detected ID while assigning the TestDevice a distinct
canonical ID. Verify that changing unrelated settings does not re-announce or
retry the pending device while the canonical known device remains disabled, then
enable that canonical device and retain the expected deviceDetected assertion.

In `@tests/unit/device/provider/deviceProviderManager.spec.ts`:
- Around line 154-168: Update the test around
DeviceProviderManager.stopProviders to reload the existing manager after
stopping it, rather than creating manager2. Use a fresh provider factory setup
as needed, then assert the subsequent reload initializes a new provider, proving
stopProviders clears the original manager’s internal state.

---

Nitpick comments:
In `@src/device/protocol/virtual/virtualDeviceProvider.ts`:
- Around line 108-119: Update the virtual-device tracking used by the discovery
loop around virtualDevices and connectedDevices so every initialization attempt,
including factory failures, remains marked as attempted. Prevent
announceDetectedDevice from re-announcing attempted devices on later settings or
discovery cycles, while removing the marker only when the device configuration
is removed or explicitly reset.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3a6df50d-3922-4daf-99ed-bd10e65aa9db

📥 Commits

Reviewing files that changed from the base of the PR and between 5cfa364 and d25ea81.

📒 Files selected for processing (35)
  • src/app.ts
  • src/automation/scriptRuntime.ts
  • src/device/bleDevice.ts
  • src/device/device.ts
  • src/device/deviceManager.ts
  • src/device/genericDeviceUpdater.ts
  • src/device/peripheralDevice.ts
  • src/device/protocol/airotic/airoticDeviceProvider.ts
  • src/device/protocol/buttplugIo/buttplugIoDevice.ts
  • src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProviderFactory.ts
  • src/device/provider/bleDeviceProvider.ts
  • src/device/provider/detectedDeviceProvider.ts
  • src/device/provider/deviceProviderManager.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/device/updater/abstractDeviceUpdater.ts
  • src/device/updater/bufferedDeviceUpdater.ts
  • src/device/updater/deviceUpdaterInterface.ts
  • src/entity/deviceList.ts
  • src/repository/connectedDeviceRepository.ts
  • src/repository/deviceRepositoryInterface.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • src/settings/deviceSource.ts
  • src/settings/knownDevice.ts
  • src/settings/serializedTypes.ts
  • src/settings/settings.ts
  • src/settings/settingsManager.ts
  • tests/integration/deviceEvents.spec.ts
  • tests/integration/helpers/appHelper.ts
  • tests/unit/device/deviceManager.spec.ts
  • tests/unit/device/provider/deviceProviderManager.spec.ts
  • tests/unit/settings/deviceSource.spec.ts
  • tests/unit/settings/knownDevice.spec.ts

Comment thread src/device/deviceManager.ts Outdated
Comment thread src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts Outdated
Comment thread src/device/provider/detectedDeviceProvider.ts Outdated
Comment thread src/device/provider/detectedDeviceProvider.ts Outdated
Comment thread src/device/provider/detectedDeviceProvider.ts Outdated
Comment thread src/device/provider/deviceProviderManager.ts Outdated
Comment thread src/settings/serializedTypes.ts
Comment thread tests/unit/device/deviceManager.spec.ts Outdated
Comment thread tests/unit/device/provider/deviceProviderManager.spec.ts
Now that AnyDevice.getAttribute()/setAttribute expose AttributeValue, the
device-value locals in the deviceEvents integration test and the StubDevice
setAttribute recorder can use AttributeValue instead of unknown.
- deviceManager: gate a pending-retry on the device's canonical id (known
  only after connecting) instead of the preliminary detection id, so a
  disabled device isn't retried on every unrelated settings change.
- buttplug provider: stop() now removes the client's listeners and
  disconnects, so an in-flight disconnect can't reinitialize the provider
  after shutdown.
- DetectedDeviceProvider: guard against a detection that completes after
  stop(), and run transport cleanup (onConnectFailed) before releasing the
  acquire claim so the next provider can't overlap the previous transport.
- deviceProviderManager: only mutate the provider map after a lifecycle
  operation succeeds - keep failed-to-stop providers, drop half-started
  ones - without aborting the rest of the reload.
- KnownDevice/DeviceSource: serialize the normalized 'enabled' getter
  (toPlainOnly) while still reading the raw field (toClassOnly), so the
  serialized payload always carries a real boolean for legacy entries.
- Tests: exercise distinct detected/canonical ids and reuse the same
  manager to prove stopProviders() clears its state.
…gh it

Now that VirtualDeviceProvider also discovers via announceDetectedDevice(),
every provider goes through the detection pipeline, leaving DeviceProvider
with a single subclass. Fold the two base classes into one:

- VirtualDeviceProvider extends the common base and drops its duplicated
  detection flow (listener, handleDeviceDetection, connected-device map,
  stopped guard) - keeping only its settings-driven discovery.
- DetectedDeviceProvider is removed; DeviceProvider now owns the pipeline.
- No generic defaults anywhere in the provider hierarchy, so every subclass
  must deliberately declare the DeviceInfo/Device it supports.
- Add AnyDeviceProvider = DeviceProvider<DeviceInfo, AnyDevice> for the
  type-agnostic boundaries (manager + factories).
- Delete the unused TestDeviceProvider test double.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/device/protocol/virtual/virtualDeviceProvider.ts`:
- Around line 99-104: Update the virtual device announcement flow around the
virtualDevices iteration and initialization tracking to record every attempted
device ID, including failed creations, before announcing or retrying it. Prevent
unrelated settings changes from re-announcing already attempted IDs, while
preserving announcements for newly configured devices. Remove an ID from the
attempted set only when its configuration entry is removed.
- Around line 93-96: Update the reconciliation loop over getConnectedDevices in
the virtual device provider so each device.close failure is caught and logged,
allowing subsequent removed devices to be processed and newly configured device
discovery to continue. Keep the existing close behavior for successful devices
and preserve the loop’s device filtering.

In `@src/device/provider/deviceProvider.ts`:
- Around line 143-145: Ensure detection claims are always released: in
src/device/provider/deviceProvider.ts lines 143-145, update abortDetection() to
run releaseDetectedDevice() in a finally block around onConnectFailed(); in
src/device/provider/deviceProvider.ts lines 114-118, invoke abortDetection()
from a finally block around device.close() so cleanup rejection cannot bypass
release.
- Around line 65-73: Update DeviceProvider.stop so a device.close rejection does
not leave a retained provider permanently inactive: make the stopped state and
deviceDetectedListener detachment transactional or roll them back when cleanup
fails, while preserving cleanup of connected devices. Ensure
DeviceProviderManager can subsequently re-enable the source by either making
stop recoverable or removing terminal providers after failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 90dfb15b-4428-4b9b-961e-f156506718dd

📥 Commits

Reviewing files that changed from the base of the PR and between c267f46 and 0f9d1d3.

📒 Files selected for processing (10)
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/provider/bleDeviceProvider.ts
  • src/device/provider/deviceProvider.ts
  • src/device/provider/deviceProviderFactory.ts
  • src/device/provider/deviceProviderManager.ts
  • src/device/provider/genericDeviceProviderFactory.ts
  • src/device/provider/serialDeviceProvider.ts
  • tests/unit/device/provider/deviceProviderManager.spec.ts
  • tests/unit/device/testDeviceProvider.ts
💤 Files with no reviewable changes (1)
  • tests/unit/device/testDeviceProvider.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/unit/device/provider/deviceProviderManager.spec.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts
  • src/device/provider/deviceProviderManager.ts

Comment on lines +93 to 96
for (const device of [...this.getConnectedDevices()]) {
if (!virtualDevices.has(device.getDeviceId)) {
await device.close();
this.attemptedDevices.delete(knowDevice.id);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not abort the entire reconciliation when one device fails to close.

A rejected device.close() prevents remaining removed devices from closing and skips discovery of newly configured devices. Catch and log each close failure, then continue processing.

Proposed fix
 for (const device of [...this.getConnectedDevices()]) {
     if (!virtualDevices.has(device.getDeviceId)) {
-        await device.close();
+        try {
+            await device.close();
+        } catch (e: unknown) {
+            logError(this.logger, `Failed to close removed virtual device '${device.getDeviceId}'`, e);
+        }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const device of [...this.getConnectedDevices()]) {
if (!virtualDevices.has(device.getDeviceId)) {
await device.close();
this.attemptedDevices.delete(knowDevice.id);
return;
}
for (const device of [...this.getConnectedDevices()]) {
if (!virtualDevices.has(device.getDeviceId)) {
try {
await device.close();
} catch (e: unknown) {
logError(this.logger, `Failed to close removed virtual device '${device.getDeviceId}'`, e);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/device/protocol/virtual/virtualDeviceProvider.ts` around lines 93 - 96,
Update the reconciliation loop over getConnectedDevices in the virtual device
provider so each device.close failure is caught and logged, allowing subsequent
removed devices to be processed and newly configured device discovery to
continue. Keep the existing close behavior for successful devices and preserve
the loop’s device filtering.

Comment on lines +99 to +104
// Announce all currently configured devices - the device manager takes care of skipping
// disabled ones (and re-announcing them once re-enabled) as well as ones already connected.
for (const knownDevice of virtualDevices.values()) {
const deviceInfo: VirtualDeviceInfo = { type: 'virtual', id: knownDevice.id, knownDevice };

try {
await device.close();
} finally {
this.connectedDevices.delete(deviceId);
this.attemptedDevices.delete(deviceId);
this.deviceManager.announceDetectedDevice(deviceInfo);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Retain attempted virtual devices after failed initialization.

When creation fails, the claim is released; every unrelated settings change then announces the same device and retries initialization. Track attempted IDs, including failures, and clear them only when the configuration entry is removed.

Based on learnings, virtual providers must retain both successful and failed initialization attempts to avoid repeated work.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/device/protocol/virtual/virtualDeviceProvider.ts` around lines 99 - 104,
Update the virtual device announcement flow around the virtualDevices iteration
and initialization tracking to record every attempted device ID, including
failed creations, before announcing or retrying it. Prevent unrelated settings
changes from re-announcing already attempted IDs, while preserving announcements
for newly configured devices. Remove an ID from the attempted set only when its
configuration entry is removed.

Source: Learnings

Comment on lines 65 to +73
public async stop(): Promise<void> {
this.stopped = true;

this.deviceManager.off(DeviceManagerEvent.deviceDetected, this.deviceDetectedListener);

for (const device of this.connectedDevices.values()) {
await device.close();
}
this.connectedDevices.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Keep failed shutdowns recoverable.

Line 66 marks the provider stopped and Line 68 detaches detection before a device close can reject. DeviceProviderManager retains providers whose stop fails, so re-enabling that source finds a retained—but permanently inactive—provider and does not create a replacement.

Make shutdown transactional/recoverable, or align the manager contract so terminal providers are removed even when device cleanup reports errors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/device/provider/deviceProvider.ts` around lines 65 - 73, Update
DeviceProvider.stop so a device.close rejection does not leave a retained
provider permanently inactive: make the stopped state and deviceDetectedListener
detachment transactional or roll them back when cleanup fails, while preserving
cleanup of connected devices. Ensure DeviceProviderManager can subsequently
re-enable the source by either making stop recoverable or removing terminal
providers after failure.

Comment thread src/device/provider/deviceProvider.ts Outdated
…mers

The provider previously ran two independent guessed timers: kick off a scan
every 60s, and blindly stop it 30s later. Both numbers were arbitrary and
ignored what the server was actually doing.

A buttplug.io scan is a bounded operation - the server ends it on its own and
reports back via the 'scanningfinished' event. Use that event to drive the
loop: start a scan on connect, and when the server reports the scan finished,
schedule the next one after a short cooldown. This removes the guessed scan
duration entirely and syncs the cadence to real server state instead of a
fixed clock.

- Replace AUTO_SCAN_INTERVAL_MS/SCAN_DURATION_MS with a single
  RESCAN_COOLDOWN_MS, and name the connect-retry interval too.
- Cooldown is injectable (defaulting to the constant) so tests can exercise
  the re-scan loop without waiting the full 30s.
- Simulator tracks StartScanning requests and exposes waitForScanCount();
  the buttplug lifecycle app now runs with autoScan enabled so the loop is
  actually covered.
Keep the existing StartScanning/StopScanning duty-cycle (it matches how the
desktop websocket server actually behaves - it scans until told to stop), but
pull the three magic numbers out into named constants so their intent is
clear: CONNECT_RETRY_INTERVAL_MS, AUTO_SCAN_INTERVAL_MS and SCAN_DURATION_MS.

The scan interval and duration are also injectable via the constructor
(defaulting to those constants) and threaded through the provider factory
config, so tests can shrink them without waiting the full 60s/30s.

Adds a dedicated auto-scanning test that constructs the provider directly
against the simulator - no full app, so it neither touches the shared BLE
teardown nor lets its repeated scanning interfere with the lifecycle suite.
The simulator now records StartScanning/StopScanning counts and no longer
echoes ScanningFinished, modelling the desktop server that scans until
stopped.
The dedicated auto-scan test surfaced a pre-existing flake in the 'device
refreshes' test that is worth investigating on its own, so remove the new
test (and the injectable interval/duration params + simulator scan-tracking
that only it used) for now. The named scan-timing constants stay.

The refresh flake will be looked at in a separate PR.
…anonical device ids

The detection-time device id and the final/canonical device id (device.getDeviceId,
which can differ for protocols that only learn their real id post-handshake, e.g.
zc95 firmware >=2.0) were both loosely called 'id', with only the retry-bookkeeping
field carrying a distinguishing name (enablementId) - backwards, since that field
held the more authoritative of the two. Rename for clarity:

- DeviceInfo -> DeviceDetectionInfo (and its BleDeviceInfo/SerialDeviceInfo/
  ButtplugIoDeviceInfo/VirtualDeviceInfo variants -> ...DeviceDetectionInfo),
  matching the vocabulary already used elsewhere (announceDetectedDevice,
  acquireDetectedDevice, detectedDeviceAcquireQueue, deviceDetected event).
- DeviceDetectionInfo.id -> detectionId, naming it for what it is: the id assigned
  at detection time, before canonical identity may be resolved.
- enablementId -> canonicalId, naming it for what it is: the device's final,
  authoritative id.

Pure identifier rename, no logic changes. The unrelated DeviceInfo type in
slvCtrlProtocol.ts (wire-protocol handshake payload) is untouched.
…lves on removal

BLE/serial observers were previously started unconditionally at app boot
regardless of whether any matching device source was even configured,
wasting BLE radio/USB polling resources. They're now started/stopped by the
individual providers that need them (BleDeviceProvider/SerialDeviceProvider),
reference-counted so multiple providers sharing the same observer (e.g.
zc95Serial + estim2bSerial + slvCtrlPlusSerial all sharing one
SerialPortObserver) don't double-start or stop it out from under each other.
Fixed a latent bug this surfaced along the way: AiroticDeviceProvider.init()
overrode the base method without calling super.init(), which would have
silently skipped starting the BLE observer entirely.

app.ts's loadDeviceProviders()/shutdown() no longer touch the observers
directly - that's now fully driven by DeviceProviderManager calling each
provider's init()/stop().

Separately, ButtplugIoDevice now closes itself when the buttplug.io server
reports it removed (while the overall connection stays up), listening
directly to its own ButtplugClientDevice's 'deviceremoved' event - mirroring
how BleDevice/PeripheralDevice detect their own physical disconnect. The
provider's connection-lost handling still closes every connected device in
bulk, since that's the one case an individual device could never notice on
its own (the buttplug protocol doesn't emit per-device removal messages once
the connection itself is gone).

Also: ButtplugIoDevice.getRefreshInterval now returns undefined for
actuator-only devices instead of unconditionally polling sensors that don't
exist - sensor values can only be polled (no push/subscribe API exists in
the installed buttplug client), so there's nothing to refresh on a device
with no sensors.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/device/deviceManager.ts (1)

204-227: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

onSettingsChanged() isn't serialized against overlapping calls.

DeviceProviderManager explicitly serializes loadFromSettings()/stopProviders() because settings can change in rapid succession (e.g. a device source being disabled and immediately re-enabled), overlapping calls need to be serialized to avoid racing on that map — but DeviceManager.onSettingsChanged() (invoked without awaiting, fire-and-forget, from app.ts's settings-changed listener) has no equivalent guard despite mutating the same kind of shared state (connectedDevices, pendingDisabledDevices) across an await device.close() boundary. Two settings changes in quick succession could interleave: the first call's loop is mid-close() on a device (still present in connectedDevices since removal happens via the deviceDisconnected listener), and the second call's loop iterates and calls close() on the same device again concurrently.

Consider mirroring DeviceProviderManager's enqueueOperation pattern here to serialize onSettingsChanged() invocations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/device/deviceManager.ts` around lines 204 - 227, Serialize overlapping
onSettingsChanged() invocations using the existing DeviceProviderManager
enqueueOperation pattern or an equivalent operation queue. Ensure the full
connectedDevices and pendingDisabledDevices processing, including awaited
device.close() calls, runs sequentially so rapid settings changes cannot close
the same device concurrently.
src/device/transport/bleObserver.ts (1)

113-118: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Prevent orphaned BLE scans by checking activeUsers after awaiting power-on.

There is a TOCTOU (time-of-check to time-of-use) race condition here. If a provider rapidly calls init() and then stop() (e.g. during a rapid settings reload):

  1. init() triggers observe(), which awaits noble.waitForPoweredOnAsync().
  2. stop() executes, decrements activeUsers to 0, and attempts to stop the scan. Because isScanning is still false, it skips stopping the scan.
  3. waitForPoweredOnAsync() resolves. The method proceeds to set isScanning = true and starts the scan.

This results in the BLE adapter scanning indefinitely while activeUsers is 0, ignoring subsequent stop() calls. Re-verifying activeUsers after the await fixes the race.

🔒️ Proposed fix
         try {
             // Wait for Adapter poweredOn state
             await noble.waitForPoweredOnAsync();
 
+            if (this.activeUsers === 0) {
+                return;
+            }
+
             this.isScanning = true;
             await noble.startScanningAsync([BleObserver.UART_SERVICE_UUID], true);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/device/transport/bleObserver.ts` around lines 113 - 118, In the observe()
startup flow, re-check activeUsers immediately after
noble.waitForPoweredOnAsync() returns and before setting isScanning or calling
noble.startScanningAsync. Abort the startup when no active users remain,
preserving stop() behavior and preventing a scan from starting after the final
consumer has stopped.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/device/protocol/buttplugIo/buttplugIoDevice.ts`:
- Line 48: Replace the console.error callback in ButtplugIoDevice’s
deviceRemovedHandler with structured logging through an injected Logger. Update
ButtplugIoDeviceFactory to provide the logger, retain the existing async close
flow, and log failures with the “Error closing device” context via the project’s
logError helper.

---

Outside diff comments:
In `@src/device/deviceManager.ts`:
- Around line 204-227: Serialize overlapping onSettingsChanged() invocations
using the existing DeviceProviderManager enqueueOperation pattern or an
equivalent operation queue. Ensure the full connectedDevices and
pendingDisabledDevices processing, including awaited device.close() calls, runs
sequentially so rapid settings changes cannot close the same device
concurrently.

In `@src/device/transport/bleObserver.ts`:
- Around line 113-118: In the observe() startup flow, re-check activeUsers
immediately after noble.waitForPoweredOnAsync() returns and before setting
isScanning or calling noble.startScanningAsync. Abort the startup when no active
users remain, preserving stop() behavior and preventing a scan from starting
after the final consumer has stopped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0e91598b-94ab-4553-9763-b0601237f41b

📥 Commits

Reviewing files that changed from the base of the PR and between 0f9d1d3 and faf22f7.

📒 Files selected for processing (29)
  • src/app.ts
  • src/device/bleDevice.ts
  • src/device/device.ts
  • src/device/deviceManager.ts
  • src/device/peripheralDevice.ts
  • src/device/protocol/airotic/airoticDeviceProvider.ts
  • src/device/protocol/buttplugIo/buttplugIoDevice.ts
  • src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts
  • src/device/protocol/buttplugIo/buttplugIoDeviceProvider.ts
  • src/device/protocol/buttplugIo/buttplugIoDeviceProviderFactory.ts
  • src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts
  • src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/zc95/zc95SerialDeviceProvider.ts
  • src/device/provider/bleDeviceProvider.ts
  • src/device/provider/deviceProvider.ts
  • src/device/provider/deviceProviderManager.ts
  • src/device/provider/genericDeviceProviderFactory.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/device/transport/bleObserver.ts
  • src/device/transport/serialPortObserver.ts
  • src/serviceMap.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • tests/integration/devices/buttplugIoDevice.spec.ts
  • tests/unit/device/deviceManager.spec.ts
  • tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts
  • tests/unit/device/provider/deviceProviderManager.spec.ts
  • tests/unit/device/transport/bleObserver.spec.ts
  • tests/unit/device/transport/serialPortObserver.spec.ts
💤 Files with no reviewable changes (2)
  • src/device/bleDevice.ts
  • src/device/peripheralDevice.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/device/provider/genericDeviceProviderFactory.ts
  • src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts
  • src/device/device.ts
  • tests/unit/device/provider/deviceProviderManager.spec.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/provider/deviceProvider.ts
  • tests/unit/device/deviceManager.spec.ts

Comment thread src/device/protocol/buttplugIo/buttplugIoDevice.ts
…ling stop()

usb is a real, module-wide EventTarget - without mocking it, every
addEventListener() call made by an observer under test was still registered
against the real usb object when the next test ran, piling up across the
whole file's run and eventually risking Node's MaxListenersExceededWarning.

The afterEach hook was working around this by calling stop() twice on every
created observer, on the assumption that no test's reference count would
ever exceed 2 - a fragile magic number that would silently start leaking
listeners again if a future test called start() a third time.

Mock usb the same way bleObserver.spec.ts already mocks @stoprocent/noble:
no real listeners are ever registered, so there's nothing to leak and
nothing to work around.
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 option to disable/enable devices and device sources in config

1 participant