Skip to content

Add E-Stim Systems 2B support - #64

Merged
heavyrubberslave merged 15 commits into
mainfrom
feat/estim2b-support
Jan 20, 2026
Merged

Add E-Stim Systems 2B support#64
heavyrubberslave merged 15 commits into
mainfrom
feat/estim2b-support

Conversation

@heavyrubberslave

@heavyrubberslaveheavyrubberslave commented Jan 17, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Added support for EStim2b devices and a SerialPort factory plus a generic device-provider factory.
  • Improvements

    • Enforced write-only attribute guards, added attribute setter/hasValue checks, and stronger validation.
    • Unified and simplified serial provider lifecycle and port handling; reduced default serial receive timeout.
    • Broadened logger error context typing and standardized child logger behavior.
    • Refactored provider registration to use generic factories.
  • Chores

    • Added a dependency for modern error handling and enabled source maps in the build.
    • Relaxed object-shorthand ESLint rule.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai

coderabbitaiBot commented Jan 17, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds EStim2b protocol/device/factory/provider, introduces a SerialPortFactory and GenericDeviceProviderFactory, refactors serial provider abstractions and provider factories, adjusts device attribute APIs and device/provider wiring, expands logging typings, updates DI registrations, tweaks ESLint/package script, and updates tests.

Changes

