Skip to content

Add BLE support#72

Closed
heavyrubberslave wants to merge 19 commits into
mainfrom
feat/ble-support
Closed

Add BLE support#72
heavyrubberslave wants to merge 19 commits into
mainfrom
feat/ble-support

Conversation

@heavyrubberslave

@heavyrubberslave heavyrubberslave commented Mar 8, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Bluetooth/BLE support for discovering and using BLE devices.
    • Devices from serial, virtual and network providers now register centrally for smoother availability.
  • Refactor

    • Unified event-driven device manager with improved connect/disconnect/refresh lifecycle.
    • Transport and port lifecycle exposed (open/close); device refresh cadence standardized.
  • Chores

    • Added Bluetooth-related libraries and an event-emitter factory.

@coderabbitai

coderabbitai Bot commented Mar 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

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

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 29511b39-018c-4ae2-a165-1321ce9e4fc5

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Centralizes device discovery/claiming in DeviceManager, introduces EventEmitter-driven device lifecycle and DeviceEvent, makes providers/devices DeviceManager-aware and generic, changes transport API from receive→onReceive with onClose/isOpen/close, wires EventEmitterFactory and loggers, and adds three BLE deps to package.json.

Changes

Cohort / File(s) Summary
Dependencies
package.json
Added BLE libs: @stoprocent/noble, advlib-ble-manufacturers, bluetooth-numbers-database.
Device manager & events
src/device/deviceManager.ts, src/device/deviceManagerEvent.ts, src/index.ts, src/automation/scriptRuntime.ts
Move/define DeviceManagerEvent into deviceManager, remove old enum file, add announce/acquire/release detected-device APIs, per-device detection queues, and update event typings/usages.
Device core & peripherals
src/device/device.ts, src/device/peripheralDevice.ts
Add EventEmitter wiring, DeviceEvent enum/map, generic Infer types, lifecycle helpers (on, close, updateLastRefresh, emit), and refresh() API; constructors now accept eventEmitter.
Provider base & factories
src/device/provider/deviceProvider.ts, src/device/provider/deviceProviderFactory.ts, src/device/provider/genericDeviceProviderFactory.ts
Make DeviceProvider generic and DeviceManager-aware (constructor now takes deviceManager), remove per-device status updaters, and narrow factory generics/return types.
Serial onboarding & observer
src/device/provider/serialDeviceProvider.ts, src/device/provider/deviceProviderLoader.ts, src/device/transport/serialPortObserver.ts
Discovery now announces devices to DeviceManager (announceDetectedDevice); connect flows return device instances (`D
Protocol providers & factories
src/device/protocol/.../* (buttplugIo, estim2b, slvCtrlPlus, zc95, virtual, factories)
Thread DeviceManager, per-device EventEmitter and Logger into factories/providers/devices, rename refreshDatarefresh, return/propagate device instances, and replace provider event emissions with DeviceManager.addDevice/remove flows.
Transport & serial port APIs
src/device/transport/deviceTransport.ts, src/device/transport/serialDeviceTransport.ts, src/serial/synchronousSerialPort.ts
Rename receiveonReceive, add lifecycle APIs onClose, isOpen, close and implement them in serial transport and port classes.
Virtual devices & logic
src/device/protocol/virtual/*, src/device/protocol/virtual/genericVirtualDeviceFactory.ts, src/device/protocol/virtual/virtualDevice.ts
Pass EventEmitter/Logger via factories, change refresh flow, add getRefreshInterval(), and increase some refresh intervals (50→175).
Async utilities & event factory
src/util/async.ts, src/factory/eventEmitterFactory.ts, src/serviceMap.ts
Add setIntervalAsync, asyncHandler, IntervalAsync types; introduce EventEmitterFactory and register it in ServiceMap.
Service wiring & DI
src/serviceProvider/*.ts, src/serviceProvider/deviceServiceProvider.ts
Update DI to supply device.manager, use factory.eventEmitter for emitters, and pass logger to DeviceManager and provider/factory constructors.
Misc / typing & lint
eslint.config.ts, src/util/error.ts, many small files
ESLint rule adjustments, added logError util, many explicit return types and minor signature tightenings across files.

Sequence Diagram(s)

sequenceDiagram
    participant SerialPortObserver as SerialPortObserver
    participant DeviceManager as DeviceManager
    participant SerialDeviceProvider as SerialDeviceProvider
    participant Device as Device

    SerialPortObserver->>DeviceManager: announceDetectedDevice(serialDeviceInfo)
    activate DeviceManager
    DeviceManager->>DeviceManager: enqueue detection, emit deviceDetected
    DeviceManager->>SerialDeviceProvider: notify / acquireDetectedDevice(deviceId)
    deactivate DeviceManager

    activate SerialDeviceProvider
    SerialDeviceProvider->>SerialDeviceProvider: connectSerialDevice(port)
    SerialDeviceProvider->>Device: instantiate Device(EventEmitter, Logger)
    SerialDeviceProvider->>DeviceManager: addDevice(device)
    deactivate SerialDeviceProvider

    DeviceManager->>DeviceManager: mark claimed, emit deviceConnected
    Device->>Device: periodic refresh() -> emit deviceRefreshed
    Device->>DeviceManager: on close -> emit deviceDisconnected
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

minor

Poem

🐇 I hopped through code with ears so bright,

Events and claims now dance in light,
Devices wake, refresh, and sing,
BLE friends hopped in on a spring,
The rabbit nods: "Well done tonight!"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The PR title 'Add BLE support' is vague and does not clearly communicate the scope of the extensive refactoring across device management, event handling, and provider architecture that comprises the majority of the changeset. Consider a more descriptive title that captures the main refactoring (e.g., 'Refactor device lifecycle management and add BLE support') or clarify whether BLE support is the primary focus or part of a larger architectural change.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/ble-support
📝 Coding Plan
  • Generate coding plan for human review comments

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 and usage tips.

Tip

CodeRabbit can scan for known vulnerabilities in your dependencies using OSV Scanner.

OSV Scanner will automatically detect and report security vulnerabilities in your project's dependencies. No additional configuration is required.

@heavyrubberslave

Copy link
Copy Markdown
Member Author

@coderabbitai review please

@coderabbitai

coderabbitai Bot commented Mar 8, 2026

Copy link
Copy Markdown

@heavyrubberslave: Sure! I'll start reviewing the PR now.

✅ Actions performed

Review triggered.

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.

@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

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)

94-101: ⚠️ Potential issue | 🟠 Major

runForEvent() signature accepts an event type whose payload is incompatible with the method parameter.

The method signature runForEvent(eventType: keyof DeviceManagerEvent, device: Device) advertises support for all DeviceManagerEvent keys, including deviceAvailable (which has payload DeviceInfo), but the parameter is typed as Device. While no current callers forward deviceAvailable, this creates a type safety gap—the method cannot correctly handle all event types it claims to accept.

Since automation only runs for connected-device events (deviceConnected, deviceDisconnected, deviceRefreshed), narrow the type signature to exclude deviceAvailable:

Suggested fix
-    public runForEvent(eventType: keyof DeviceManagerEvent, device: Device): void
+    public runForEvent(
+        eventType: Exclude<keyof DeviceManagerEvent, 'deviceAvailable'>,
+        device: Device
+    ): void
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/automation/scriptRuntime.ts` around lines 94 - 101, The method
runForEvent declares eventType as keyof DeviceManagerEvent but accepts a Device
parameter while some DeviceManagerEvent keys (e.g., deviceAvailable) use a
different payload type; change the signature to only accept the connected-device
event keys that map to Device (e.g., narrow eventType to the union
'deviceConnected' | 'deviceDisconnected' | 'deviceRefreshed' or use keyof
Pick<DeviceManagerEvent,
'deviceConnected'|'deviceDisconnected'|'deviceRefreshed'>) so the event payload
type matches the device parameter, and update any callers of runForEvent if
needed to use one of these narrowed keys; keep the rest of runForEvent
(sandbox.event assignment) unchanged and ensure TypeScript types now align.
🧹 Nitpick comments (3)
package.json (2)

13-13: Pin the GitHub dependency to a specific commit or tag for reproducibility.

The bluetooth-numbers-database dependency references the repository without a commit SHA or tag. While package-lock.json currently pins it to commit 85317212ef563adcc57e575793a24e679c81b724, fresh installs or lock regeneration could pull different commits, leading to inconsistent builds.

📦 Suggested fix to pin the commit
-    "bluetooth-numbers-database": "github:NordicSemiconductor/bluetooth-numbers-database",
+    "bluetooth-numbers-database": "github:NordicSemiconductor/bluetooth-numbers-database#85317212ef563adcc57e575793a24e679c81b724",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` at line 13, The GitHub dependency "bluetooth-numbers-database"
is not pinned to a commit/tag which can cause non-reproducible installs; update
the package.json entry for "bluetooth-numbers-database" to include the known
commit SHA or a stable tag (e.g. change
"github:NordicSemiconductor/bluetooth-numbers-database" to
"github:NordicSemiconductor/bluetooth-numbers-database#85317212ef563adcc57e575793a24e679c81b724"),
then run your package manager to regenerate package-lock.json and commit both
package.json and package-lock.json so installs remain reproducible.

8-8: Note: @stoprocent/noble requires native Bluetooth dependencies.

This library requires platform-specific Bluetooth stack dependencies (e.g., libbluetooth-dev on Linux, Xcode command line tools on macOS). Ensure these are documented in setup/installation instructions for developers and deployment environments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` at line 8, The dependency "@stoprocent/noble" requires native
Bluetooth stack libraries, so update the repository setup/docs to list
platform-specific prerequisites and install commands (e.g., libbluetooth-dev and
build-essential for Debian/Ubuntu, Xcode command line tools for macOS,
appropriate Windows Bluetooth SDKs), add a short note near the dependency
reference in README/INSTALL describing these requirements, include any
apt/brew/choco install commands and CI/container setup tips (e.g., Docker image
packages or actions) and mention failure symptoms and how to verify Bluetooth
libs are present to help developers and deployment automation.
src/device/provider/deviceProvider.ts (1)

40-46: Consider emitting deviceRefreshed only after successful refresh.

The deviceRefreshed event is emitted synchronously (line 44) immediately after starting the async refreshData() call, not after it completes. This means:

  1. The event fires even if refresh fails
  2. Subscribers receive the event before the device data is actually updated

If this is intentional (to signal "refresh cycle occurred"), the current behavior is fine. Otherwise, consider emitting inside the .then() callback.

💡 Suggested change to emit after successful refresh
         device.refreshData().then(() => device.updateLastRefresh()).catch(
             (e: Error) => this.logger.error(`device: ${device.getDeviceId} -> refreshData -> failed: ${e.message}`)
-        );
-
-        this.eventEmitter.emit(DeviceProviderEvent.deviceRefreshed, device);
+        ).then(() => {
+            this.eventEmitter.emit(DeviceProviderEvent.deviceRefreshed, device);
+            this.logger.trace(`Device refreshed: ${device.getDeviceId}`)
+        });
-
-        this.logger.trace(`Device refreshed: ${device.getDeviceId}`)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/provider/deviceProvider.ts` around lines 40 - 46, The
deviceRefreshed event is emitted before the async refresh completes; move the
this.eventEmitter.emit(DeviceProviderEvent.deviceRefreshed, device) and the
this.logger.trace(`Device refreshed: ${device.getDeviceId}`) inside the success
path of the device.refreshData() promise (i.e., within the .then() after
device.updateLastRefresh()) so the event and trace only occur after
refreshData() resolves successfully; keep the existing .catch(...) for logging
failures and do not emit the event on error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/device/deviceManager.ts`:
- Around line 63-71: freeClaimedDevice currently shifts and resolves the entry
returned by shift(), which wakes the current owner instead of the next waiter;
change freeClaimedDevice to remove the current claimant first (call shift() to
discard the owner) and then resolve the next pending claimant (peek
deviceQueue[0] or deviceQueue.shift() for the next element) only if it exists;
reference the deviceClaimQueue map and the freeClaimedDevice method and ensure
you do not call resolve() on the removed entry so the next waiter is the one
resumed.
- Around line 79-80: The async listener that calls claimAvailableDevice() in
serialDeviceProvider.ts must not allow rejected Promises to become unhandled:
update the listener (the async deviceAvailable handler that awaits
claimAvailableDevice()) to catch rejections (wrap the await in try/catch) and
return a clear non-exception result (e.g., resolve to false) or otherwise handle
the losing case, or alternatively change claimAvailableDevice() to return a
non-throwing result (Promise<boolean>) and adjust callers accordingly; ensure
this handles the rejection path created by addDevice() which rejects entries
stored in deviceClaimQueue and references device.getDeviceId so no unhandled
promise rejection occurs.

