Skip to content

Allow enabling/disabling device sources and known devices - #92

Merged
heavyrubberslave merged 39 commits into
mainfrom
feat/device-source-enable-disable
Jul 25, 2026
Merged

heavyrubberslave merged 39 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/disabled controls for known devices and device sources with dynamic synchronization (disable disconnects; re-enable reconnects, including virtual devices).
    • Virtual devices now sync from settings directly (no polling interval).
    • Buttplug IO refresh interval is unset when no sensor reads are configured.
  • Bug Fixes
    • More reliable device/provider lifecycle during settings reloads and application shutdown.
    • Improved automation runtime startup/shutdown robustness; missing automation logs are treated as empty.
    • Settings updates now return clearer 400 errors on invalid input.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

This PR adds enable/disable support for devices and sources, generalizes device and provider contracts, centralizes detection and lifecycle handling, serializes provider reloads, moves settings validation into serialization, and introduces an isolated automation VM abstraction.

Changes

Device enablement and contracts

Layer / File(s) Summary
Settings and generalized device contracts
src/settings/*, src/device/*, src/repository/*, src/entity/deviceList.ts
Adds persisted enabled fields and widens device-facing contracts to AnyDevice.
DeviceManager enablement flow
src/device/deviceManager.ts, tests/unit/device/deviceManager.spec.ts, tests/integration/deviceEvents.spec.ts
Suppresses disabled detections, tracks retries, closes disabled devices, and re-announces devices after re-enablement.

Provider lifecycle and orchestration

Layer / File(s) Summary
Generic providers and observers
src/device/provider/*, src/device/transport/*, src/device/protocol/*
Centralizes detection, acquisition, cleanup, provider lifecycle, and shared BLE/serial observer management.
Provider reconciliation and application wiring
src/device/provider/deviceProviderManager.ts, src/app.ts, src/serviceProvider/*, src/serviceMap.ts
Serializes provider reloads, starts and stops providers from settings, and updates dependency injection and shutdown wiring.

Automation and settings validation

Layer / File(s) Summary
Isolated automation runtime
src/automation/*, src/serviceProvider/automationServiceProvider.ts, tests/unit/automation/*
Adds ScriptVm and ScriptVmFactory, and routes runtime loading, event dispatch, cancellation, logging, and disposal through them.
Schema-aware settings transformation
src/serialization/*, src/schemaValidation/*, src/controller/settings/*, src/serviceProvider/settingsServiceProvider.ts, src/serviceProvider/controllerServiceProvider.ts
Moves validation and default hydration into PlainToClassSerializer and handles SchemaValidationError in settings flows.

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

Sequence Diagram(s)

sequenceDiagram
  participant SettingsManager
  participant App
  participant DeviceProviderManager
  participant DeviceManager
  participant DeviceProvider

  SettingsManager->>App: settings changed
  App->>DeviceProviderManager: loadFromSettings(settings)
  DeviceProviderManager->>DeviceProvider: start or stop provider
  App->>DeviceManager: onSettingsChanged()
  DeviceProvider->>DeviceManager: announceDetectedDevice(detectionInfo)
  DeviceManager->>DeviceProvider: acquire and add device
Loading

Possibly related PRs

Suggested labels: patch

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also includes unrelated automation, serialization, and schema-validation refactors that go beyond enabling/disabling devices. Move the unrelated refactors into separate PRs, or document why each is required for the enable/disable feature.
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 (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding enable/disable support for device sources and known devices.
Linked Issues check ✅ Passed The PR implements optional enabled flags, skips disabled sources/devices, and reloads providers on settings changes as requested.
✨ 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 Outdated
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 thread src/device/protocol/virtual/virtualDeviceProvider.ts
Comment thread src/device/protocol/virtual/virtualDeviceProvider.ts
Comment thread src/device/provider/deviceProvider.ts
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 Outdated
…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.
…protocols

Every other protocol factory (airotic, zc95, estim2b, slvCtrlPlus) takes the
already-computed deviceId as a plain parameter and only ButtplugIoDeviceFactory
independently recomputed the same id from the raw ButtplugClientDevice via its
own static computeDeviceId(). ButtplugIoDeviceFactory.create() now takes
(deviceId, buttplugDevice, provider) like its siblings, and the id computation
itself moved into buttplugIoWebsocketDeviceProvider's toDeviceInfo() - the one
place it was actually called from - since the separate computeDeviceId()
method was only ever a couple of lines wrapping that single call site.

Also fixes the buttplugIoWebsocketDeviceProvider class name's casing
(lowercase 'b' was a pre-existing typo against TS convention) and realigns
providerName with what main already uses ('buttplugIoWebsocket'), rather than
what this branch had reverted it to earlier.
@heavyrubberslave
heavyrubberslave marked this pull request as ready for review July 19, 2026 17:11

@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.

Caution

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

⚠️ Outside diff range comments (1)
src/device/transport/bleObserver.ts (1)

88-106: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid blocking init() indefinitely if the BLE adapter is unavailable.

await noble.waitForPoweredOnAsync() will hang indefinitely if Bluetooth is turned off, unavailable, or lacking permissions. Because DeviceProviderManager serializes provider operations, this hang will block the entire initialization queue, preventing unrelated providers (like serial or Buttplug) from loading.

Additionally, this indefinite await creates a TOCTOU race condition: if stop() is called while it is waiting, observe() will eventually resume and start scanning after the observer is supposed to be stopped.

Since you already have a stateChange listener that triggers observe() when the adapter powers on, you can safely drop the indefinite wait and rely on the state machine instead.

🔒️ Proposed fix
     private async observe(): Promise<void> {
         if (this.isScanning) {
             return;
         }
 
         try {
-            // Wait for Adapter poweredOn state
-            await noble.waitForPoweredOnAsync();
+            if (noble.state !== 'poweredOn') {
+                this.logger.debug(`BLE adapter state is '${noble.state}', waiting for 'poweredOn' event`);
+                return;
+            }
 
             this.isScanning = true;
             await noble.startScanningAsync([BleObserver.UART_SERVICE_UUID], true);
 
             this.logger.info('Looking for BLE UART devices');
🤖 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 88 - 106, Remove the
blocking noble.waitForPoweredOnAsync() call from observe() so init() cannot hang
when the adapter is unavailable and stop() cannot be bypassed after waiting. Let
observe() proceed only when the existing adapter stateChange listener has
detected a powered-on state, while preserving the current scanning, logging, and
error-handling behavior in observe().
🧹 Nitpick comments (3)
src/device/transport/bleObserver.ts (1)

59-69: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider explicitly tracking and removing specific event listeners.

Calling noble.removeAllListeners() without arguments clears every listener attached to the module, which can sometimes interfere with internal library state or other modules sharing the singleton. Retaining references to your bound callbacks and removing them explicitly via noble.off(...) is a safer pattern.

🤖 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 59 - 69, Update onLastStop
to stop calling noble.removeAllListeners(). Retain references to this observer’s
registered callbacks and remove only those listeners with noble.off(...),
preserving listeners owned by noble or other modules while keeping the existing
scan-stop and noble.stop behavior unchanged.
src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts (1)

163-172: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Prevent unhandled errors by verifying connection status before stopping the scan.

If the provider is stopped or the websocket disconnects while this 30-second scan window is open, the timeout callback will still fire. Calling stopScanning() on a disconnected client typically throws an error.

Consider checking this.isStopped() or !this.buttplugClient.connected to avoid unnecessary API calls and potential errors during teardown.

♻️ Proposed fix
         setTimeout(() => {
-            if (undefined === this.buttplugClient || !this.buttplugClient.isScanning) {
+            if (this.isStopped() || !this.buttplugClient.connected || !this.buttplugClient.isScanning) {
                 return;
             }
 
             this.buttplugClient.stopScanning()
🤖 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/buttplugIo/buttplugIoWebsocketDeviceProvider.ts` around
lines 163 - 172, Update the delayed scan-stop callback in the
ButtplugIoWebsocketDeviceProvider scan flow to return when the provider is
stopped or the client is no longer connected, before calling stopScanning().
Preserve the existing undefined-client and isScanning checks, and keep
stopScanning() and its logging behavior unchanged for active connected clients.
src/device/transport/sharedObserver.ts (1)

13-22: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider tracking the startup promise to support concurrent acquire() calls.

While DeviceProviderManager safely serializes provider initialization in the current architecture, this reference-counting logic will fail if acquire() is ever called concurrently. The second caller increments activeUsers and returns immediately, believing the observer is fully started even though the first caller's onFirstStart() promise is still pending.

If future changes allow concurrent provider loading, consider caching the startup promise so subsequent callers wait for initialization to finish.

♻️ Proposed fix
+    private startupPromise: Promise<void> | null = null;
+
     protected async acquire(): Promise<void> {
         this.activeUsers++;
 
         if (this.activeUsers > 1) {
             this.logger.debug(`Already running, now used by ${this.activeUsers} provider(s)`);
+            if (this.startupPromise) await this.startupPromise;
             return;
         }
 
-        await this.onFirstStart();
+        this.startupPromise = this.onFirstStart();
+        try {
+            await this.startupPromise;
+        } finally {
+            this.startupPromise = null;
+        }
     }
🤖 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/sharedObserver.ts` around lines 13 - 22, Update
SharedObserver.acquire to cache the in-flight onFirstStart startup promise when
activeUsers transitions from zero, and have concurrent callers await that same
promise before returning. Preserve the existing reference counting and debug
behavior, while ensuring the cached promise is cleared appropriately after
startup completes.
🤖 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.

Outside diff comments:
In `@src/device/transport/bleObserver.ts`:
- Around line 88-106: Remove the blocking noble.waitForPoweredOnAsync() call
from observe() so init() cannot hang when the adapter is unavailable and stop()
cannot be bypassed after waiting. Let observe() proceed only when the existing
adapter stateChange listener has detected a powered-on state, while preserving
the current scanning, logging, and error-handling behavior in observe().

---

Nitpick comments:
In `@src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts`:
- Around line 163-172: Update the delayed scan-stop callback in the
ButtplugIoWebsocketDeviceProvider scan flow to return when the provider is
stopped or the client is no longer connected, before calling stopScanning().
Preserve the existing undefined-client and isScanning checks, and keep
stopScanning() and its logging behavior unchanged for active connected clients.

In `@src/device/transport/bleObserver.ts`:
- Around line 59-69: Update onLastStop to stop calling
noble.removeAllListeners(). Retain references to this observer’s registered
callbacks and remove only those listeners with noble.off(...), preserving
listeners owned by noble or other modules while keeping the existing scan-stop
and noble.stop behavior unchanged.

In `@src/device/transport/sharedObserver.ts`:
- Around line 13-22: Update SharedObserver.acquire to cache the in-flight
onFirstStart startup promise when activeUsers transitions from zero, and have
concurrent callers await that same promise before returning. Preserve the
existing reference counting and debug behavior, while ensuring the cached
promise is cleared appropriately after startup completes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1fce9863-c182-4c8e-9512-c4fecfec08ad

📥 Commits

Reviewing files that changed from the base of the PR and between faf22f7 and 1a9005c.

📒 Files selected for processing (31)
  • src/app.ts
  • src/controller/settings/putSettingsController.ts
  • src/device/deviceManager.ts
  • src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProviderFactory.ts
  • src/device/provider/bleDeviceProvider.ts
  • src/device/provider/deviceProvider.ts
  • src/device/provider/deviceProviderManager.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/device/transport/bleObserver.ts
  • src/device/transport/serialPortObserver.ts
  • src/device/transport/sharedObserver.ts
  • src/schemaValidation/schemaValidationError.ts
  • src/serialization/plainToClassSerializer.ts
  • src/serviceMap.ts
  • src/serviceProvider/controllerServiceProvider.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • src/serviceProvider/serializationServiceProvider.ts
  • src/serviceProvider/settingsServiceProvider.ts
  • src/settings/deviceSource.ts
  • src/settings/knownDevice.ts
  • src/settings/settings.ts
  • src/settings/settingsManager.ts
  • tests/integration/devices/buttplugIoDevice.spec.ts
  • tests/unit/device/transport/serialPortObserver.spec.ts
  • tests/unit/settings/deviceSource.spec.ts
  • tests/unit/settings/knownDevice.spec.ts
  • tests/unit/settings/settings.spec.ts
💤 Files with no reviewable changes (7)
  • tests/unit/settings/knownDevice.spec.ts
  • tests/unit/settings/deviceSource.spec.ts
  • src/serviceProvider/controllerServiceProvider.ts
  • src/app.ts
  • src/device/provider/bleDeviceProvider.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/serviceProvider/settingsServiceProvider.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/settings/settings.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/transport/serialPortObserver.ts
  • src/device/provider/deviceProviderManager.ts
  • src/device/provider/deviceProvider.ts
  • src/device/deviceManager.ts

…nify observer/provider naming

Fixes for findings from PR review comments that were embedded in full review
bodies (outside-diff/nitpick sections) rather than separate discussion
threads, so an earlier per-thread pass missed them:

- DeviceManager.onSettingsChanged() is invoked fire-and-forget from a
  settings-changed listener, so rapid successive settings changes could
  overlap and race on connectedDevices/pendingDisabledDevices across the
  await device.close() boundary. Serialized via the same SequentialTaskQueue
  pattern already used by DeviceProviderManager.
- BleObserver.init() (now start()) awaited noble.waitForPoweredOnAsync()
  directly, which - while bounded to noble's own 10s default rather than
  truly indefinite - still meant a BLE adapter that's slow/never powers on
  delays this provider's start() by that long, and raced against stop()
  resolving mid-wait. Restructured to run the power-on wait as a background
  task instead of blocking start()/acquire() on it (see below).
- ButtplugIoWebsocketDeviceProvider's delayed scan-stop callback didn't
  account for the connection having dropped or the provider having stopped
  during the scan window, since buttplugClient.isScanning isn't reset by a
  lost/closed connection - only by an explicit start/stopScanning call or a
  'scanningfinished' message. Added connected()/isStopped() checks.
- DeviceProvider.abortDetection() didn't guarantee releaseDetectedDevice()
  runs if onConnectFailed()/device.close() themselves threw, permanently
  leaking the acquire-queue claim for that device. Wrapped both in finally.
  (This was previously marked resolved by the review bot, but checking the
  referenced commit showed the finally-wrapping was never actually added.)

BleObserver's power-on wait is now a genuinely backgrounded task (started
fire-and-forget from onFirstStart(), not awaited) so a BLE-based device
source with no adapter available can't stall DeviceProviderManager's whole
reload pipeline behind it. It still uses noble's own recommended
waitForPoweredOnAsync() (matching noble's documented usage), but re-issues it
in 10-minute chunks rather than one huge timeout, since noble has no
"wait forever" option and Node's setTimeout silently clamps any delay above
~24.8 days down to 1ms. stop() cancels the wait via a self-controlled
Promise.race signal - though since noble exposes no cancellation API, the
abandoned noble call's own internal timer keeps running regardless for up to
that chunk size, which is also why it's bounded to a human-scale duration
rather than something much larger.

Separately, followed up on the earlier SharedObserver reference-counting fix:
concurrent acquire() calls are now actually reachable, since
DeviceProviderManager.doLoadFromSettings() parallelizes starting/stopping
providers for different device sources via Promise.allSettled() instead of
one at a time - so one slow/hung provider no longer delays every other,
unrelated device source during a settings reload. Each task only ever
touches its own id in the providers map, so nothing new to race there.

Finally, two naming cleanups for consistency:
- SharedObserver.start()/stop() are now concrete public methods on the base
  class itself (renamed from the protected acquire()/release()), since every
  subclass's public entry point was already a pure one-line passthrough to
  them with no added behavior - removes that redundant wrapper entirely from
  both BleObserver and SerialPortObserver, and BleObserver's own init()/stop()
  become the inherited start()/stop() directly.
- DeviceProvider.init() renamed to start() across the whole provider
  hierarchy to match this same start()/stop() convention.

Added tests/unit/helper/async.ts's waitTicks() for tests that need to let an
already-pending promise chain progress a specific number of microtask turns -
preferred over a real setTimeout()-based wait since it's fully deterministic
and works the same regardless of whether the calling test uses
vi.useFakeTimers() (serialPortObserver.spec.ts does).

@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 (1)
src/device/provider/deviceProviderManager.ts (1)

81-105: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Provider construction failures are silently swallowed.

factory.create(deviceSource.config) (line 89) runs outside the try that wraps provider.start(). If create() throws synchronously, Promise.allSettled absorbs the rejection with no logging at all - unlike doStopProviders(), which explicitly collects and logs rejected results. An operator would have no way to tell why a device source silently never started.

🔧 Suggested fix
-            const provider = factory.create(deviceSource.config);
-
             try {
+                const provider = factory.create(deviceSource.config);
+
                 await provider.start();
                 // Only record the provider once it started successfully, so a failed start
                 // doesn't leave a stuck entry that blocks all future retries for this source.
                 this.providers.set(id, provider);
+
+                return;
             } catch (error: unknown) {
-                logError(this.logger, `Failed to start device provider for device source '${id}'`, error);
+                logError(this.logger, `Failed to create/start device provider for device source '${id}'`, error);
+                return;
+            }
+```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

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/deviceProviderManager.ts around lines 81 - 105, Move the
provider construction call in the startup flow around the Promise.allSettled
callback into the existing error-handling try block so synchronous failures from
factory.create(deviceSource.config) are caught and logged with the device source
context. Preserve the current start-success registration and half-started
provider cleanup behavior for providers that are created successfully.


</details>

<!-- cr-comment:v1:a933b225facc6108c13db8a7 -->

</blockquote></details>

</blockquote></details>
🤖 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/provider/deviceProviderManager.ts`:
- Line 23: Add an error listener for the SequentialTaskQueue instance
initialized by operationQueue, ensuring rejected tasks from doStopProviders()
are handled through the queue’s error event after its existing logging path.

---

Outside diff comments:
In `@src/device/provider/deviceProviderManager.ts`:
- Around line 81-105: Move the provider construction call in the startup flow
around the Promise.allSettled callback into the existing error-handling try
block so synchronous failures from factory.create(deviceSource.config) are
caught and logged with the device source context. Preserve the current
start-success registration and half-started provider cleanup behavior for
providers that are created successfully.
🪄 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: 3894eb80-ffc5-4701-8345-3c900c4b5d5b

📥 Commits

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

📒 Files selected for processing (34)
  • src/app.ts
  • src/controller/settings/putSettingsController.ts
  • src/device/deviceManager.ts
  • src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProviderFactory.ts
  • src/device/provider/bleDeviceProvider.ts
  • src/device/provider/deviceProvider.ts
  • src/device/provider/deviceProviderManager.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/device/transport/bleObserver.ts
  • src/device/transport/serialPortObserver.ts
  • src/device/transport/sharedObserver.ts
  • src/schemaValidation/schemaValidationError.ts
  • src/serialization/plainToClassSerializer.ts
  • src/serviceMap.ts
  • src/serviceProvider/controllerServiceProvider.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • src/serviceProvider/serializationServiceProvider.ts
  • src/serviceProvider/settingsServiceProvider.ts
  • src/settings/deviceSource.ts
  • src/settings/knownDevice.ts
  • src/settings/settings.ts
  • src/settings/settingsManager.ts
  • tests/integration/devices/buttplugIoDevice.spec.ts
  • tests/unit/device/provider/deviceProviderManager.spec.ts
  • tests/unit/device/transport/bleObserver.spec.ts
  • tests/unit/device/transport/serialPortObserver.spec.ts
  • tests/unit/helper/async.ts
  • tests/unit/settings/deviceSource.spec.ts
  • tests/unit/settings/knownDevice.spec.ts
  • tests/unit/settings/settings.spec.ts
💤 Files with no reviewable changes (5)
  • tests/unit/settings/deviceSource.spec.ts
  • tests/unit/settings/knownDevice.spec.ts
  • src/serviceProvider/controllerServiceProvider.ts
  • src/serviceProvider/settingsServiceProvider.ts
  • src/app.ts
🚧 Files skipped from review as they are similar to previous changes (18)
  • src/schemaValidation/schemaValidationError.ts
  • src/settings/deviceSource.ts
  • src/controller/settings/putSettingsController.ts
  • tests/integration/devices/buttplugIoDevice.spec.ts
  • tests/unit/settings/settings.spec.ts
  • src/device/protocol/virtual/virtualDeviceProviderFactory.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts
  • src/serialization/plainToClassSerializer.ts
  • src/settings/settingsManager.ts
  • tests/unit/device/transport/serialPortObserver.spec.ts
  • src/settings/settings.ts
  • src/device/transport/serialPortObserver.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/serviceMap.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts
  • src/device/deviceManager.ts

Comment thread src/device/provider/deviceProviderManager.ts
@heavyrubberslave heavyrubberslave added minor Creates a new minor release if merged patch Creates a new patch/bugfix release if merged and removed minor Creates a new minor release if merged labels Jul 21, 2026
- app.ts: reload device sources before applying device enable/disable
  changes on a settings change (was unordered, could re-announce a
  device before its provider reloaded); split the reactive settings-
  change handling out of configureWebsocket() into its own
  configureSettingsChangeHandling(), since it has nothing to do with
  WebSockets beyond the broadcast itself.

- DeviceProvider: start()/stop() are now template methods calling
  subclass hooks doStart()/doStop(), matching SharedObserver's
  onFirstStart()/onLastStop() pattern. The deviceManager.on()
  subscription moved out of the constructor into start() (previously
  reactive before the provider was ever started). Providers now
  properly support a stop() -> start() restart cycle instead of
  throwing, collapsed into a single `running` field instead of two.

- ButtplugIoWebsocketDeviceProvider: split reconnect() out of
  doStart() so a lost-connection reconnect no longer re-registers the
  buttplug client's listeners.

- DeviceProviderManager: restart a provider when its device source's
  type/config changes instead of leaving it running with stale config.

- SharedObserver: a failed onFirstStart() no longer permanently
  increments the refcount; reworked so a caller only counts as active
  once startup actually succeeds, removing the need for manual
  rollback on failure.

- BleObserver: distinguish real adapter errors from a power-on timeout
  instead of swallowing both as "timeout"; fixed a start/stop race
  around the scanning flag.

- PlainToClassSerializer: don't mutate the caller's input object when
  hydrating schema defaults.

- Removed dead code (BleDevice.getPeripheral, PeripheralDevice.getTransport),
  routed ButtplugIoDevice's device-removed handler through its logger
  instead of console.error, extracted a shared WithUntypedAttributes<D>
  helper instead of duplicating the same Omit/intersection three times,
  renamed the stale device.provider.loader DI key to device.provider.manager,
  and unified deviceDetectionInfo/deviceInfo parameter naming across
  DeviceProvider and its subclasses.

Tests: added coverage for SharedObserver's startup-failure/idempotency
behaviour, DeviceProvider's restart cycle and listener bookkeeping, and
DeviceProviderManager's config-change restart; fixed a flaky
integration test that could observe a stale sensor reading.

@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: 2

Caution

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

⚠️ Outside diff range comments (1)
src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts (1)

131-146: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Uncaught device.close() can permanently stall reconnection.

handleLostConnection() iterates connected devices and awaits device.close() without a try/catch. If any device's close() rejects, the loop aborts, autoScanningIntervalRef is never cleared, and — since this now also gates the reconnect resumption — this.connectClient() at Line 145 never runs. The provider would stop attempting to reconnect to the buttplug.io server until manually restarted. This mirrors the exact hazard already fixed in deviceProvider.ts's stop() (per-device try/catch), but is missing here.

🩹 Proposed fix
     private async handleLostConnection(url: string): Promise<void> {
         this.logger.info(`Lost connection to buttplug.io server (${url})`);
 
         for (const device of [...this.getConnectedDevices()]) {
-            await device.close();
+            try {
+                await device.close();
+            } catch (e: unknown) {
+                logError(this.logger, `Failed to close device '${device.getDeviceId}' after losing buttplug.io connection`, e);
+            }
         }
 
         clearInterval(this.autoScanningIntervalRef);
         this.autoScanningIntervalRef = undefined;
🤖 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/buttplugIo/buttplugIoWebsocketDeviceProvider.ts` around
lines 131 - 146, Update handleLostConnection to close each connected device
inside its own try/catch, matching the per-device handling in deviceProvider.ts
stop(). Ensure a rejected device.close() is handled without aborting the loop,
so interval cleanup and the subsequent connectClient() call still execute.
🤖 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/app.ts`:
- Around line 265-267: Guard the device provider shutdown call in shutdown() so
an error from device.provider.manager.stopProviders() is handled without
aborting the remaining graceful-shutdown steps. Ensure
health.metricsCollector.stop() and the subsequent websocket/HTTP(S) server close
operations still execute when an individual provider fails, while preserving the
existing stopProviders invocation.

In `@src/device/provider/deviceProvider.ts`:
- Around line 46-58: Guard the await of doStop() in DeviceProvider.stop() so a
subclass failure cannot prevent the remaining shutdown steps. Ensure
deviceDetected listener detachment and connectedDevices cleanup/close still
execute, while preserving the existing non-rejecting stop behavior and handling
the doStop error consistently with the device-close loop.

---

Outside diff comments:
In `@src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts`:
- Around line 131-146: Update handleLostConnection to close each connected
device inside its own try/catch, matching the per-device handling in
deviceProvider.ts stop(). Ensure a rejected device.close() is handled without
aborting the loop, so interval cleanup and the subsequent connectClient() call
still execute.
🪄 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: 2e41bb83-a76a-4ae4-9495-011babc2334c

📥 Commits

Reviewing files that changed from the base of the PR and between e2bfc6b and 1a6466c.

📒 Files selected for processing (28)
  • 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/buttplugIoWebsocketDeviceProvider.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/serialDeviceProvider.ts
  • src/device/transport/bleObserver.ts
  • src/device/transport/sharedObserver.ts
  • src/serialization/plainToClassSerializer.ts
  • src/serviceMap.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • tests/integration/devices/buttplugIoDevice.spec.ts
  • tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts
  • tests/unit/device/provider/deviceProvider.spec.ts
  • tests/unit/device/provider/deviceProviderManager.spec.ts
  • tests/unit/device/transport/sharedObserver.spec.ts
  • tests/unit/settings/settings.spec.ts
🚧 Files skipped from review as they are similar to previous changes (19)
  • tests/unit/settings/settings.spec.ts
  • src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • src/device/transport/sharedObserver.ts
  • src/device/peripheralDevice.ts
  • src/serialization/plainToClassSerializer.ts
  • tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts
  • src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts
  • src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts
  • src/device/protocol/zc95/zc95SerialDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/airotic/airoticDeviceProvider.ts
  • src/device/provider/bleDeviceProvider.ts
  • src/device/protocol/buttplugIo/buttplugIoDevice.ts
  • tests/unit/device/provider/deviceProviderManager.spec.ts
  • src/device/transport/bleObserver.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/device/deviceManager.ts
  • src/device/provider/deviceProviderManager.ts

Comment thread src/app.ts
Comment thread src/device/provider/deviceProvider.ts
- shutdown() no longer aborts remaining cleanup steps if stopProviders() throws
- DeviceProvider.stop() no longer skips listener detachment and device cleanup if doStop() throws

Addresses CodeRabbit findings on PR #92
POST /automation/run resolved before the log file's write stream
finished opening, so an immediate GET /automation/log could hit
read-last-lines' fs.exists() check before the file existed on disk,
causing a 500. load() now awaits the stream's open event before
resolving. getLog() also treats a missing log file as an empty log
instead of an error, covering the case of no script having run yet.
@heavyrubberslave

Copy link
Copy Markdown
Member Author

@coderabbitai please do a final review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

@heavyrubberslave I’ll perform a final review of the complete PR.

✅ Action performed

Full review finished.

@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 (1)
src/device/transport/serialPortObserver.ts (1)

110-121: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clear managedDevices when stopping the serial observer.

Disabling/re-enabling a serial source runs DeviceProviderManager.stopProviders() followed by loadFromSettings(), which creates a new SerialProvider, starts it, and shuts down the previous provider via DeviceProvider.stop() (closing the connected device). Because SerialPortObserver keeps entries in managedDevices and only skips announcement while a device is already present, a still-plugged serial device is not re-announced on the restart. Clear this cache in onLastStop() so the next start reports present devices again.

🛠️ Proposed fix
     protected async onLastStop(): Promise<void> {
         if (this.rescanTimer !== undefined) {
             clearTimeout(this.rescanTimer);
             this.rescanTimer = undefined;
         }

         if (this.onUsbEventRef !== undefined) {
             usb.removeEventListener('connect', this.onUsbEventRef);
             usb.removeEventListener('disconnect', this.onUsbEventRef);
             this.onUsbEventRef = undefined;
         }
+
+        // Drop detection cache so a subsequent start re-announces still-present devices
+        this.managedDevices.clear();
     }
🤖 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/serialPortObserver.ts` around lines 110 - 121, Clear the
managedDevices cache in SerialPortObserver.onLastStop() during shutdown,
alongside the existing timer and USB listener cleanup, so restarting the
observer re-announces currently connected serial devices.
🤖 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/automation/scriptRuntime.ts`:
- Around line 302-312: Update the writer-open handling in load() so any
rejection from the promise waiting on writer open disposes the already-created
isolate and VM context and clears the captured dispatchRef/lifecycleRef before
rethrowing the error. Ensure the cleanup occurs before load() exits, while
preserving normal initialization and assignment of logWriter after a successful
open.

---

Outside diff comments:
In `@src/device/transport/serialPortObserver.ts`:
- Around line 110-121: Clear the managedDevices cache in
SerialPortObserver.onLastStop() during shutdown, alongside the existing timer
and USB listener cleanup, so restarting the observer re-announces currently
connected serial devices.
🪄 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 Plus

Run ID: 7e55bcba-0dfa-46ff-8ac4-b4a897f70890

📥 Commits

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

📒 Files selected for processing (61)
  • src/app.ts
  • src/automation/scriptRuntime.ts
  • src/controller/settings/putSettingsController.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/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts
  • src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts
  • src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProviderFactory.ts
  • src/device/protocol/zc95/zc95SerialDeviceProvider.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
  • src/device/transport/bleObserver.ts
  • src/device/transport/serialPortObserver.ts
  • src/device/transport/sharedObserver.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/schemaValidation/schemaValidationError.ts
  • src/serialization/plainToClassSerializer.ts
  • src/serviceMap.ts
  • src/serviceProvider/controllerServiceProvider.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • src/serviceProvider/serializationServiceProvider.ts
  • src/serviceProvider/settingsServiceProvider.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/devices/buttplugIoDevice.spec.ts
  • tests/integration/helpers/appHelper.ts
  • tests/unit/automation/scriptRuntime.spec.ts
  • tests/unit/device/deviceManager.spec.ts
  • tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts
  • tests/unit/device/provider/deviceProvider.spec.ts
  • tests/unit/device/provider/deviceProviderManager.spec.ts
  • tests/unit/device/testDeviceProvider.ts
  • tests/unit/device/transport/bleObserver.spec.ts
  • tests/unit/device/transport/serialPortObserver.spec.ts
  • tests/unit/device/transport/sharedObserver.spec.ts
  • tests/unit/helper/async.ts
  • tests/unit/settings/deviceSource.spec.ts
  • tests/unit/settings/knownDevice.spec.ts
  • tests/unit/settings/settings.spec.ts
💤 Files with no reviewable changes (3)
  • src/serviceProvider/controllerServiceProvider.ts
  • tests/unit/device/testDeviceProvider.ts
  • src/serviceProvider/settingsServiceProvider.ts

Comment thread src/automation/scriptRuntime.ts Outdated
@heavyrubberslave

Copy link
Copy Markdown
Member Author

@coderabbitai please do a final review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

@heavyrubberslave I’ll perform a final review of the complete PR.

✅ Action performed

Full review finished.

@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: 2

Caution

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

⚠️ Outside diff range comments (1)
src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts (1)

107-127: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Missing post-await isStopped() check leaks a connection after stop() races with an in-flight connect.

isStopped() is only checked before await this.buttplugClient.connect(...). If stop() runs while that connect is pending, doStop() sees buttplugClient.connected === false and skips disconnecting, and already removed all listeners. When the pending connect then resolves, connectToServer() proceeds unconditionally - the websocket stays open with no listeners, and (if autoScan is on) a fresh autoScanningIntervalRef starts driving discoverButtplugIoDevices() indefinitely, even though the provider is stopped.

🩹 Proposed fix
         try {
             await this.buttplugClient.connect(this.buttplugConnector);
+
+            if (this.isStopped()) {
+                // stop() ran while this connect attempt was in flight; listeners are already
+                // torn down, so just close the now-stale connection instead of proceeding
+                try {
+                    await this.buttplugClient.disconnect();
+                } catch (e: unknown) {
+                    logError(this.logger, 'Could not disconnect stale connection after stop()', e);
+                }
+                return;
+            }
+
             this.logger.info(`Successfully connected to buttplug.io server (${url})`);
🤖 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/buttplugIo/buttplugIoWebsocketDeviceProvider.ts` around
lines 107 - 127, Add an isStopped() check immediately after the awaited
buttplugClient.connect() in connectToServer(). If the provider was stopped while
the connection was pending, disconnect the newly established client and return
before logging success, clearing connection timers, or starting auto-scanning.
🤖 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/automation/scriptRuntime.ts`:
- Around line 226-243: Wrap the initialization after assigning `this.logWriter`
in cleanup handling covering isolate/context creation, script compilation, and
jail setup. On failure, close the writer, release any partially created isolate
or context, reset related state as needed, and rethrow the original error so
`stop()` is not relied on while `runningSince` remains null.

In `@src/device/provider/deviceProviderManager.ts`:
- Around line 84-110: Move factory.create(deviceSource.config) inside the try
block in DeviceProviderManager’s provider-start flow so creation failures are
passed to logError and do not escape silently; preserve cleanup only when a
provider was created. In
tests/unit/device/provider/deviceProviderManager.spec.ts lines 85-237, add
coverage where the factory’s create() throws, asserting logError/warn is invoked
and the source is retried on the next loadFromSettings() call.

---

Outside diff comments:
In `@src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts`:
- Around line 107-127: Add an isStopped() check immediately after the awaited
buttplugClient.connect() in connectToServer(). If the provider was stopped while
the connection was pending, disconnect the newly established client and return
before logging success, clearing connection timers, or starting auto-scanning.
🪄 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 Plus

Run ID: 0ecf8733-2f5a-4165-8fe2-04dfa071d98c

📥 Commits

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

📒 Files selected for processing (61)
  • src/app.ts
  • src/automation/scriptRuntime.ts
  • src/controller/settings/putSettingsController.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/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts
  • src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts
  • src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProviderFactory.ts
  • src/device/protocol/zc95/zc95SerialDeviceProvider.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
  • src/device/transport/bleObserver.ts
  • src/device/transport/serialPortObserver.ts
  • src/device/transport/sharedObserver.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/schemaValidation/schemaValidationError.ts
  • src/serialization/plainToClassSerializer.ts
  • src/serviceMap.ts
  • src/serviceProvider/controllerServiceProvider.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • src/serviceProvider/serializationServiceProvider.ts
  • src/serviceProvider/settingsServiceProvider.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/devices/buttplugIoDevice.spec.ts
  • tests/integration/helpers/appHelper.ts
  • tests/unit/automation/scriptRuntime.spec.ts
  • tests/unit/device/deviceManager.spec.ts
  • tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts
  • tests/unit/device/provider/deviceProvider.spec.ts
  • tests/unit/device/provider/deviceProviderManager.spec.ts
  • tests/unit/device/testDeviceProvider.ts
  • tests/unit/device/transport/bleObserver.spec.ts
  • tests/unit/device/transport/serialPortObserver.spec.ts
  • tests/unit/device/transport/sharedObserver.spec.ts
  • tests/unit/helper/async.ts
  • tests/unit/settings/deviceSource.spec.ts
  • tests/unit/settings/knownDevice.spec.ts
  • tests/unit/settings/settings.spec.ts
💤 Files with no reviewable changes (3)
  • tests/unit/device/testDeviceProvider.ts
  • src/serviceProvider/controllerServiceProvider.ts
  • src/serviceProvider/settingsServiceProvider.ts

Comment thread src/automation/scriptRuntime.ts Outdated
Comment thread src/device/provider/deviceProviderManager.ts

@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.

Caution

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

⚠️ Outside diff range comments (1)
src/automation/scriptRuntime.ts (1)

55-87: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard load() against concurrent execution.

POST /automation/run calls scriptRuntime.load() directly, and there’s no existing stop()/running-state check before starting a new script. If load() is entered while another script is already running, the previous VM isolate and log writer are replaced before teardown. Call stop() first or add a reentrancy guard that reuses/rejects the running script.

🤖 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/automation/scriptRuntime.ts` around lines 55 - 87, Guard
ScriptRuntime.load against concurrent execution by checking the existing running
state before opening a new log writer or creating a VM. Reuse or reject the load
request while a script is active, or invoke stop() first so the current VM and
log writer are fully torn down before replacement; preserve the existing startup
and failure cleanup behavior for non-concurrent loads.
🧹 Nitpick comments (1)
src/automation/scriptVmFactory.ts (1)

252-260: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a CPU/timeout limit to sandboxed script execution.

memoryLimit: 128 bounds memory, but compiledScript.run(vmContext, { promise: true }) and the async Reference.apply(...) calls in ScriptVm can execute arbitrary user code without a timeout option, so a synchronous infinite loop can leave the isolate thread pinning the awaiting caller. Pass a bounded timeout where isolated-vm permits it for this execution path.

🤖 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/automation/scriptVmFactory.ts` around lines 252 - 260, Add a bounded
timeout to sandboxed user-code execution in the script factory and ScriptVm
dispatch paths. Update compiledScript.run in the bootstrap flow and the async
Reference.apply calls in ScriptVm to pass the timeout option supported by
isolated-vm, while leaving bootstrap execution and existing promise behavior
unchanged.
🤖 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.

Outside diff comments:
In `@src/automation/scriptRuntime.ts`:
- Around line 55-87: Guard ScriptRuntime.load against concurrent execution by
checking the existing running state before opening a new log writer or creating
a VM. Reuse or reject the load request while a script is active, or invoke
stop() first so the current VM and log writer are fully torn down before
replacement; preserve the existing startup and failure cleanup behavior for
non-concurrent loads.

---

Nitpick comments:
In `@src/automation/scriptVmFactory.ts`:
- Around line 252-260: Add a bounded timeout to sandboxed user-code execution in
the script factory and ScriptVm dispatch paths. Update compiledScript.run in the
bootstrap flow and the async Reference.apply calls in ScriptVm to pass the
timeout option supported by isolated-vm, while leaving bootstrap execution and
existing promise behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8d257178-87e5-4aee-a543-b9371df9c6a1

📥 Commits

Reviewing files that changed from the base of the PR and between 4dffc96 and 24d868b.

📒 Files selected for processing (9)
  • src/automation/scriptRuntime.ts
  • src/automation/scriptVm.ts
  • src/automation/scriptVmFactory.ts
  • src/device/provider/deviceProviderManager.ts
  • src/serviceMap.ts
  • src/serviceProvider/automationServiceProvider.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • tests/unit/automation/scriptRuntime.spec.ts
  • tests/unit/device/provider/deviceProviderManager.spec.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • tests/unit/automation/scriptRuntime.spec.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • tests/unit/device/provider/deviceProviderManager.spec.ts
  • src/device/provider/deviceProviderManager.ts

@heavyrubberslave

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@heavyrubberslave
heavyrubberslave merged commit 2ac7274 into main Jul 25, 2026
7 checks passed
@heavyrubberslave
heavyrubberslave deleted the feat/device-source-enable-disable branch July 25, 2026 06:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patch Creates a new patch/bugfix release if merged

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