Cohort / File(s)Summary
EStim2b device & protocol (NEW)
src/device/protocol/estim2b/estim2bDevice.ts, src/device/protocol/estim2b/estim2bDeviceFactory.ts, src/device/protocol/estim2b/estim2bProtocol.ts, src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts
New EStim2b protocol client, device class, factory and serial provider; status parsing, attribute mapping, refresh/updater, setAttribute flows and battery helpers added.
Serial port factory
src/factory/serialPortFactory.ts
New SerialPortFactory to create SerialPort instances for providers.
Serial provider core & abstractions
src/device/provider/serialDeviceProvider.ts, src/device/provider/genericDeviceProviderFactory.ts, src/device/provider/deviceProvider.ts
SerialDeviceProvider now depends on SerialPortFactory, implements connectToDevice orchestration and connectSerialDevice/getSerialDeviceProviderPortOpenOptions hooks; added GenericDeviceProviderFactory; removed DeviceProvider.init abstract method.
Provider adaptations & DI
src/device/protocol/slvCtrlPlus/..., src/device/protocol/zc95/..., src/serviceMap.ts, src/serviceProvider/*.ts
SLV and ZC95 providers refactored to new serial provider shape (protected connectSerialDevice, port option helpers), added providerName statics, connectedDevices tracking; replaced concrete provider factories with GenericDeviceProviderFactory registrations; added factory.serialPort and device.factory.estim2b mappings.
Provider factories removed
src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProviderFactory.ts (deleted), src/device/protocol/zc95/zc95SerialDeviceProviderFactory.ts (deleted)
Removed concrete provider factory files in favor of generic factory registrations.
Device attributes & manager
src/device/attribute/deviceAttribute.ts, src/device/device.ts, src/device/deviceManager.ts
DeviceAttribute gained setter and hasValue(); getter enforces write-only read guard; Device.attributes made mutable; DeviceManager no longer auto-inits providers and updater skips refresh when device state is busy.
ZC95 refinements
src/device/protocol/zc95/Zc95Serial.ts, src/device/protocol/zc95/zc95Device.ts, src/device/protocol/zc95/zc95SerialDeviceProvider.ts
Zc95Serial.recv default timeout reduced (6000→500ms); setAttribute stronger validation; provider DI updated to use SerialPortFactory, port options hook added.
Serial utilities & transport
src/serial/SynchronousSerialPort.ts, src/device/transport/serialDeviceTransport.ts, src/device/transport/serialPortObserver.ts
SynchronousSerialPort now queues tasks via wrapper functions; minor formatting; observer uses truthiness check.
Logging types
src/logging/Logger.ts, src/logging/PinoLogger.ts
Added ErrorContext type; Logger.error and PinoLogger.error accept `ErrorContext
Serialization & types
src/serialization/discriminator/deviceDiscriminator.ts, src/types.d.ts
Registered estim2b discriminator; added internal listener type aliases.
Config, package, tests
eslint.config.ts, package.json, tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts
Disabled ESLint object-shorthand; added modern-errors dependency and --enable-source-maps to compile script; one test changed attribute modifier (writeOnly→readWrite).

Sequence Diagram(s)

sequenceDiagram
participant User as User / Discovery
participant Provider as EStim2bSerial<br/>DeviceProvider
participant Transport as Serial<br/>Transport
participant Protocol as EStim2b<br/>Protocol
participant Factory as Estim2b<br/>DeviceFactory
participant Device as EStim2b<br/>Device
User->>Provider: connectSerialDevice(port, portInfo)
activate Provider
Provider->>Transport: wrap port (Readline + Synchronous wrapper)
Provider->>Protocol: new EStim2bProtocol(transport)
Provider->>Protocol: requestStatus()
activate Protocol
Protocol->>Transport: send(status request)
Transport-->>Protocol: raw status
Protocol-->>Provider: parsed EStim2bStatus
deactivate Protocol
Provider->>Factory: create(protocol, initialStatus, providerName)
activate Factory
Factory->>Device: new EStim2bDevice(..., attributes)
Device-->>Factory: constructed device
deactivate Factory
Provider->>Device: initDeviceStatusUpdater()
Provider-->>User: emit deviceConnected
deactivate Provider
User->>Device: setAttribute(mode/level/...), value
activate Device
Device->>Protocol: setMode/setPower/...
Protocol->>Transport: send(command)
Transport-->>Protocol: response status
Protocol-->>Device: updated status
Device->>Device: updateAttributeValues(status)
deactivate Device
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

minor

Poem

🐰 I hopped through ports and plugs tonight,

Built new protocols in soft moonlight,
Factories hum and transports sing,
Devices wake and attributes spring,
A tiny rabbit's joyous byte!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title 'Add E-Stim Systems 2B support' directly and clearly describes the main change: introducing new device support for E-Stim Systems 2B protocol and hardware integration.
Docstring Coverage✅ PassedNo 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 docstrings

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.

@heavyrubberslave
heavyrubberslave marked this pull request as ready for review January 18, 2026 16:31

@coderabbitaicoderabbitaiBot 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 (2)
src/device/protocol/zc95/zc95Device.ts (1)

84-112: Add range validation for power-channel updates.

Line 98 accepts any number and forwards it to the device without checking the attribute’s valid range, which can push out‑of‑range values and break state consistency. Suggest validating via the attribute’s isValidValue before sending.

🐛 Suggested fix
- if (this.isPowerChannelAttribute(attributeName) && typeof value === 'number') {- await this.setAttributePowerChannel(attributeName, value);- return value;- }+ if (this.isPowerChannelAttribute(attributeName) && typeof value === 'number') {+ const attr = this.attributes[attributeName];+ if (!attr || !attr.isValidValue(value)) {+ throw new Error(+ `Could not set value ${JSON.stringify(value)} (type: ${typeof value}) for attribute '${attributeName}'`+ );+ }+ await this.setAttributePowerChannel(attributeName, value);+ return value;+ }
src/device/transport/serialPortObserver.ts (1)

66-72: Confirm connectToDevice returns a strict boolean before relying on truthiness.

Line 70 now accepts any truthy value; if any provider returns an object/string by mistake, discovery will stop early. If the contract is boolean, consider keeping the strict check (or enforce the return type at the interface).

💡 Suggested fix
- if (result) {+ if (result === true) {
break;
}

Please verify the return types in the interface and implementations:

#!/bin/bash# Locate connectToDevice definitions and usages
rg -n -C3 --type=ts '\bconnectToDevice\s*\('
🤖 Fix all issues with AI agents
In `@package.json`:
- Line 17: The dependency modern-errors is pinned to a vulnerable version
(7.1.4); update the version string to at least 7.4.1 in package.json (replace
"modern-errors": "7.1.4" with "modern-errors": ">=7.4.1" or the exact "7.4.1"),
then regenerate the lockfile by running your package manager install (npm
install or yarn install) to update package-lock.json / yarn.lock and run the
test suite; ensure no other code relies on breaking changes in modern-errors and
adjust imports/usages only if tests or linting fail.
In `@src/device/protocol/estim2b/estim2bDevice.ts`:
- Around line 205-227: The IntRangeDeviceAttribute max values in
createPulsePwmAttribute and createPulseFrequencyAttribute exceed the protocol
limit (currently Int.from(100)); update both calls to use Int.from(99) as the
upper bound so the attribute ranges match the protocol setters, i.e., change the
IntRangeDeviceAttribute.createInitialized max argument for 'pulsePwm' and
'pulseFrequency' from 100 to 99.
In `@src/device/protocol/estim2b/estim2bDeviceFactory.ts`:
- Around line 76-118: pulseFrequency and pulsePwm are incorrectly initialized
from channelALevel/channelBLevel and use a max of 100 while protocol setters cap
at 99; update the IntRangeDeviceAttribute.createInitialized calls for
pulseFrequency and pulsePwm to use the correct initialStatus fields (e.g.,
initialStatus.pulseFrequency and initialStatus.pulsePwm) and make the upper
bound Int.from(99) to match the protocol setters (or, if 100 is intended, update
the protocol setter bounds instead) so the initial values and UI ranges are
consistent with the device protocol.
In `@src/device/protocol/estim2b/estim2bSerialDeviceProvider.ts`:
- Around line 54-67: The code mistakenly uses the method reference
device.getDeviceId instead of calling it, causing Map key collisions and wrong
log output; update every usage in estim2bSerialDeviceProvider (e.g.,
this.connectedDevices.set(device.getDeviceId, device),
this.connectedDevices.delete(device.getDeviceId), logger.debug(`Assigned device
id: ${device.getDeviceId} ...`), logger.info('Lost serial device: ' +
device.getDeviceId), and the connected devices size logs) to call the method
(device.getDeviceId()) so the actual ID string is used as the Map key and in
logs/events.
🧹 Nitpick comments (6)
src/types.d.ts (1)

14-17: Allow readonly tuples in event maps to avoid never listeners.

If event maps are inferred via as const, T[K] becomes a readonly tuple and fails unknown[], making Listener resolve to never. Consider accepting readonly arrays to keep inference usable.

♻️ Suggested tweak
-type Listener<K, T> = T extends DefaultEventMap ? (...args: any[]) => void : (- K extends keyof T ? (- T[K] extends unknown[] ? (...args: T[K]) => void : never- )- : never- );+type Listener<K, T> = T extends DefaultEventMap ? (...args: any[]) => void : (+ K extends keyof T ? (+ T[K] extends readonly unknown[] ? (...args: T[K]) => void : never+ )+ : never+ );
src/device/device.ts (1)

54-56: Consider keeping attributes readonly unless full reassignment is required.
readonly still allows value mutations (e.g., this.attributes.foo.value = …) while preventing wholesale map replacement. If reassignment isn’t needed, retaining readonly preserves invariants.

♻️ Optional revision (if reassignment isn’t required)
- protected attributes: TAttributes;+ protected readonly attributes: TAttributes;
src/logging/Logger.ts (1)

9-20: ErrorContext | unknown collapses to unknown; the new type adds no type safety.

In TypeScript, unknown is the top type—it absorbs any union. If the goal is to enforce stricter typing for error contexts, remove unknown from the union (or add overloads for specific cases).

♻️ Suggested tightening
- error(msg: string, context?: ErrorContext | unknown): void;+ error(msg: string, context?: ErrorContext): void;
src/device/protocol/estim2b/estim2bProtocol.ts (1)

135-154: Harden response parsing (trim + NaN checks).

Consider trimming the response and validating numeric parts to fail fast on malformed frames.

♻️ Suggested hardening
- private static parseResponse(response: string): EStim2bStatus {- const parts = response.split(':');+ private static parseResponse(response: string): EStim2bStatus {+ const parts = response.trim().split(':');
if (9 !== parts.length) {
throw new Error(
`Could not parse status message of 2B device: expected 9 parts, got ${parts.length} (${response})`
);
}
+ const nums = parts.slice(0, 7).map((p) => Number.parseInt(p, 10));+ if (nums.some((n) => Number.isNaN(n))) {+ throw new Error(`Could not parse numeric fields in status message: ${response}`);+ }+
return {
- batteryLevel: parseInt(parts[0], 10),- channelALevel: parseInt(parts[1], 10)/2,- channelBLevel: parseInt(parts[2], 10)/2,- pulseFrequency: parseInt(parts[3], 10)/2,- pulsePwm: parseInt(parts[4], 10)/2,- currentMode: parseInt(parts[5], 10),- powerMode: parts[6],- channelsJoined: parseInt(parts[7], 10) === 1,- firmwareVersion: parts[8]+ batteryLevel: nums[0],+ channelALevel: nums[1]/2,+ channelBLevel: nums[2]/2,+ pulseFrequency: nums[3]/2,+ pulsePwm: nums[4]/2,+ currentMode: nums[5],+ powerMode: parts[6],+ channelsJoined: nums[6] === 1,+ firmwareVersion: parts[8].trim()
};
}
src/device/protocol/zc95/zc95SerialDeviceProvider.ts (1)

32-75: Well-structured device connection lifecycle management.

The connectSerialDevice implementation properly:

  • Resets the device before querying version details
  • Validates the response before proceeding
  • Sets up the device status updater
  • Cleans up resources (interval, map entry) in the close handler

One minor nit on line 33:

- const serialLogger = this.logger.child({ name: Zc95Serial.name })+ const serialLogger = this.logger.child({ name: Zc95Serial.name });
src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts (1)

112-130: Consider adding a timeout for the Arduino ready byte wait.

If the Arduino device never sends the ready byte (e.g., firmware issue, device stuck in reset loop), the promise returned by preparePort will hang indefinitely. This could cause connection attempts to stall without clear error feedback.

♻️ Proposed fix with timeout
 protected preparePort(port: SerialPort, portInfo: PortInfo): Promise<void> {
return new Promise<void>(resolve => {
+ const READY_TIMEOUT_MS = 5000;+
if (portInfo.vendorId !== SlvCtrlPlusSerialDeviceProvider.arduinoVendorId) {
// It's NOT an Arduino
resolve();
return;
}
const readyParser = port.pipe(new ReadyParser({
delimiter: [SlvCtrlPlusSerialDeviceProvider.moduleReadyByte]
}));
+ const timeout = setTimeout(() => {+ port.unpipe(readyParser);+ readyParser.destroy();+ this.logger.warn('Arduino ready byte timeout - proceeding anyway');+ resolve();+ }, READY_TIMEOUT_MS);+
readyParser.once('ready', () => {
+ clearTimeout(timeout);
port.unpipe(readyParser);
readyParser.destroy();
resolve();
});
});
}

Comment threadpackage.json
Comment threadsrc/device/protocol/estim2b/estim2bDevice.ts
Comment threadsrc/device/protocol/estim2b/estim2bDeviceFactory.ts Outdated
Comment threadsrc/device/protocol/estim2b/estim2bSerialDeviceProvider.ts
@heavyrubberslaveheavyrubberslave added minor Creates a new minor release if merged patch Creates a new patch/bugfix release if merged and removed minor Creates a new minor release if merged labels Jan 18, 2026

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts`:
- Around line 112-130: preparePort currently waits indefinitely for the
ReadyParser to emit the moduleReadyByte which can hang connectToDevice; add a
timeout (e.g. 3s) inside SlvCtrlPlusSerialDeviceProvider.preparePort that, when
elapsed, cleans up readyParser (unpipe + destroy), removes the 'ready' listener,
clears the timer, and resolves the promise so the higher-level connectToDevice
can continue; also ensure the timer is cleared if readyParser.once('ready', ...)
fires and that the non-Arduino early-return behavior remains unchanged.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/serial/SynchronousSerialPort.ts`:
- Around line 80-81: The return line in SynchronousSerialPort incorrectly
asserts the awaited value as Promise<string>; since await unwraps the promise
the result is a string. Update the return from the push call on this.queue (call
site: this.queue.push(wrappedPromise, options)) to remove the "as
Promise<string>" cast (or change it to assert a string if necessary) so the
async function returns the correct string type without wrapping a Promise twice.
🧹 Nitpick comments (1)
src/serial/SynchronousSerialPort.ts (1)

51-56: Redundant null check.

removeListeners is always defined (initialized to an empty function on line 41), so the undefined !== removeListeners check is always true.

Proposed simplification
 const dataHandler = (receivedData: string): void => {
- if (undefined !== removeListeners) {- removeListeners();- }+ removeListeners();
resolve(receivedData);
};

Comment threadsrc/serial/SynchronousSerialPort.ts Outdated

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

🤖 Fix all issues with AI agents
In `@src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts`:
- Around line 84-96: parseDeviceInfo currently assumes
introductionResult.split(';') yields parts[1]; if the input lacks the ';'
delimiter parts[1] is undefined and parts[1].split(',') throws. Update
parseDeviceInfo to guard after splitting by ';' — verify parts.length > 1 (and
that parts[0] === 'introduce') before accessing parts[1]; only then split
parts[1] into deviceInfoParts and continue validation (deviceInfoParts.length
=== 3). Ensure the function returns undefined for malformed inputs instead of
throwing.
- Around line 112-137: The Promise constructor in preparePort has an extra
opening parenthesis causing a syntax error; update new Promise<void>(((resolve,
reject) => { ... }) to use a single parenthesis around the executor (new
Promise<void>((resolve, reject) => { ... })) and ensure the matching closing
parentheses/braces remain balanced in
SlvCtrlPlusSerialDeviceProvider.preparePort so the function compiles.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts`:
- Around line 112-137: preparePort can reject (e.g., on the ready-byte timeout)
which currently leads to unhandled rejections and leaked open ports; wrap the
call to SlvCtrlPlusSerialDeviceProvider.preparePort(...) in a try-catch
(wherever you call it) and on catch ensure the SerialPort instance (port) is
explicitly closed/destroyed (e.g., port.close() or port.destroy()) before
rethrowing or returning an error; alternatively, modify the caller to await
preparePort inside a promise chain that on rejection always closes the port—this
ensures any timeout rejection from readyParser/preparePort does not leave the
port open.
♻️ Duplicate comments (1)
src/device/protocol/slvCtrlPlus/slvCtrlPlusSerialDeviceProvider.ts (1)

84-105: Guard against missing ; in introduction payload.
parts[1] can be undefined when the delimiter is absent, causing a throw before returning undefined.

🔧 Suggested hardening
 protected parseDeviceInfo(introductionResult: string): DeviceInfo | undefined {
const parts = introductionResult.split(';');
- if ('introduce' !== parts[0]) {+ if ('introduce' !== parts[0] || parts.length < 2) {
return undefined;
}
const deviceInfoParts = parts[1].split(',');

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

🤖 Fix all issues with AI agents
In `@src/device/provider/serialDeviceProvider.ts`:
- Line 37: There's a trailing whitespace in the serialDeviceProvider module —
remove the stray space character at the end of the offending line in
src/device/provider/serialDeviceProvider.ts so the file contains no trailing
spaces; edit the SerialDeviceProvider (or the top-level export in that file) and
save the file, then run ESLint/format to confirm the trailing whitespace is
gone.
- Around line 35-42: The preparePort call is happening before the serial port is
opened, so the ReadyParser (used in preparePort for Arduino vendor ID '2341')
never receives data when ports are created with autoOpen: false; move the
port.open(...) promise to execute before preparePort(port, portInfo) to ensure
the port is open when preparePort attaches the ReadyParser, then call result =
await this.connectSerialDevice(port, portInfo) as before.

Comment threadsrc/device/provider/serialDeviceProvider.ts
Comment threadsrc/device/provider/serialDeviceProvider.ts Outdated
@heavyrubberslave
heavyrubberslave merged commit 90160e8 into mainJan 20, 2026
17 checks passed
@heavyrubberslave
heavyrubberslave deleted the feat/estim2b-support branch January 20, 2026 16:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patchCreates a new patch/bugfix release if merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@heavyrubberslave