In `@src/device/deviceManagerEvent.ts`:
- Around line 4-9: The interface DeviceManagerEvents is currently type-only and
removes the runtime value used by tests; add a runtime export named
DeviceManagerEvent (singular) containing the event name strings
(deviceConnected, deviceDisconnected, deviceRefreshed, deviceAvailable) so
value-space consumers can reference DeviceManagerEvent.deviceConnected, etc.,
while keeping the existing DeviceManagerEvents interface for typing; ensure the
runtime object keys match the interface keys exactly.

In `@src/device/provider/serialDeviceProvider.ts`:
- Around line 32-46: The async event handler registered on this.deviceManager
('deviceAvailable' callback) lacks try/catch and can produce unhandled promise
rejections from calls like claimAvailableDevice, connectToDevice, or addDevice;
wrap the handler body in a try { ... } catch (err) { ... } block, call
freeClaimedDevice(deviceInfo.id) in the catch if the device was claimed (or
after a failed connect), and log or surface the error via the existing logging
utility before returning so the promise rejection is handled; keep the existing
checks using isSerialDeviceInfo(deviceInfo), and ensure connectToDevice and
addDevice failures clean up by invoking freeClaimedDevice when appropriate.

---

Outside diff comments:
In `@src/automation/scriptRuntime.ts`:
- Around line 94-101: The method runForEvent declares eventType as keyof
DeviceManagerEvent but accepts a Device parameter while some DeviceManagerEvent
keys (e.g., deviceAvailable) use a different payload type; change the signature
to only accept the connected-device event keys that map to Device (e.g., narrow
eventType to the union 'deviceConnected' | 'deviceDisconnected' |
'deviceRefreshed' or use keyof Pick<DeviceManagerEvent,
'deviceConnected'|'deviceDisconnected'|'deviceRefreshed'>) so the event payload
type matches the device parameter, and update any callers of runForEvent if
needed to use one of these narrowed keys; keep the rest of runForEvent
(sandbox.event assignment) unchanged and ensure TypeScript types now align.

---

Nitpick comments:
In `@package.json`:
- Line 13: The GitHub dependency "bluetooth-numbers-database" is not pinned to a
commit/tag which can cause non-reproducible installs; update the package.json
entry for "bluetooth-numbers-database" to include the known commit SHA or a
stable tag (e.g. change "github:NordicSemiconductor/bluetooth-numbers-database"
to
"github:NordicSemiconductor/bluetooth-numbers-database#85317212ef563adcc57e575793a24e679c81b724"),
then run your package manager to regenerate package-lock.json and commit both
package.json and package-lock.json so installs remain reproducible.
- Line 8: The dependency "@stoprocent/noble" requires native Bluetooth stack
libraries, so update the repository setup/docs to list platform-specific
prerequisites and install commands (e.g., libbluetooth-dev and build-essential
for Debian/Ubuntu, Xcode command line tools for macOS, appropriate Windows
Bluetooth SDKs), add a short note near the dependency reference in
README/INSTALL describing these requirements, include any apt/brew/choco install
commands and CI/container setup tips (e.g., Docker image packages or actions)
and mention failure symptoms and how to verify Bluetooth libs are present to
help developers and deployment automation.

In `@src/device/provider/deviceProvider.ts`:
- Around line 40-46: The deviceRefreshed event is emitted before the async
refresh completes; move the
this.eventEmitter.emit(DeviceProviderEvent.deviceRefreshed, device) and the
this.logger.trace(`Device refreshed: ${device.getDeviceId}`) inside the success
path of the device.refreshData() promise (i.e., within the .then() after
device.updateLastRefresh()) so the event and trace only occur after
refreshData() resolves successfully; keep the existing .catch(...) for logging
failures and do not emit the event on error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ecd6a43d-c923-4486-9c07-a49b103a32a2

📥 Commits

Reviewing files that changed from the base of the PR and between 708c0b8 and bc170dd.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (22)
  • package.json
  • src/automation/scriptRuntime.ts
  • src/device/device.ts
  • src/device/deviceManager.ts
  • src/device/deviceManagerEvent.ts
  • src/device/peripheralDevice.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProviderFactory.ts
  • src/device/protocol/estim2b/estim2bDevice.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/deviceProvider.ts
  • src/device/provider/deviceProviderFactory.ts
  • src/device/provider/deviceProviderLoader.ts
  • src/device/provider/genericDeviceProviderFactory.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/device/transport/serialPortObserver.ts
  • src/index.ts
  • src/serviceProvider/deviceServiceProvider.ts
💤 Files with no reviewable changes (1)
  • src/device/provider/deviceProviderLoader.ts

Comment thread src/device/deviceManager.ts Outdated
Comment thread src/device/deviceManager.ts Outdated
Comment thread src/device/deviceManagerEvent.ts Outdated
Comment thread src/device/provider/serialDeviceProvider.ts Outdated
@heavyrubberslave

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 12, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

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.

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

♻️ Duplicate comments (1)
src/device/provider/serialDeviceProvider.ts (1)

29-44: ⚠️ Potential issue | 🟠 Major

Catch failures inside the deviceAvailable listener.

This async EventEmitter handler returns a promise nobody observes. If claimAvailableDevice() races or addDevice() throws, the rejection becomes unhandled and any successful claim is never released.

🛡️ Proposed fix
-        this.deviceManager.on(DeviceManagerEvent.deviceAvailable, async (deviceInfo: DeviceInfo) => {
+        this.deviceManager.on(DeviceManagerEvent.deviceAvailable, async (deviceInfo: DeviceInfo): Promise<void> => {
             if (!this.isSerialDeviceInfo(deviceInfo)) {
                 return;
             }

-            await this.deviceManager.claimAvailableDevice(deviceInfo.id);
-
-            const device = await this.connectToDevice(deviceInfo.portInfo);
-
-            if (undefined === device) {
-                this.deviceManager.freeClaimedDevice(deviceInfo.id);
-                return;
-            }
-
-            this.deviceManager.addDevice(device);
+            try {
+                await this.deviceManager.claimAvailableDevice(deviceInfo.id);
+            } catch {
+                return;
+            }
+
+            try {
+                const device = await this.connectToDevice(deviceInfo.portInfo);
+                if (undefined === device) {
+                    this.deviceManager.freeClaimedDevice(deviceInfo.id);
+                    return;
+                }
+
+                this.deviceManager.addDevice(device);
+            } catch (e: unknown) {
+                this.deviceManager.freeClaimedDevice(deviceInfo.id);
+                this.logger.error(`Failed to connect serial device '${deviceInfo.id}'`, e as Error);
+            }
         });
In Node.js `events.EventEmitter`, are rejected promises from `async` listeners handled automatically, or is `captureRejections` required?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/provider/serialDeviceProvider.ts` around lines 29 - 44, The event
listener passed to this.deviceManager.on is async and its promise can reject
unobserved; wrap the listener body in a try/catch (or attach a .catch) to handle
all errors, ensure any successful claim is always released on failure, and log
the error. Concretely, inside the DeviceManagerEvent.deviceAvailable handler
(the function that calls isSerialDeviceInfo, claimAvailableDevice,
connectToDevice, freeClaimedDevice, addDevice) introduce a boolean like
"claimed" set after claimAvailableDevice resolves, then try { const device =
await connectToDevice(...); if (!device) { freeClaimedDevice(id); return; }
addDevice(device); } catch (err) { if (claimed) freeClaimedDevice(id); /* log or
surface err via processLogger / this.logger */ } so no rejection escapes the
listener and claims are always cleaned up.
🧹 Nitpick comments (2)
src/device/protocol/estim2b/estim2bDevice.ts (1)

84-90: Avoid rebuilding the attribute map on every poll.

Line 86 replaces the mode-dependent attributes every 175ms, even when currentMode is unchanged. That adds steady allocation churn and can invalidate cached attribute references for no state change. Rebuild only when the mode actually changes.

♻️ Possible tweak
 public async refresh(): Promise<void> {
     const status = await this.send(this.protocol.createGetStatusCommand());
-    this.attributes = this.setModeBasedAttributes(status);
+    if (this.attributes.mode.value !== Int.from(status.currentMode)) {
+        this.attributes = this.setModeBasedAttributes(status);
+    }
     this.updateAttributeValues(status);
 
     await super.refresh();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/protocol/estim2b/estim2bDevice.ts` around lines 84 - 90, The
refresh() method rebuilds the mode-dependent attributes every poll which causes
allocation churn; change it to call setModeBasedAttributes only when the device
mode actually changes by comparing the new mode from the status (e.g.,
status.mode or the relevant field) with the currentMode stored on the device,
updating this.attributes and currentMode only on change, while always calling
updateAttributeValues(status) and await super.refresh(); use the existing
setModeBasedAttributes, attributes and currentMode symbols to locate and gate
the rebuild.
src/device/device.ts (1)

124-127: The base refresh() method is a silent no-op for actual polling logic, creating a maintenance risk.

Currently, if a device subclass doesn't override refresh(), it silently inherits only updateLastRefresh() with no active polling. While this works for event-driven devices like Zc95Device, it could hide implementation bugs in future polling-based devices. Consider either:

  1. Making refresh() abstract to enforce explicit override
  2. Implementing a template-method pattern with a protected executeRefresh() hook that subclasses implement, while the base class handles updateLastRefresh()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/device.ts` around lines 124 - 127, The current public refresh() is
a silent no-op (only calls updateLastRefresh()), which risks subclasses
forgetting to implement polling; change refresh() to a template method that
enforces subclass behavior: declare a protected abstract executeRefresh():
Promise<void> (or protected async executeRefresh(): Promise<void> in
non-abstract base if you prefer) and implement public async refresh():
Promise<void> to await executeRefresh() inside a try/finally that always calls
updateLastRefresh(); alternatively, if you want compile-time enforcement, make
refresh() abstract so subclasses must implement it (use the class' abstract
modifier and remove the base implementation).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/device/device.ts`:
- Line 5: The import for EventEmitter is from the wrong module; update the
import statement that currently imports EventEmitter from 'stream' to instead
import it from 'events' so it matches other usages and TypeScript/Node types
(look for the EventEmitter import at the top of src/device/device.ts and replace
the module source 'stream' with 'events').

In `@src/device/deviceManager.ts`:
- Around line 121-125: removeDevice currently deletes the device from
connectedDevices but does not stop the manager-owned refresh interval created by
initDeviceRefresher, so you must perform the same cleanup the device-emitted
disconnect path does: locate where initDeviceRefresher stores the per-device
timer (e.g., a map like this.deviceRefresherMap or a timer stored alongside
entries in this.connectedDevices), clearInterval for that timer and remove the
timer entry, remove any listeners set for that device, then delete from
this.connectedDevices and emit 'deviceDisconnected'; apply the identical fix to
the other removal spot referenced (the code around the 171-177 block) so both
code paths stop the refresher and remove listeners consistently.
- Around line 54-60: The addAvailableDevice method resets claim state by
unconditionally calling this.deviceClaimQueue.set(deviceInfo.id, []), which
drops existing owners/waiters used by claimAvailableDevice; change
addAvailableDevice so it updates/sets this.availableDevices as before but only
initializes this.deviceClaimQueue for deviceInfo.id if it does not already exist
(i.e., do not overwrite an existing queue), preserving any pending
promises/owners; keep emitting the 'deviceAvailable' event as-is.

In `@src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts`:
- Around line 111-113: The provider adds devices to this.connectedDevices and
registers them via this.deviceManager.addDevice(device) on connect, but the
disconnect path only removes from this.connectedDevices; update the
disconnect/remove code paths (the block paired with this.connectedDevices.set
and the other removal block at 122-135) to also unregister with the manager by
calling this.deviceManager.removeDevice(device) (or the manager's corresponding
unregister method) and trigger the device-disconnected lifecycle (e.g., call
device.deviceDisconnected() or this.deviceManager.deviceDisconnected(device) if
that’s the manager API) so DeviceManager state and lifecycle hooks stay in sync.

In `@src/device/protocol/messageResponseHandler.ts`:
- Line 38: The transport close path currently only wires transport.onReceive to
this.onResponse, leaving pendingEntries unresolved; add a handler for
transport.onClose (or equivalent close event) that iterates pendingEntries,
rejects each pending promise with a TransportClosedError (or a clear Error
including transport info), and then clears pendingEntries so in-flight requests
fail fast; ensure you reference transport.onClose, pendingEntries, and the
existing onResponse wiring and unregister any listeners if needed.

In `@src/device/protocol/virtual/virtualDeviceProvider.ts`:
- Around line 84-85: The provider's removeDevice() currently only clears
provider-local maps but doesn't unregister the device from the shared
DeviceManager, leaving the virtual device visible after deletion; update
removeDevice() to also call the DeviceManager removal API (e.g.,
deviceManager.removeDevice(...) with the same identifier/object used in
deviceManager.addDevice(device)) after the local maps are cleared so the central
DeviceManager no longer lists or refreshes the removed virtual device.

In `@src/device/transport/deviceTransport.ts`:
- Around line 20-36: Update the type signatures and event registration so async
handlers are supported and their rejections are observed: change the interface
methods onReceive and onClose to accept callbacks of type (data: Buffer) => void
| Promise<void> or (=> Promise<void>) (pick one consistent choice), then update
SerialDeviceTransport.onReceive and SerialDeviceTransport.onClose to accept that
async signature and wrap the provided handler before passing to Node streams
(e.g. const wrapped = (...args) => Promise.resolve(handler(...args)).catch(err
=> /* log/forward error */); this ensures async data handlers (used by
zc95Device, messageResponseHandler, peripheralDevice) are awaited/handled and
errors are not silently dropped). Ensure the wrapper is used for both data and
close/end/close events and that any logging uses the transport's logger.

In `@src/serial/synchronousSerialPort.ts`:
- Around line 39-41: The onClose method currently registers an async callback
(() => Promise<void>) directly with the EventEmitter via this.writer.on('close',
callback) which will not be awaited and can cause unhandled rejections and it
only listens to the writer; change onClose to accept a synchronous callback type
(() => void) or keep the async signature but wrap the callback when registering
to catch and log/reject safely (e.g., (/*evt*/) => { callback().catch(err =>
/*handle*/); }); and register that wrapped handler on both this.writer and
this.reader 'close' events so the callback fires when either stream closes;
update the onClose signature and usage of writer/reader accordingly (refer to
onClose, this.writer, this.reader, and the 'close' event).
- Around line 47-51: The close() method currently calls writer.end() and then
immediately writer.destroy() and reader.destroy(), which can truncate buffered
writes; change close() to either wait for the writer's 'finish' event after
calling writer.end() before destroying the streams (i.e., attach a one-time
'finish' listener to writer to then destroy writer and reader and cancel/clear
the queue) or, if you intentionally want a forceful shutdown, skip writer.end()
and only call writer.destroy() and reader.destroy() after first
cancelling/clearing the queue to avoid mid-flight task failures; update
references in close(), writer.end(), writer.destroy(), reader.destroy(), and the
queue cancellation logic accordingly and ensure any event listeners are cleaned
up.

In `@src/util/async.ts`:
- Around line 40-60: The timeout promise currently resolves the race and allows
loop() to schedule the next iteration even though the original fn(...args) may
still be running; change the logic in loop so that after Promise.race(promises)
returns you always await the original fn promise (e.g., track it as fnPromise
from the call to fn(...args)) before scheduling the next timer, and use the
timeout only to call options.onError or abort (if an AbortController is
supplied) but not to signal that iteration is complete; update symbols
timeoutHandle, promises, fnPromise (or create one), and loop so the next
setTimeout(loop, options.intervalMs) is only set after fnPromise settles (or
after it is aborted), preventing overlapping executions.
- Around line 41-50: The timeout callback currently calls resolve() and then
throws an Error (making the throw run after the promise is already settled);
change the Promise constructor to (resolve, reject) and do not call resolve() in
the timeout path—instead create the Error and call options.onError(error) if
present, then call reject(error) (or just reject(error) if no onError),
replacing the stray throw; update references to timeoutHandle and
options.timeoutMs accordingly so the timeout branch properly rejects the
promise.
- Around line 27-34: The loop() helper currently rethrows errors from
fn(...args) when options.onError is undefined, which leads to unhandled rejected
promises because loop() is invoked fire-and-forget via void loop() and
setTimeout(loop, ...); fix this by ensuring all rejections are consumed: inside
loop() replace the throw in the fn(...args).catch handler with a safe fallback
(e.g., call a default error handler, log via options.logger or swallow) so the
promise resolves, and when invoking loop() (the void loop() call and
setTimeout(loop, ...)) ensure any returned promise is handled by appending
.catch(...) or wrapping the call in an async IIFE that catches errors; reference
symbols: loop, fn, options.onError, void loop(), and setTimeout(loop, ...).

---

Duplicate comments:
In `@src/device/provider/serialDeviceProvider.ts`:
- Around line 29-44: The event listener passed to this.deviceManager.on is async
and its promise can reject unobserved; wrap the listener body in a try/catch (or
attach a .catch) to handle all errors, ensure any successful claim is always
released on failure, and log the error. Concretely, inside the
DeviceManagerEvent.deviceAvailable handler (the function that calls
isSerialDeviceInfo, claimAvailableDevice, connectToDevice, freeClaimedDevice,
addDevice) introduce a boolean like "claimed" set after claimAvailableDevice
resolves, then try { const device = await connectToDevice(...); if (!device) {
freeClaimedDevice(id); return; } addDevice(device); } catch (err) { if (claimed)
freeClaimedDevice(id); /* log or surface err via processLogger / this.logger */
} so no rejection escapes the listener and claims are always cleaned up.

---

Nitpick comments:
In `@src/device/device.ts`:
- Around line 124-127: The current public refresh() is a silent no-op (only
calls updateLastRefresh()), which risks subclasses forgetting to implement
polling; change refresh() to a template method that enforces subclass behavior:
declare a protected abstract executeRefresh(): Promise<void> (or protected async
executeRefresh(): Promise<void> in non-abstract base if you prefer) and
implement public async refresh(): Promise<void> to await executeRefresh() inside
a try/finally that always calls updateLastRefresh(); alternatively, if you want
compile-time enforcement, make refresh() abstract so subclasses must implement
it (use the class' abstract modifier and remove the base implementation).

In `@src/device/protocol/estim2b/estim2bDevice.ts`:
- Around line 84-90: The refresh() method rebuilds the mode-dependent attributes
every poll which causes allocation churn; change it to call
setModeBasedAttributes only when the device mode actually changes by comparing
the new mode from the status (e.g., status.mode or the relevant field) with the
currentMode stored on the device, updating this.attributes and currentMode only
on change, while always calling updateAttributeValues(status) and await
super.refresh(); use the existing setModeBasedAttributes, attributes and
currentMode symbols to locate and gate the rebuild.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8cd86dcc-aaf1-4b30-983c-ffca580c25f9

📥 Commits

Reviewing files that changed from the base of the PR and between bc170dd and 6ff32eb.

📒 Files selected for processing (36)
  • eslint.config.ts
  • src/automation/scriptRuntime.ts
  • src/device/device.ts
  • src/device/deviceManager.ts
  • src/device/deviceManagerEvent.ts
  • src/device/genericDeviceUpdater.ts
  • src/device/peripheralDevice.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/estim2bDevice.ts
  • src/device/protocol/estim2b/estim2bDeviceFactory.ts
  • src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts
  • src/device/protocol/messageResponseHandler.ts
  • src/device/protocol/slvCtrlPlus/genericSlvCtrlPlusDevice.ts
  • src/device/protocol/slvCtrlPlus/slvCtrlPlusDevice.ts
  • src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts
  • src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts
  • src/device/protocol/virtual/audio/piperVirtualDeviceLogic.ts
  • src/device/protocol/virtual/audio/ttsVirtualDeviceLogic.ts
  • src/device/protocol/virtual/genericVirtualDeviceFactory.ts
  • src/device/protocol/virtual/virtualDevice.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/virtual/virtualDeviceProviderFactory.ts
  • src/device/protocol/zc95/zc95Device.ts
  • src/device/protocol/zc95/zc95DeviceFactory.ts
  • src/device/protocol/zc95/zc95SerialDeviceProvider.ts
  • src/device/provider/deviceProvider.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/device/transport/deviceTransport.ts
  • src/device/transport/serialDeviceTransport.ts
  • src/index.ts
  • src/serial/synchronousSerialPort.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • src/util/async.ts
💤 Files with no reviewable changes (1)
  • src/device/deviceManagerEvent.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/index.ts

Comment thread src/device/device.ts Outdated
Comment thread src/device/deviceManager.ts Outdated
Comment thread src/device/deviceManager.ts Outdated
Comment thread src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts
Comment thread src/device/protocol/messageResponseHandler.ts Outdated
Comment thread src/device/transport/deviceTransport.ts Outdated
Comment thread src/serial/synchronousSerialPort.ts
Comment thread src/util/async.ts Outdated
Comment thread src/util/async.ts
Comment thread src/util/async.ts Outdated
@heavyrubberslave

Copy link
Copy Markdown
Member Author

@coderabbitai please review the latest commit and also check the open comments. If something has been solved of your open suggestions by this commit, please close the conversation. :-)

@coderabbitai

coderabbitai Bot commented Mar 13, 2026

Copy link
Copy Markdown
🧩 Analysis chain

@heavyrubberslave: I'll trigger a review of the latest commit right away and check your open comments to close any that have been resolved!


🏁 Script executed:

#!/bin/bash
# Fetch open review comments on PR `#72`
gh pr view 72 --repo SlvCtrlPlus/slvctrlplus-server --json reviews,comments
gh api repos/SlvCtrlPlus/slvctrlplus-server/pulls/72/comments --jq '.[] | {id: .id, body: .body, resolved: .resolved, path: .path, line: .line, user: .login}'

Length of output: 154147


@heavyrubberslave: Happy to check! Let me look at the current state of removeDevice to verify whether making it private fully addresses the open concern.


🏁 Script executed:

#!/bin/bash
# Check the current state of deviceManager.ts - specifically removeDevice and initDeviceRefresher
echo "=== Latest git log ==="
git log --oneline -5

echo ""
echo "=== Current deviceManager.ts (full file) ==="
cat src/device/deviceManager.ts

Length of output: 6299


@heavyrubberslave: Yes, making removeDevice (and refreshDevice) private fully resolves the concern! Here's why:

With removeDevice private, device removal can only happen through the DeviceEvent.deviceDisconnected event listener registered in addDevice(). That same disconnect event also triggers the deviceRefreshInterval.clear() listener registered in initDeviceRefresher(). Since both listeners are attached to the same event, they are guaranteed to fire together — there is no longer any code path that removes the device from connectedDevices without also stopping the refresh loop.

The architectural change is clean and actually stronger than my original suggested fix (which tracked refreshers in a separate map). Making the methods private enforces the invariant at the type level.

I also noticed the broader API cleanup in the latest commit is excellent:

  • acquireAvailableDevice returning ClaimResult (instead of rejecting) eliminates the unhandled promise rejection concern from the claim race
  • releaseAvailableDevice correctly advances the next waiter
  • addAvailableDevice now preserves existing queues with the has() guard

I've triggered a review of the latest commit to check any remaining open items. 🐇

✅ Actions performed

Review triggered.

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.

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

60-96: ⚠️ Potential issue | 🟠 Major

Await the port close operation before releasing the claim to prevent concurrent access.

The port.close() call at line 87 is fire-and-forget (callback-based, not awaited). Since connectToDevice() returns immediately and the caller then releases the device claim at line 46, the next provider can begin opening the same port path while the previous file descriptor is still closing. This mirrors the same pattern correctly applied to port.open() at line 73-75.

Possible fix
         if (undefined === result) {
             if (port.isOpen) {
-                port.close();
+                try {
+                    await new Promise<void>((resolve, reject) => {
+                        port.close(err => err ? reject(err) : resolve());
+                    });
+                } catch (closeErr) {
+                    this.logger.warn(
+                        `Could not fully close serial device '${portInfo.path}' after failed connect: ${BaseError.normalize(closeErr).message}`
+                    );
+                }
             }
             this.logger.info(`Could not connect to serial device '${portInfo.path}': ${attemptFailureReason}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/provider/serialDeviceProvider.ts` around lines 60 - 96,
connectToDevice currently calls port.close() fire-and-for-get, which can allow a
new claimant to open the same serial path before the descriptor is actually
closed; change the close call in connectToDevice to await the close operation
(wrap port.close callback in a Promise like the port.open usage), only call it
if port.isOpen, and handle/log rejection so the promise never leaves an
unawaited callback—apply this to the close path where result === undefined and
keep existing logging behavior in connectToDevice / preparePort /
connectSerialDevice.
♻️ Duplicate comments (1)
src/util/async.ts (1)

40-60: ⚠️ Potential issue | 🔴 Critical

This still stops via an unhandled rejected promise when onError is omitted.

throw err rejects loop(), but both launch sites (void loop() and setTimeout(loop, ...)) ignore that promise. The interval does stop, but it stops through an unobserved rejection instead of a controlled failure path. Either require onError or catch every loop() invocation and stop explicitly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/util/async.ts` around lines 40 - 60, The loop() promise can reject (when
options.onError is absent) and both call sites ignore that rejection; change
both invocations so they handle rejections instead of letting an unhandled
rejection stop the interval: replace the direct call sites for loop (the
immediate launch at options.runImmediately and the delayed call via setTimeout
with timer) to call loop().catch(err => { if (options.onError)
options.onError(err); clearTimeout(timer); /* stop scheduling further runs */
}), and for the setTimeout case invoke loop inside the timer callback and attach
the same .catch handler; this ensures the interval is stopped in a controlled
way when loop rejects even if onError is not provided.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/device/deviceManager.ts`:
- Around line 56-66: The bug is that availableDeviceClaimQueue retains an empty
array after the final claimant calls shift(), preventing addAvailableDevice from
re-emitting deviceAvailable for the same id; in the release/claimant-removal
code (the method that calls shift() / releases a claimant — review the code
around lines 88-104 and any function handling claim releases), after removing a
claimant with shift() check if the array is now empty and if so call
availableDeviceClaimQueue.delete(deviceId) so the entry is removed; this ensures
addAvailableDevice(deviceInfo) will not early-return on has(deviceInfo.id) and
will emit deviceAvailable on rediscovery/unplug-replug.
- Around line 106-127: The addDevice -> claimAvailableDevice sequence is
fragile: listener exceptions thrown during deviceConnected emission can prevent
claimAvailableDevice from running and leave the device stuck in
connectedDevices. Fix by (1) making addDevice resilient to listener errors: wrap
the this.eventEmitter.emit('deviceConnected', device) call in a try/catch and
log the listener error instead of letting it propagate, and (2) defensively
update the success path in serialDeviceProvider (the code that calls
addDevice(...) then claimAvailableDevice(...)) to use a try/finally so
claimAvailableDevice(deviceId) is always executed even if addDevice throws;
reference the addDevice and claimAvailableDevice methods and the success path in
serialDeviceProvider when applying the changes.

---

Outside diff comments:
In `@src/device/provider/serialDeviceProvider.ts`:
- Around line 60-96: connectToDevice currently calls port.close()
fire-and-for-get, which can allow a new claimant to open the same serial path
before the descriptor is actually closed; change the close call in
connectToDevice to await the close operation (wrap port.close callback in a
Promise like the port.open usage), only call it if port.isOpen, and handle/log
rejection so the promise never leaves an unawaited callback—apply this to the
close path where result === undefined and keep existing logging behavior in
connectToDevice / preparePort / connectSerialDevice.

---

Duplicate comments:
In `@src/util/async.ts`:
- Around line 40-60: The loop() promise can reject (when options.onError is
absent) and both call sites ignore that rejection; change both invocations so
they handle rejections instead of letting an unhandled rejection stop the
interval: replace the direct call sites for loop (the immediate launch at
options.runImmediately and the delayed call via setTimeout with timer) to call
loop().catch(err => { if (options.onError) options.onError(err);
clearTimeout(timer); /* stop scheduling further runs */ }), and for the
setTimeout case invoke loop inside the timer callback and attach the same .catch
handler; this ensures the interval is stopped in a controlled way when loop
rejects even if onError is not provided.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 52911bbb-3f5c-4a3e-ac01-76a01e1d096a

📥 Commits

Reviewing files that changed from the base of the PR and between 6ff32eb and 9714158.

📒 Files selected for processing (19)
  • src/device/device.ts
  • src/device/deviceManager.ts
  • src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts
  • src/device/protocol/estim2b/estim2bDeviceFactory.ts
  • src/device/protocol/messageResponseHandler.ts
  • src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts
  • src/device/protocol/virtual/genericVirtualDeviceFactory.ts
  • src/device/protocol/virtual/virtualDeviceProviderFactory.ts
  • src/device/protocol/zc95/zc95DeviceFactory.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/factory/eventEmitterFactory.ts
  • src/serial/synchronousSerialPort.ts
  • src/serviceMap.ts
  • src/serviceProvider/automationServiceProvider.ts
  • src/serviceProvider/deviceServiceProvider.ts
  • src/serviceProvider/factoryServiceProvider.ts
  • src/serviceProvider/settingsServiceProvider.ts
  • src/util/async.ts
  • src/util/devNullStream.ts
💤 Files with no reviewable changes (1)
  • src/util/devNullStream.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/device/protocol/estim2b/estim2bDeviceFactory.ts
  • src/device/device.ts
  • src/serial/synchronousSerialPort.ts
  • src/device/protocol/messageResponseHandler.ts
  • src/device/protocol/virtual/virtualDeviceProviderFactory.ts

Comment thread src/device/deviceManager.ts Outdated
Comment thread src/device/deviceManager.ts
@heavyrubberslave

Copy link
Copy Markdown
Member Author

@coderabbitai please check latest commit, it should fix another few of the open discussions. Check the changes against the open discussions and tell me which ones you close because the latest commit fixed them.

@coderabbitai

coderabbitai Bot commented Mar 13, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Latest git log ==="
git log --oneline -5

echo ""
echo "=== Latest commit diff ==="
git diff HEAD~1 HEAD --stat

echo ""
echo "=== Open review comments on PR `#72` ==="
gh api repos/SlvCtrlPlus/slvctrlplus-server/pulls/72/comments \
  --jq '.[] | {id: .id, path: .path, line: .line, body: .body, resolved: .resolved}' 2>/dev/null | head -300

echo ""
echo "=== PR review threads ==="
gh api graphql -f query='
{
  repository(owner: "SlvCtrlPlus", name: "slvctrlplus-server") {
    pullRequest(number: 72) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 1) {
            nodes {
              body
              path
              line
              author { login }
            }
          }
        }
      }
    }
  }
}' 2>/dev/null

Length of output: 114959


🏁 Script executed:

#!/bin/bash
echo "=== Latest commit message ==="
git show 242fbac --stat --format="%s%n%b"

echo ""
echo "=== buttplugIoWebsocketDeviceProvider.ts ==="
cat -n src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts

echo ""
echo "=== virtualDeviceProvider.ts ==="
cat -n src/device/protocol/virtual/virtualDeviceProvider.ts

echo ""
echo "=== deviceTransport.ts ==="
cat -n src/device/transport/deviceTransport.ts

echo ""
echo "=== synchronousSerialPort.ts (onClose section) ==="
cat -n src/serial/synchronousSerialPort.ts

echo ""
echo "=== deviceManager.ts addDevice / claimAvailableDevice section ==="
cat -n src/device/deviceManager.ts

Length of output: 26266


🏁 Script executed:

#!/bin/bash
# Resolve threads 1 (buttplugIo) and 2 (virtual) via GraphQL
gh api graphql -f query='
mutation {
  resolveReviewThread(input: { threadId: "PRRT_kwDOHZZYrc5zyGR4" }) {
    thread { id isResolved }
  }
}'

gh api graphql -f query='
mutation {
  resolveReviewThread(input: { threadId: "PRRT_kwDOHZZYrc5zyGSB" }) {
    thread { id isResolved }
  }
}'

# Also check serialDeviceTransport and serialDeviceProvider for context on thread 3 and 5
echo "=== serialDeviceTransport.ts ==="
cat -n src/device/transport/serialDeviceTransport.ts

echo ""
echo "=== serialDeviceProvider.ts ==="
cat -n src/device/provider/serialDeviceProvider.ts

Length of output: 8099


@heavyrubberslave: Here's a full breakdown of commit 242fbac against all open discussions:


✅ Closed by the latest commit

1. Mirror addDevice() with manager-side remove on disconnect — buttplugIoWebsocketDeviceProvider.ts
removeButtplugIoDevice now calls await device.close(). Since PeripheralDevice.close() emits DeviceEvent.deviceDisconnected, and DeviceManager.addDevice() registers device.on(DeviceEvent.deviceDisconnected, (d) => this.removeDevice(d)), the manager is cleaned up automatically — without needing to call the now-private removeDevice() directly. Resolved ✅

2. Mirror DeviceManager registration on virtual-device removal — virtualDeviceProvider.ts
removeDevice() now calls await device.close() first (line 94), which triggers the same event chain as above. Resolved ✅


⚠️ Partially addressed — still open

3. Type signature mismatch in DeviceTransportdeviceTransport.ts (PRRT_kwDOHZZYrc5zyGSC)
The primary issue (LSP violation: interface declared Promise<void> but SerialDeviceTransport.onReceive used void) is now fixed — both interface and implementation use (data: Buffer) => void. However, onClose still accepts () => Promise<void> but forwards the callback directly to Node's EventEmitter, which does not await it. If the callback rejects, it becomes an unhandled promise rejection.

4. Async callback not awaited by event emitter — synchronousSerialPort.ts (PRRT_kwDOHZZYrc5zyGSF)
The "only listens to writer's close" part is fixed — the latest commit added this.reader.on('close', callback) so both streams are covered. The remaining concern is that callback: () => Promise<void> is passed directly to Node's EventEmitter.on() without being wrapped, so rejections are still silently dropped.


🔴 Still open

5. addDevice() + claimAvailableDevice() not atomic — serialDeviceProvider.ts (PRRT_kwDOHZZYrc50OUev)
Lines 50–51 in serialDeviceProvider.ts still call addDevice() then claimAvailableDevice() without a try/finally guard. If a deviceConnected listener throws inside addDevice() (which calls this.eventEmitter.emit('deviceConnected', device) synchronously), claimAvailableDevice() will never execute, leaving other providers blocked indefinitely in the claim queue.

A minimal fix:

try {
    this.deviceManager.addDevice(device);
} finally {
    this.deviceManager.claimAvailableDevice(deviceInfo.id);
}

@heavyrubberslave

Copy link
Copy Markdown
Member Author

@coderabbitai please review the newest commit. If this commit solves some of the open discussions, please resolve them and write a summary to this comment here with all closed discussions due to the latest commit.

@coderabbitai

coderabbitai Bot commented Mar 13, 2026

Copy link
Copy Markdown
🧩 Analysis chain

Let me check the latest commit and the current state of open discussions.


🏁 Script executed:

#!/bin/bash
echo "=== Latest git log ==="
git log --oneline -5

echo ""
echo "=== Latest commit diff stat ==="
git diff HEAD~1 HEAD --stat

echo ""
echo "=== Latest commit full diff ==="
git diff HEAD~1 HEAD

echo ""
echo "=== Open review threads ==="
gh api graphql -f query='
{
  repository(owner: "SlvCtrlPlus", name: "slvctrlplus-server") {
    pullRequest(number: 72) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 3) {
            nodes {
              body
              path
              line
              author { login }
            }
          }
        }
      }
    }
  }
}' 2>/dev/null

Length of output: 104458


Let me resolve the two threads that the latest commit addresses.


🏁 Script executed:

#!/bin/bash
# Resolve PRRT_kwDOHZZYrc5zyGSF (synchronousSerialPort async callback)
gh api graphql -f query='
mutation {
  resolveReviewThread(input: { threadId: "PRRT_kwDOHZZYrc5zyGSF" }) {
    thread { id isResolved }
  }
}'

# Resolve PRRT_kwDOHZZYrc5zyGSC (DeviceTransport type signature mismatch)
gh api graphql -f query='
mutation {
  resolveReviewThread(input: { threadId: "PRRT_kwDOHZZYrc5zyGSC" }) {
    thread { id isResolved }
  }
}'

Length of output: 184


@heavyrubberslave: Commit 271b333 is solid work — here's the full breakdown:


✅ Closed by this commit

1. Async callback not awaited in synchronousSerialPort.ts (PRRT_kwDOHZZYrc5zyGSF) — Resolved ✅

The new asyncHandler utility now wraps both the writer and reader 'close' callbacks:

this.writer.on('close', asyncHandler(callback, (e) => logError(this.logger, `Error in writer close handler`, e)));
this.reader.on('close', asyncHandler(callback, (e) => logError(this.logger, `Error in reader close handler`, e)));

Both of the original concerns are now fixed: rejections are caught and logged via logError, and both streams are covered.

2. Type signature mismatch / async callbacks not observed in deviceTransport.ts (PRRT_kwDOHZZYrc5zyGSC) — Resolved ✅

The introduction of asyncHandler throughout the codebase (in serialDeviceProvider, buttplugIoWebsocketDeviceProvider, etc.) provides the canonical pattern: async callbacks are always wrapped into void-returning functions before being registered on EventEmitters. The new @typescript-eslint/no-misused-promises: error ESLint rule enforces this at compile time going forward, preventing any async handler from accidentally being passed unwrapped to an EventEmitter.


🔴 Still open

3. addDevice() + claimAvailableDevice() not atomic in serialDeviceProvider.ts (PRRT_kwDOHZZYrc50OUev)

In handleDeviceDetection, the success path still calls these two sequentially without a guard:

this.deviceManager.addDevice(device);          // emits deviceConnected synchronously
this.deviceManager.claimAvailableDevice(deviceInfo.id); // never runs if addDevice throws

The asyncHandler wrapper will catch the overall rejection from handleDeviceDetection, but if a deviceConnected listener throws inside addDevice, claimAvailableDevice is skipped — leaving the claim queue entry hanging and blocking other providers. A try/finally guard would fix this:

try {
    this.deviceManager.addDevice(device);
} finally {
    this.deviceManager.claimAvailableDevice(deviceInfo.id);
}
✅ Actions performed

Review triggered.

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.

@coderabbitai

coderabbitai Bot commented Mar 13, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Latest git log ==="
git log --oneline -5

echo ""
echo "=== Latest commit diff stat ==="
git diff HEAD~1 HEAD --stat

echo ""
echo "=== Latest commit full diff ==="
git diff HEAD~1 HEAD

echo ""
echo "=== Open review threads ==="
gh api graphql -f query='
{
  repository(owner: "SlvCtrlPlus", name: "slvctrlplus-server") {
    pullRequest(number: 72) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 3) {
            nodes {
              body
              path
              line
              author { login }
            }
          }
        }
      }
    }
  }
}' 2>/dev/null

Length of output: 104501


@heavyrubberslave: I've checked the latest commit (271b333) against all open discussions. Here's the full picture:


✅ Closed by this commit

None of the previously unresolved discussions are newly closed by this commit — all other threads had already been resolved by prior commits.


What this commit does bring

This commit is a strong architectural improvement:

  • New asyncHandler utility in src/util/async.ts — provides a canonical, type-safe way to wrap any async function before passing it to an EventEmitter, making rejections explicit and observed via an onError callback.
  • @typescript-eslint/no-misused-promises: error — now enforced at compile time. Any accidental unwrapped async handler passed to an EventEmitter will be a build error.
  • @typescript-eslint/explicit-function-return-type: error — catches implicit any-returning functions that previously hid type mismatches (e.g. getDeviceInfo, getAttributes in slvCtrlPlusDeviceFactory.ts).
  • src/util/error.ts with logError — consistent error normalization using BaseError.normalize.
  • DeviceManagerEvent.deviceAvailable renamed to DeviceManagerEvent.deviceDetected for clarity.

🔴 Still open

PRRT_kwDOHZZYrc50OUevaddDevice() + claimAvailableDevice() not atomic in serialDeviceProvider.ts

The asyncHandler wrapper correctly catches any exception that escapes handleDeviceDetection, but the root issue is inside handleDeviceDetection:

// serialDeviceProvider.ts
this.deviceManager.addDevice(device);           // emits deviceConnected synchronously
this.deviceManager.claimAvailableDevice(deviceInfo.id); // skipped if addDevice() throws

If a synchronous deviceConnected listener throws inside addDevice(), claimAvailableDevice() is never called. The asyncHandler catches the overall rejection, but the claim queue entry for this device is now permanently stuck — blocking all future providers from ever acquiring the same device id.

A try/finally guard is still needed here:

try {
    this.deviceManager.addDevice(device);
} finally {
    this.deviceManager.claimAvailableDevice(deviceInfo.id);
}
✅ Actions performed

Review triggered.

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.

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

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/virtual/virtualDeviceProvider.ts (1)

91-101: ⚠️ Potential issue | 🟠 Major

Ensure provider-local cleanup still runs when device.close() fails.

Right now a thrown close aborts map cleanup and disconnect emission, leaving stale entries in connectedDevices/attemptedDevices.

🧩 Suggested fix (cleanup in finally)
 private async removeDevice(device: Device): Promise<void> {
     const deviceId = device.getDeviceId;
-
-    await device.close();
-    this.connectedDevices.delete(deviceId);
-    this.attemptedDevices.delete(deviceId);
-
-    this.eventEmitter.emit(DeviceProviderEvent.deviceDisconnected, device);
-
-    this.logger.info(`Device removed: ${deviceId} (${device.getDeviceName})`);
-    this.logger.info(`Connected devices: ${this.connectedDevices.size.toString()}`);
+    try {
+        await device.close();
+    } catch (e: unknown) {
+        this.logger.error(`Could not close virtual device '${deviceId}': ${(e as Error).message}`, e);
+    } finally {
+        this.connectedDevices.delete(deviceId);
+        this.attemptedDevices.delete(deviceId);
+        this.eventEmitter.emit(DeviceProviderEvent.deviceDisconnected, device);
+        this.logger.info(`Device removed: ${deviceId} (${device.getDeviceName})`);
+        this.logger.info(`Connected devices: ${this.connectedDevices.size.toString()}`);
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/protocol/virtual/virtualDeviceProvider.ts` around lines 91 - 101,
The removeDevice method currently calls device.close() and if that throws it
prevents local cleanup and the disconnect event; change removeDevice (function
removeDevice) to wrap device.close() in try/catch and perform the
connectedDevices.delete(deviceId), attemptedDevices.delete(deviceId),
eventEmitter.emit(DeviceProviderEvent.deviceDisconnected, device) and
logger.info calls in a finally block so provider-local cleanup and event
emission always run; in the catch log the close error (including
device.getDeviceId / device.getDeviceName for context) via this.logger.error
before rethrowing or continuing as appropriate.
♻️ Duplicate comments (1)
src/device/deviceManager.ts (1)

107-119: ⚠️ Potential issue | 🟠 Major

Uncaught deviceConnected listener errors can still strand claim finalization.

emit() is synchronous; if a listener throws here, provider code can skip the subsequent claimAvailableDevice(...) step and leave queue state hanging.

#!/bin/bash
# Verify unresolved addDevice -> claimAvailableDevice atomicity in serial provider.
echo "=== deviceManager emit path ==="
rg -nP --type=ts -C3 "eventEmitter\\.emit\\('deviceConnected'|eventEmitter\\.emit\\(\"deviceConnected\"" src/device/deviceManager.ts

echo -e "\n=== serial provider occurrences ==="
rg -nP --type=ts -C8 "\\baddDevice\\s*\\(|\\bclaimAvailableDevice\\s*\\(" src/device/provider/serialDeviceProvider.ts

echo -e "\n=== check for try/finally around success path ==="
rg -nP --type=ts -C8 "\\btry\\b|\\bfinally\\b" src/device/provider/serialDeviceProvider.ts
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/deviceManager.ts` around lines 107 - 119, addDevice currently
calls this.eventEmitter.emit('deviceConnected', device) synchronously which can
throw and prevent callers (e.g., serial provider) from completing the subsequent
claimAvailableDevice flow; wrap the emit call in a try/catch, and on error do
not rethrow synchronously — instead log/report the error and rethrow
asynchronously (e.g., setImmediate(() => { throw err }) or process.nextTick) so
the addDevice method returns normally and callers can proceed to
claimAvailableDevice; update the addDevice method (around eventEmitter.emit),
keeping existing calls to this.initDeviceRefresher, device.on(DeviceEvent...),
refreshDevice and removeDevice unchanged.
🧹 Nitpick comments (2)
src/device/protocol/buttplugIo/slvCtrlPlusButtplugWebsocketClientConnector.ts (1)

1-1: Remove file-level eslint disable and refactor open listener to use sync wrapper with internal async IIFE.

Line 1 suppresses the rule for the entire file, but only Line 18's async event handler actually triggers the violation. Replace the async handler with a sync wrapper that contains an internal async IIFE.

Proposed refactor
-/* eslint-disable `@typescript-eslint/no-misused-promises` */
 import { ButtplugNodeWebsocketClientConnector } from 'buttplug';
@@
-            ws.addEventListener('open', async (): Promise<void> => {
-                this._ws = ws;
-                try {
-                    await this.initialize();
-                    this._ws.addEventListener('message', (msg) => {
-                        this.parseIncomingMessage(msg);
-                    });
-                    this._ws.removeEventListener('close', onCloseCallback);
-                    this._ws.removeEventListener('error', onErrorCallback);
-                    this._ws.addEventListener('close', this.disconnect);
-                    resolve();
-                } catch (e) {
-                    reject(e);
-                }
-            });
+            ws.addEventListener('open', (): void => {
+                void (async (): Promise<void> => {
+                    this._ws = ws;
+                    try {
+                        await this.initialize();
+                        this._ws.addEventListener('message', (msg) => {
+                            this.parseIncomingMessage(msg);
+                        });
+                        this._ws.removeEventListener('close', onCloseCallback);
+                        this._ws.removeEventListener('error', onErrorCallback);
+                        this._ws.addEventListener('close', this.disconnect);
+                        resolve();
+                    } catch (e) {
+                        reject(e);
+                    }
+                })();
+            });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/device/protocol/buttplugIo/slvCtrlPlusButtplugWebsocketClientConnector.ts`
at line 1, Remove the file-level eslint disable and refactor the async event
handler for the "open" listener in slvCtrlPlusButtplugWebsocketClientConnector:
delete the top-line "/* eslint-disable `@typescript-eslint/no-misused-promises`
*/", replace the async callback passed to the "open" event with a synchronous
wrapper that immediately starts an internal async IIFE (e.g., socket.on("open",
() => { (async () => { ... })().catch(err => { /* handle */ }); })), and ensure
any thrown errors are caught/handled inside the IIFE so the listener is
synchronous and no rule is violated.
src/device/provider/serialDeviceProvider.ts (1)

99-99: Stale TODO comment should be addressed or removed.

The TODO // todo: freigeben! ("release!") appears to be a leftover. The release logic is already handled by the caller in handleDeviceDetection (line 56). Either remove this comment or clarify its intent if additional cleanup is needed here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/provider/serialDeviceProvider.ts` at line 99, Remove the stale
TODO comment "// todo: freigeben!" in serialDeviceProvider.ts or replace it with
a short clarifying comment; since release/cleanup is already performed by the
caller handleDeviceDetection, either delete the vestigial TODO or update it to
state that resource release is handled by handleDeviceDetection so future
readers won't expect release logic in the serial device provider.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/device/device.ts`:
- Around line 149-152: The close() method currently emits
DeviceEvent.deviceDisconnected every time it's called; make close() idempotent
by adding a boolean flag (e.g. private _closed = false on the Device class) and
at the start of close() check if _closed is true and return early, otherwise set
_closed = true and then emit DeviceEvent.deviceDisconnected (and perform any
other cleanup once). Update the Device class state (not just emit) so subsequent
close() calls are no-ops.

In `@src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts`:
- Around line 146-157: The removal flow currently only calls
this.connectedDevices.delete(buttplugDevice.index) after await device.close(),
so a thrown error leaves a stale entry; ensure the provider-local entry is
always removed by deleting this.connectedDevices.delete(buttplugDevice.index)
regardless of close() outcome (e.g., call delete before awaiting device.close()
or perform the delete in a finally block), keep the logger.error path intact to
record close() failures, and continue to use device.getDeviceId and
buttplugDevice.name/index values for logging.
- Around line 85-96: The interval refs (this.connectionIntervalRef,
this.autoScanningIntervalRef) are not being cleared to undefined after
clearInterval, causing subsequent init() calls to skip re-creating intervals due
to the ??= checks; update connectToServer() to set this.connectionIntervalRef =
undefined immediately after clearInterval(...) in that method, and update
handleLostConnection() to set both this.autoScanningIntervalRef = undefined and
this.connectionIntervalRef = undefined immediately after their
clearInterval(...) calls so future init() runs will recreate the intervals as
intended (refer to methods connectToServer(), handleLostConnection(), and
properties connectionIntervalRef/autoScanningIntervalRef).

In `@src/device/provider/serialDeviceProvider.ts`:
- Around line 60-61: Wrap the addDevice + claimAvailableDevice sequence in a
try/finally to ensure claimAvailableDevice(deviceInfo.id) always runs even if
this.deviceManager.addDevice(device) throws; specifically, call
this.deviceManager.addDevice(device) inside a try block and move
this.deviceManager.claimAvailableDevice(deviceInfo.id) into the finally block,
preserving and rethrowing any caught exception so callers still see failures.
Use the existing deviceManager, addDevice, claimAvailableDevice and deviceInfo
symbols to locate the code in serialDeviceProvider.ts.

In `@src/serial/synchronousSerialPort.ts`:
- Around line 41-44: The onClose implementation currently registers the same
callback on both this.writer and this.reader so shutdown that closes both
streams calls the callback twice; modify onClose (and the similar block at
50-55) to wrap the provided callback in a one-time guard: create a local
"called" flag (or use a once wrapper) and register the same wrapper function
with asyncHandler for both this.writer.on('close', ...) and
this.reader.on('close', ...); inside the wrapper ensure it only invokes the
original callback once, and after the first invocation remove the other listener
(or no-op subsequent calls) to prevent duplicate cleanup; reference onClose,
this.writer, this.reader, asyncHandler, and logError/logger when making the
change.

---

Outside diff comments:
In `@src/device/protocol/virtual/virtualDeviceProvider.ts`:
- Around line 91-101: The removeDevice method currently calls device.close() and
if that throws it prevents local cleanup and the disconnect event; change
removeDevice (function removeDevice) to wrap device.close() in try/catch and
perform the connectedDevices.delete(deviceId),
attemptedDevices.delete(deviceId),
eventEmitter.emit(DeviceProviderEvent.deviceDisconnected, device) and
logger.info calls in a finally block so provider-local cleanup and event
emission always run; in the catch log the close error (including
device.getDeviceId / device.getDeviceName for context) via this.logger.error
before rethrowing or continuing as appropriate.

---

Duplicate comments:
In `@src/device/deviceManager.ts`:
- Around line 107-119: addDevice currently calls
this.eventEmitter.emit('deviceConnected', device) synchronously which can throw
and prevent callers (e.g., serial provider) from completing the subsequent
claimAvailableDevice flow; wrap the emit call in a try/catch, and on error do
not rethrow synchronously — instead log/report the error and rethrow
asynchronously (e.g., setImmediate(() => { throw err }) or process.nextTick) so
the addDevice method returns normally and callers can proceed to
claimAvailableDevice; update the addDevice method (around eventEmitter.emit),
keeping existing calls to this.initDeviceRefresher, device.on(DeviceEvent...),
refreshDevice and removeDevice unchanged.

---

Nitpick comments:
In
`@src/device/protocol/buttplugIo/slvCtrlPlusButtplugWebsocketClientConnector.ts`:
- Line 1: Remove the file-level eslint disable and refactor the async event
handler for the "open" listener in slvCtrlPlusButtplugWebsocketClientConnector:
delete the top-line "/* eslint-disable `@typescript-eslint/no-misused-promises`
*/", replace the async callback passed to the "open" event with a synchronous
wrapper that immediately starts an internal async IIFE (e.g., socket.on("open",
() => { (async () => { ... })().catch(err => { /* handle */ }); })), and ensure
any thrown errors are caught/handled inside the IIFE so the listener is
synchronous and no rule is violated.

In `@src/device/provider/serialDeviceProvider.ts`:
- Line 99: Remove the stale TODO comment "// todo: freigeben!" in
serialDeviceProvider.ts or replace it with a short clarifying comment; since
release/cleanup is already performed by the caller handleDeviceDetection, either
delete the vestigial TODO or update it to state that resource release is handled
by handleDeviceDetection so future readers won't expect release logic in the
serial device provider.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 56baeeba-d669-4861-a7d7-7cf4b1133a39

📥 Commits

Reviewing files that changed from the base of the PR and between 9714158 and 271b333.

📒 Files selected for processing (20)
  • eslint.config.ts
  • src/device/attribute/numberDeviceAttribute.ts
  • src/device/device.ts
  • src/device/deviceManager.ts
  • src/device/peripheralDevice.ts
  • src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts
  • src/device/protocol/buttplugIo/slvCtrlPlusButtplugWebsocketClientConnector.ts
  • src/device/protocol/messageResponseHandler.ts
  • src/device/protocol/slvCtrlPlus/slvCtrlPlusDeviceFactory.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/zc95/Zc95SerialReader.ts
  • src/device/protocol/zc95/zc95Device.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/device/transport/deviceTransport.ts
  • src/entity/automationScript.ts
  • src/serial/frameParser.ts
  • src/serial/synchronousSerialPort.ts
  • src/util/async.ts
  • src/util/devNullStream.ts
  • src/util/error.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • eslint.config.ts
  • src/device/transport/deviceTransport.ts
  • src/util/async.ts

Comment thread src/device/device.ts
Comment thread src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts
Comment on lines +146 to +157
try {
await device.close();
this.connectedDevices.delete(buttplugDevice.index);

this.connectedDevices.delete(buttplugDevice.index);
this.logger.info(`Device removed: ${device.getDeviceId} (${buttplugDevice.name}@${buttplugDevice.index})`);
} catch (e: unknown) {
const error = BaseError.normalize(e);
this.logger.error(
`Could not remove device '${device.getDeviceId}' (${buttplugDevice.name}@${buttplugDevice.index}): ${error.message}`,
e
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Always remove provider-local device entry even if close() fails.

If close throws, the device stays in connectedDevices, which leaves stale state and repeated failed removals.

🧹 Suggested fix
-    try {
-        await device.close();
-        this.connectedDevices.delete(buttplugDevice.index);
+    try {
+        await device.close();
@@
-    } catch (e: unknown) {
+    } catch (e: unknown) {
         const error = BaseError.normalize(e);
         this.logger.error(
             `Could not remove device '${device.getDeviceId}' (${buttplugDevice.name}@${buttplugDevice.index}): ${error.message}`,
             e
         );
+    } finally {
+        this.connectedDevices.delete(buttplugDevice.index);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts` around
lines 146 - 157, The removal flow currently only calls
this.connectedDevices.delete(buttplugDevice.index) after await device.close(),
so a thrown error leaves a stale entry; ensure the provider-local entry is
always removed by deleting this.connectedDevices.delete(buttplugDevice.index)
regardless of close() outcome (e.g., call delete before awaiting device.close()
or perform the delete in a finally block), keep the logger.error path intact to
record close() failures, and continue to use device.getDeviceId and
buttplugDevice.name/index values for logging.

Comment thread src/device/provider/serialDeviceProvider.ts Outdated
Comment thread src/serial/synchronousSerialPort.ts
@coderabbitai

coderabbitai Bot commented Mar 13, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Latest git log ==="
git log --oneline -5

echo ""
echo "=== Latest commit diff stat ==="
git diff HEAD~1 HEAD --stat

echo ""
echo "=== Latest commit full diff ==="
git diff HEAD~1 HEAD

echo ""
echo "=== Open review threads ==="
gh api graphql -f query='
{
  repository(owner: "SlvCtrlPlus", name: "slvctrlplus-server") {
    pullRequest(number: 72) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 3) {
            nodes {
              body
              path
              line
              author { login }
            }
          }
        }
      }
    }
  }
}' 2>/dev/null

echo ""
echo "=== Current serialDeviceProvider.ts ==="
cat -n src/device/provider/serialDeviceProvider.ts

Length of output: 110206


@heavyrubberslave: I've reviewed commit 90c2ed7 against all open discussions. Here's the full picture:


✅ Closed by this commit

None. This commit is a naming/cleanup improvement rather than a bug fix. Specifically, it:

  • Renames the device-detection API (addAvailableDeviceannounceDetectedDevice, acquireAvailableDeviceacquireDetectedDevice, releaseAvailableDevicereleaseDetectedDevice)
  • Replaces all string-literal event emissions ('deviceConnected', 'deviceDisconnected', etc.) with DeviceManagerEvent enum members — eliminating the "string events instead of enums" inconsistency
  • Removes the now-unused DeviceProviderEvent enum and its residual emissions from virtualDeviceProvider.ts and zc95SerialDeviceProvider.ts
  • Removes the DeviceProvider.on() method (no longer needed since all event routing goes through DeviceManager)

All good improvements — but none target the open bug threads.


🔴 Still open (5 threads)

1. PRRT_kwDOHZZYrc50OUev / PRRT_kwDOHZZYrc50Q29i — Non-atomic addDevice + claimAvailableDevice in serialDeviceProvider.ts (Lines 60–61)

// These two need a try/finally guard:
this.deviceManager.addDevice(device);           // emits deviceConnected synchronously
this.deviceManager.claimAvailableDevice(deviceInfo.id); // skipped if addDevice throws

2. PRRT_kwDOHZZYrc50Q29Vclose() not idempotent in device.ts (Line 152)
Repeated close() calls emit deviceDisconnected multiple times. A private closed = false guard is needed.

3. PRRT_kwDOHZZYrc50Q29a — Reconnect/autoscan intervals not reset to undefined in buttplugIoWebsocketDeviceProvider.ts (Line 96)
After clearInterval(), the refs remain truthy, so ??= in init() skips re-creating them on reconnect.

4. PRRT_kwDOHZZYrc50Q29dconnectedDevices.delete() must run even if close() throws in buttplugIoWebsocketDeviceProvider.ts (Line 157)
Needs a finally block to ensure local state is always cleaned up.

5. PRRT_kwDOHZZYrc50Q29monClose callback fires twice in synchronousSerialPort.ts (Line 44)
Both writer and reader 'close' events trigger the same callback. A one-time guard is needed.

✅ Actions performed

Review triggered.

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.

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

🧹 Nitpick comments (2)
src/device/provider/serialDeviceProvider.ts (1)

99-99: Address or remove the TODO comment.

The comment // todo: freigeben! ("release!" in German) suggests cleanup was intended here but not implemented. The releaseDetectedDevice call at line 56 already handles the failure path, so this TODO may be stale. Please verify and either implement the missing logic or remove the comment.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/provider/serialDeviceProvider.ts` at line 99, The TODO comment "//
todo: freigeben!" in serialDeviceProvider.ts is stale—either remove it or
implement the intended release/cleanup; verify that the existing
releaseDetectedDevice helper is invoked on all failure and exit paths inside the
same function (ensure calls to releaseDetectedDevice occur where devices/errors
are cleaned up), and if any success path also needs explicit releasing implement
that call; otherwise simply delete the comment to avoid misleading TODOs.
src/device/protocol/zc95/zc95SerialDeviceProvider.ts (1)

78-84: Remove the commented-out code.

The // clearInterval(deviceStatusUpdaterInterval); comment references logic that has been moved to DeviceManager.initDeviceRefresher. This dead comment is confusing and should be removed.

♻️ Proposed fix
         port.on('close', () => {
-            // clearInterval(deviceStatusUpdaterInterval);
             this.connectedDevices.delete(device.getDeviceId);
 
             this.logger.info('Lost serial device: ' + device.getDeviceId);
             this.logger.info('Connected ZC95 serial devices: ' + this.connectedDevices.size.toString());
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/protocol/zc95/zc95SerialDeviceProvider.ts` around lines 78 - 84,
Remove the stale commented-out line referencing deviceStatusUpdaterInterval
inside the port.on('close', ...) handler in zc95SerialDeviceProvider.ts;
specifically edit the port.on('close', () => { ... }) block (which contains
this.connectedDevices.delete(device.getDeviceId) and logger.info calls) and
delete the line "// clearInterval(deviceStatusUpdaterInterval);" so the handler
no longer contains the dead comment that was moved to
DeviceManager.initDeviceRefresher.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/device/protocol/zc95/zc95SerialDeviceProvider.ts`:
- Around line 78-84: Remove the stale commented-out line referencing
deviceStatusUpdaterInterval inside the port.on('close', ...) handler in
zc95SerialDeviceProvider.ts; specifically edit the port.on('close', () => { ...
}) block (which contains this.connectedDevices.delete(device.getDeviceId) and
logger.info calls) and delete the line "//
clearInterval(deviceStatusUpdaterInterval);" so the handler no longer contains
the dead comment that was moved to DeviceManager.initDeviceRefresher.

In `@src/device/provider/serialDeviceProvider.ts`:
- Line 99: The TODO comment "// todo: freigeben!" in serialDeviceProvider.ts is
stale—either remove it or implement the intended release/cleanup; verify that
the existing releaseDetectedDevice helper is invoked on all failure and exit
paths inside the same function (ensure calls to releaseDetectedDevice occur
where devices/errors are cleaned up), and if any success path also needs
explicit releasing implement that call; otherwise simply delete the comment to
avoid misleading TODOs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ef30333c-ee32-459a-bc53-8c71546e0ecb

📥 Commits

Reviewing files that changed from the base of the PR and between 271b333 and 90c2ed7.

📒 Files selected for processing (7)
  • src/device/deviceManager.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/zc95/zc95SerialDeviceProvider.ts
  • src/device/provider/deviceProvider.ts
  • src/device/provider/deviceProviderEvent.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/device/transport/serialPortObserver.ts
💤 Files with no reviewable changes (1)
  • src/device/provider/deviceProviderEvent.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/device/transport/serialPortObserver.ts

@coderabbitai

coderabbitai Bot commented Mar 13, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Latest git log ==="
git log --oneline -5

echo ""
echo "=== Latest commit diff stat ==="
git diff HEAD~1 HEAD --stat

echo ""
echo "=== Latest commit full diff ==="
git diff HEAD~1 HEAD

echo ""
echo "=== Open review threads ==="
gh api graphql -f query='
{
  repository(owner: "SlvCtrlPlus", name: "slvctrlplus-server") {
    pullRequest(number: 72) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 3) {
            nodes {
              body
              path
              line
              author { login }
            }
          }
        }
      }
    }
  }
}' 2>/dev/null

echo ""
echo "=== Current serialDeviceProvider.ts ==="
cat -n src/device/provider/serialDeviceProvider.ts

echo ""
echo "=== Current device.ts ==="
cat -n src/device/device.ts

echo ""
echo "=== Current buttplugIoWebsocketDeviceProvider.ts ==="
cat -n src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts

echo ""
echo "=== Current synchronousSerialPort.ts ==="
cat -n src/serial/synchronousSerialPort.ts

Length of output: 129002


@heavyrubberslave: I've checked commit 90c2ed7 against all open review threads. Here's the full picture:


✅ Closed by this commit

None. Commit 90c2ed7 is a clean naming/hygiene improvement:

  • All string-literal event emissions ('deviceConnected', 'deviceDisconnected', 'deviceRefreshed', 'deviceAvailable') are replaced with DeviceManagerEvent enum members
  • addAvailableDeviceannounceDetectedDevice, acquireAvailableDeviceacquireDetectedDevice, releaseAvailableDevicereleaseDetectedDevice
  • ClaimResultAcquireResult
  • DeviceProviderEvent enum removed, DeviceProvider.on() method removed
  • deviceDetected enum value fixed from 'deviceAvailable' to 'deviceDetected'

All good improvements — but none address the open bug threads.


🔴 Still open (5 threads)

1. PRRT_kwDOHZZYrc50OUev / PRRT_kwDOHZZYrc50Q29i — Non-atomic addDevice + claimAvailableDevice (serialDeviceProvider.ts Lines 60–61)

this.deviceManager.addDevice(device);           // still no try/finally guard
this.deviceManager.claimAvailableDevice(deviceInfo.id);

A synchronous exception inside addDevice still leaves the claim queue entry permanently stuck. Suggested fix:

try {
    this.deviceManager.addDevice(device);
} finally {
    this.deviceManager.claimAvailableDevice(deviceInfo.id);
}

2. PRRT_kwDOHZZYrc50Q29Vclose() not idempotent (device.ts Line 152)

deviceDisconnected is still emitted on every close() call. A private closed = false guard is still needed.

3. PRRT_kwDOHZZYrc50Q29a — Interval refs not reset to undefined after clearInterval() (buttplugIoWebsocketDeviceProvider.ts Line 96)

After clearInterval(this.connectionIntervalRef) (line 75) and clearInterval(this.autoScanningIntervalRef) (line 94), both refs remain truthy. The ??= checks in init() and connectToServer() then permanently skip re-creating them on reconnect.

4. PRRT_kwDOHZZYrc50Q29dconnectedDevices.delete() not in finally (buttplugIoWebsocketDeviceProvider.ts Line 157)

this.connectedDevices.delete(buttplugDevice.index) (line 148) is still inside the try block. If device.close() throws, the provider's local map retains a stale entry.

5. PRRT_kwDOHZZYrc50Q29monClose callback fires twice (synchronousSerialPort.ts Line 44)

Both writer and reader 'close' events still trigger the same callback independently, causing double downstream cleanup on shutdown.

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

♻️ Duplicate comments (1)
src/device/provider/serialDeviceProvider.ts (1)

46-61: ⚠️ Potential issue | 🟠 Major

Always settle the detected-device queue after a successful acquire.

Once acquireDetectedDevice() returns success, every exit path needs to end in either releaseDetectedDevice() or claimAvailableDevice(). Right now a synchronous throw from connectToDevice() or addDevice() only gets logged by asyncHandler, so the current queue entry is left behind and the remaining providers never get a turn.

🛠️ Minimal fix
-        const device = await this.connectToDevice(deviceInfo.portInfo);
-
-        if (undefined === device) {
-            this.deviceManager.releaseDetectedDevice(deviceInfo.id);
-            return;
-        }
-
-        this.deviceManager.addDevice(device);
-        this.deviceManager.claimAvailableDevice(deviceInfo.id);
+        let device: D | undefined;
+        try {
+            device = await this.connectToDevice(deviceInfo.portInfo);
+        } catch (err) {
+            this.deviceManager.releaseDetectedDevice(deviceInfo.id);
+            throw err;
+        }
+
+        if (undefined === device) {
+            this.deviceManager.releaseDetectedDevice(deviceInfo.id);
+            return;
+        }
+
+        try {
+            this.deviceManager.addDevice(device);
+        } finally {
+            this.deviceManager.claimAvailableDevice(deviceInfo.id);
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/device/provider/serialDeviceProvider.ts` around lines 46 - 61, After a
successful acquireDetectedDevice() call you must always settle the
detected-device queue by calling either releaseDetectedDevice(deviceInfo.id) or
claimAvailableDevice(deviceInfo.id) on deviceManager; update the logic around
connectToDevice(deviceInfo.portInfo) and deviceManager.addDevice(device) so
synchronous throws don’t skip that settlement: wrap connectToDevice and
addDevice in a try/catch/finally (or use a try { ... } finally { ... }) that
ensures claimAvailableDevice(deviceInfo.id) is called on success and
releaseDetectedDevice(deviceInfo.id) is called on error, referencing the
existing deviceManager methods (acquireDetectedDevice, releaseDetectedDevice,
claimAvailableDevice) and the functions connectToDevice and addDevice to locate
the code to modify.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/device/provider/serialDeviceProvider.ts`:
- Around line 46-61: After a successful acquireDetectedDevice() call you must
always settle the detected-device queue by calling either
releaseDetectedDevice(deviceInfo.id) or claimAvailableDevice(deviceInfo.id) on
deviceManager; update the logic around connectToDevice(deviceInfo.portInfo) and
deviceManager.addDevice(device) so synchronous throws don’t skip that
settlement: wrap connectToDevice and addDevice in a try/catch/finally (or use a
try { ... } finally { ... }) that ensures claimAvailableDevice(deviceInfo.id) is
called on success and releaseDetectedDevice(deviceInfo.id) is called on error,
referencing the existing deviceManager methods (acquireDetectedDevice,
releaseDetectedDevice, claimAvailableDevice) and the functions connectToDevice
and addDevice to locate the code to modify.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c00d1633-6170-4b27-a45f-606b22d9baf5

📥 Commits

Reviewing files that changed from the base of the PR and between 271b333 and 90c2ed7.

📒 Files selected for processing (7)
  • src/device/deviceManager.ts
  • src/device/protocol/virtual/virtualDeviceProvider.ts
  • src/device/protocol/zc95/zc95SerialDeviceProvider.ts
  • src/device/provider/deviceProvider.ts
  • src/device/provider/deviceProviderEvent.ts
  • src/device/provider/serialDeviceProvider.ts
  • src/device/transport/serialPortObserver.ts
💤 Files with no reviewable changes (1)
  • src/device/provider/deviceProviderEvent.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/device/protocol/virtual/virtualDeviceProvider.ts

@heavyrubberslave

Copy link
Copy Markdown
Member Author

@coderabbitai with my most recent commit, all the problems should be fixed. Can you check any open discussions and close the ones that are resolved with the current code base committed and make a summary of the ones that are still unaddressed.

@coderabbitai

coderabbitai Bot commented Mar 14, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Latest git log ==="
git log --oneline -5

echo ""
echo "=== Latest commit diff stat ==="
git diff HEAD~1 HEAD --stat

echo ""
echo "=== Latest commit full diff ==="
git diff HEAD~1 HEAD

echo ""
echo "=== Open review threads ==="
gh api graphql -f query='
{
  repository(owner: "SlvCtrlPlus", name: "slvctrlplus-server") {
    pullRequest(number: 72) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 3) {
            nodes {
              body
              path
              line
              author { login }
            }
          }
        }
      }
    }
  }
}' 2>/dev/null

echo ""
echo "=== Current serialDeviceProvider.ts ==="
cat -n src/device/provider/serialDeviceProvider.ts

echo ""
echo "=== Current device.ts ==="
cat -n src/device/device.ts

echo ""
echo "=== Current buttplugIoWebsocketDeviceProvider.ts ==="
cat -n src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts

echo ""
echo "=== Current synchronousSerialPort.ts ==="
cat -n src/serial/synchronousSerialPort.ts

Length of output: 135765


🏁 Script executed:

#!/bin/bash
# Resolve PRRT_kwDOHZZYrc50Q29a — interval refs now correctly set to undefined after clearInterval
gh api graphql -f query='
mutation {
  resolveReviewThread(input: { threadId: "PRRT_kwDOHZZYrc50Q29a" }) {
    thread { id isResolved }
  }
}'

Length of output: 92


@heavyrubberslave: Here's the full summary for the latest commit (f160e2d):


✅ Closed by this commit

PRRT_kwDOHZZYrc50Q29a — Reconnect/autoscan intervals not reset after clearInterval() in buttplugIoWebsocketDeviceProvider.ts

Both interval refs are now correctly nulled after clearing:

  • this.connectionIntervalRef = undefined after clearInterval in connectToServer() (Line 76)
  • this.autoScanningIntervalRef = undefined after clearInterval in handleLostConnection() (Line 96)

The ??= guards in init() and connectToServer() will now correctly re-create the intervals on reconnect. Resolved ✅


🔴 Still open (1 thread)

PRRT_kwDOHZZYrc50Q29dconnectedDevices.delete() not guaranteed to run if close() throws in buttplugIoWebsocketDeviceProvider.ts (Line 151)

In removeButtplugIoDevice, the connectedDevices.delete call is still inside the try block:

try {
    await device.close();
    this.connectedDevices.delete(buttplugDevice.index); // ← skipped if close() throws
    ...
} catch (e: unknown) {
    ...
    // stale entry remains in connectedDevices
}

If device.close() throws (e.g. because the device state machine is in an unexpected state), the provider-local map retains a stale entry. The fix is to move the delete into a finally block:

try {
    await device.close();
    this.logger.info(`Device removed: ${device.getDeviceId} (${buttplugDevice.name}@${buttplugDevice.index})`);
} catch (e: unknown) {
    const error = BaseError.normalize(e);
    this.logger.error(
        `Could not remove device '${device.getDeviceId}' (${buttplugDevice.name}@${buttplugDevice.index}): ${error.message}`,
        e
    );
} finally {
    this.connectedDevices.delete(buttplugDevice.index);
}

@heavyrubberslave

Copy link
Copy Markdown
Member Author

Closed in favor of #74

@heavyrubberslave heavyrubberslave deleted the feat/ble-support branch March 14, 2026 14:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant