Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
ec6e912
feat: allow enabling/disabling device sources and known devices
heavyrubberslave Jul 5, 2026
2586e6f
Some improvements, still wip
heavyrubberslave Jul 5, 2026
6b3088e
Centralize device enable/disable handling in DeviceManager
heavyrubberslave Jul 18, 2026
a0489e6
Clear pending retry on device disappearance, align virtual provider
heavyrubberslave Jul 18, 2026
f18c599
Generalize device provider detection flow into a base class
heavyrubberslave Jul 18, 2026
68da237
Introduce AnyDevice to erase setAttribute bivariance at device bounda…
heavyrubberslave Jul 18, 2026
cd3e0db
Type AnyDevice.setAttribute with AttributeValue instead of unknown
heavyrubberslave Jul 18, 2026
d25ea81
Restore device-family type safety for BLE/serial providers
heavyrubberslave Jul 18, 2026
bd140d6
Tighten test attribute-value typings from unknown to AttributeValue
heavyrubberslave Jul 18, 2026
c267f46
Address code review findings on device lifecycle and settings
heavyrubberslave Jul 18, 2026
0f9d1d3
Merge DetectedDeviceProvider into DeviceProvider, route virtual throu…
heavyrubberslave Jul 18, 2026
00088ea
Drive buttplug.io scanning off 'scanningfinished' instead of fixed ti…
heavyrubberslave Jul 18, 2026
bb40c9d
Revert "Drive buttplug.io scanning off 'scanningfinished' instead of …
heavyrubberslave Jul 18, 2026
a03632a
Name buttplug.io scan timing constants and cover the scan duty-cycle
heavyrubberslave Jul 18, 2026
9fea5d7
Drop the auto-scan duty-cycle test and its injectable timers
heavyrubberslave Jul 18, 2026
150ed88
Remove unnecessary comments, rename some things for clarity
heavyrubberslave Jul 18, 2026
4de46df
Rename DeviceInfo to DeviceDetectionInfo, disambiguate detection vs c…
heavyrubberslave Jul 19, 2026
833c607
Removed some more unnecessay comments
heavyrubberslave Jul 19, 2026
faf22f7
Reference-count BLE/serial observers; buttplugIo devices close themse…
heavyrubberslave Jul 19, 2026
dd3a17b
Revert renaming
heavyrubberslave Jul 19, 2026
d472a40
Mock the usb module in SerialPortObserver tests instead of double-cal…
heavyrubberslave Jul 19, 2026
e661512
Stop recomputing buttplugIo device id in the factory, matching other …
heavyrubberslave Jul 19, 2026
338aca2
Don't abort virtual device reconciliation when one device fails to close
heavyrubberslave Jul 19, 2026
3e16303
Rename buttplugIo naming leftovers for clarity
heavyrubberslave Jul 19, 2026
7ea3b0d
Don't leave a provider permanently zombied when device.close() fails …
heavyrubberslave Jul 19, 2026
440a058
Replace DeviceProviderManager's hand-rolled operation queue with Sequ…
heavyrubberslave Jul 19, 2026
249e4a0
Extract shared reference-counting logic from BleObserver/SerialPortOb…
heavyrubberslave Jul 19, 2026
d68a8bd
Hydrate schema defaults and validate via AJV in PlainToClassSerialize…
heavyrubberslave Jul 19, 2026
448318b
Drop redundant void prefix on a promise that already has a .catch()
heavyrubberslave Jul 19, 2026
1a9005c
Remove some more comments
heavyrubberslave Jul 19, 2026
e2bfc6b
Fix remaining CodeRabbit findings, parallelize provider start/stop, u…
heavyrubberslave Jul 20, 2026
1a6466c
Fix settings-change races, provider restart, and DI/scope cleanup
heavyrubberslave Jul 22, 2026
72736a5
fix: guard provider shutdown/stop errors so graceful shutdown completes
heavyrubberslave Jul 22, 2026
3ebf4a3
fix: close race between automation log file creation and read
heavyrubberslave Jul 23, 2026
4dffc96
fix: open automation log before isolate creation to avoid leak on wri…
heavyrubberslave Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 26 additions & 18 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { ClientToServerEvents, ServerToClientEvents, WebsocketServer } from './s
import { SerializedDevice } from './device/serializedTypes.js';
import { SerializedSettings } from './settings/serializedTypes.js';
import AutomationServiceProvider from './serviceProvider/automationServiceProvider.js';
import Device from './device/device.js';
import { AnyDevice } from './device/device.js';
import WebSocketEvent from './device/webSocketEvent.js';
import AutomationEventType from './automation/automationEventType.js';
import LoggerServiceProvider from './serviceProvider/loggerServiceProvider.js';
Expand Down Expand Up @@ -104,22 +104,22 @@ const configureWebsocket = (io: WebsocketServer, container: Container<ServiceMap
socket.on(WebSocketEvent.deviceUpdateReceived, (data) => deviceUpdateHandler.handle(data));
});

deviceManager.on(DeviceManagerEvent.deviceConnected, (device: Device) => {
deviceManager.on(DeviceManagerEvent.deviceConnected, (device: AnyDevice) => {
io.emit(WebSocketEvent.deviceConnected, serializer.transform<SerializedDevice>(device, deviceDiscriminator));
void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceConnected, device, args: [] });
});

deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: Device) => {
deviceManager.on(DeviceManagerEvent.deviceDisconnected, (device: AnyDevice) => {
io.emit(WebSocketEvent.deviceDisconnected, serializer.transform<SerializedDevice>(device, deviceDiscriminator));
void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceDisconnected, device, args: [] });
});

deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: Device) => {
deviceManager.on(DeviceManagerEvent.deviceRefreshed, (device: AnyDevice) => {
io.emit(WebSocketEvent.deviceRefreshed, serializer.transform<SerializedDevice>(device, deviceDiscriminator));
void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceRefreshed, device, args: [] });
});

deviceManager.on(DeviceManagerEvent.deviceNotification, (device: Device, notification) => {
deviceManager.on(DeviceManagerEvent.deviceNotification, (device: AnyDevice, notification) => {
io.emit(WebSocketEvent.deviceNotification, serializer.transform<SerializedDevice>(device, deviceDiscriminator), notification);
void scriptRuntime.runForEvent({ type: DeviceManagerEvent.deviceNotification, device, args: [notification] });
});
Expand All @@ -132,21 +132,25 @@ const configureWebsocket = (io: WebsocketServer, container: Container<ServiceMap
scriptRuntime.on(AutomationEventType.consoleLog, (data: string) => io.emit(AutomationEventType.consoleLog, data));
};

const loadDeviceProviders = (container: Container<ServiceMap>): void => {
const serialPortObserver = container.get('device.observer.serial');
const bleObserver = container.get('device.observer.ble');
const startDeviceProviders = (container: Container<ServiceMap>): void => {
const logger = container.get('logger.default');
const settingsManager = container.get('settings.manager');
const settings = container.get('settings');
const deviceProviderManager = container.get('device.provider.loader');

deviceProviderManager.loadFromSettings(settings);
const deviceProviderManager = container.get('device.provider.manager');
const deviceManager = container.get('device.manager');

deviceProviderManager
.startProviders()
.loadFromSettings(settings)
.catch(e => logError(logger, `Loading device providers failed`, e));

serialPortObserver.start().catch(e => logError(logger, `Initializing serial port observer failed`, e));
bleObserver.init().catch(e => logError(logger, `Initializing BLE observer failed`, e));
settingsManager.on(SettingsEventType.changed, (changedSettings: Settings) => {
// Reload device sources first so re-enabled devices are only re-announced once their provider runs again
deviceProviderManager
.loadFromSettings(changedSettings)
.catch(e => logError(logger, 'Failed to reload device sources after settings change', e))
.then(() => deviceManager.onSettingsChanged())
.catch(e => logError(logger, 'Failed to apply device enabled/disabled changes', e));
});
};

const buildCorsOptions = (allowedOrigins: string[]): CorsOptions => ({
Expand Down Expand Up @@ -205,7 +209,7 @@ export const createApp = (container: Container<ServiceMap>, options: AppOptions)

configureRoutes(app, container);
configureWebsocket(websocketServer, container);
loadDeviceProviders(container);
startDeviceProviders(container);

let serveResult: ServeResult | undefined;
let canBeShutDown = false;
Expand Down Expand Up @@ -259,9 +263,13 @@ export const createApp = (container: Container<ServiceMap>, options: AppOptions)
logger.info('Shutting down...');

await container.get('automation.scriptRuntime').stop();
await container.get('device.observer.serial').stop();
await container.get('device.observer.ble').stop();
await container.get('device.provider.loader').stopProviders();

try {
await container.get('device.provider.manager').stopProviders();
} catch (e: unknown) {
logError(logger, 'Failed to stop device providers during shutdown', e);
}

container.get('health.metricsCollector').stop();
Comment thread
heavyrubberslave marked this conversation as resolved.

await websocketServer.close();
Expand Down
41 changes: 33 additions & 8 deletions src/automation/scriptRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ivm from 'isolated-vm';
import { transform } from 'sucrase';
import Device, { DeviceNotification } from '../device/device.js';
import { AnyDevice, DeviceNotification } from '../device/device.js';
import DeviceRepositoryInterface from '../repository/deviceRepositoryInterface.js';
import fs, { WriteStream } from 'fs';
import readLastLines from 'read-last-lines/dist/index.js';
Expand All @@ -11,8 +11,8 @@ import { AttributeValue } from '../device/attribute/deviceAttribute.js';
import Logger from '../logging/Logger.js';

export type SupportedDeviceEvent =
| { type: DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed; device: Device; args: [] }
| { type: DeviceManagerEvent.deviceNotification; device: Device; args: [notification: DeviceNotification] };
| { type: DeviceManagerEvent.deviceConnected | DeviceManagerEvent.deviceDisconnected | DeviceManagerEvent.deviceRefreshed; device: AnyDevice; args: [] }
| { type: DeviceManagerEvent.deviceNotification; device: AnyDevice; args: [notification: DeviceNotification] };

type ScriptRuntimeEvents = {
[AutomationEventType.consoleLog]: (data: string) => void,
Expand Down Expand Up @@ -223,6 +223,25 @@ export default class ScriptRuntime

public async load(scriptCode: string): Promise<void>
{
const writer = fs.createWriteStream(`${this.logPath}/automation.log`);
writer.on('error', (err) => {
this.logger.error(`Automation log write error: ${err.message}`);
});

// Open the log file before creating the isolate: if this fails, nothing
// needs to be torn down yet.
try {
await new Promise<void>((resolve, reject) => {
writer.once('open', () => resolve());
writer.once('error', (err) => reject(err));
});
} catch (e) {
writer.destroy();
throw e;
}

this.logWriter = writer;
Comment on lines +226 to +243

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clean up partial initialization failures.

If isolate/context creation, script compilation, or jail setup fails after Line 243, logWriter (and possibly the isolate/context) remains allocated while runningSince is still null. stop() then returns early, leaking the handle/resources. Wrap the remaining initialization in cleanup that closes the writer and releases partial VM state before rethrowing.

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

In `@src/automation/scriptRuntime.ts` around lines 226 - 243, Wrap the
initialization after assigning `this.logWriter` in cleanup handling covering
isolate/context creation, script compilation, and jail setup. On failure, close
the writer, release any partially created isolate or context, reset related
state as needed, and rethrow the original error so `stop()` is not relied on
while `runningSince` remains null.


this.isolate = new ivm.Isolate({ memoryLimit: 128 });
this.vmContext = await this.isolate.createContext();

Expand Down Expand Up @@ -299,10 +318,6 @@ export default class ScriptRuntime
this.dispatchRef = await this.vmContext.global.get('__dispatchEvent');
this.lifecycleRef = await this.vmContext.global.get('__dispatchLifecycle');

this.logWriter = fs.createWriteStream(`${this.logPath}/automation.log`);
this.logWriter.on('error', (err) => {
this.logger.error(`Automation log write error: ${err.message}`);
});
this.runningSince = new Date();

const lifecycleRef = this.lifecycleRef;
Expand Down Expand Up @@ -441,7 +456,17 @@ export default class ScriptRuntime

public async getLog(maxLines: number): Promise<string>
{
return readLastLines.read(`${this.logPath}/automation.log`, maxLines);
try {
return await readLastLines.read(`${this.logPath}/automation.log`, maxLines);
} catch (e: unknown) {
// No script has run yet (or its log file was not created) - treat as empty log
// rather than an error condition.
if (e instanceof Error && e.message.includes('file does not exist')) {
return '';
}

throw e;
}
}

public isRunning(): boolean
Expand Down
23 changes: 11 additions & 12 deletions src/controller/settings/putSettingsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import ControllerInterface from '../controllerInterface.js';
import SettingsManager from '../../settings/settingsManager.js';
import Settings, { SettingsSchema } from '../../settings/settings.js';
import JsonSchemaValidator from '../../schemaValidation/JsonSchemaValidator.js';
import SchemaValidationError from '../../schemaValidation/schemaValidationError.js';
import PlainToClassSerializer from '../../serialization/plainToClassSerializer.js';
import ClassToPlainSerializer from '../../serialization/classToPlainSerializer.js';
import { JsonObject } from '../../types.js';
Expand All @@ -17,35 +17,34 @@ export default class PutSettingsController implements ControllerInterface

private classToPlainSerializer: ClassToPlainSerializer;

private settingsSchemaValidator: JsonSchemaValidator<typeof SettingsSchema>;

public constructor(
settingsManager: SettingsManager,
classToPlainSerializer: ClassToPlainSerializer,
plainToClassSerializer: PlainToClassSerializer,
settingsSchemaValidator: JsonSchemaValidator<typeof SettingsSchema>
plainToClassSerializer: PlainToClassSerializer
) {
this.settingsManager = settingsManager;
this.settingsSchemaValidator = settingsSchemaValidator;
this.plainToClassSerializer = plainToClassSerializer;
this.classToPlainSerializer = classToPlainSerializer;
}

public execute(req: PutSettingsRequest, res: Response): void
{
const valid = this.settingsSchemaValidator.validate(req.body);
let settings: Settings;

try {
settings = this.plainToClassSerializer.transform(Settings, req.body, SettingsSchema);
} catch (e: unknown) {
if (!(e instanceof SchemaValidationError)) {
throw e;
}

if (!valid) {
const validationErrors = this.settingsSchemaValidator.getValidationErrors();
res.status(400).json({
message: `Settings are not in a valid format`,
errors: [...validationErrors]
errors: e.validationErrors
});
return;
}

const settings = this.plainToClassSerializer.transform(Settings, req.body);

this.settingsManager.replace(settings);

res.send(JSON.stringify(this.classToPlainSerializer.transform(
Expand Down
12 changes: 6 additions & 6 deletions src/device/bleDevice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Peripheral } from '@stoprocent/noble';
import BaseError from 'modern-errors';
import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications } from './device.js';
import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications, WithUntypedAttributes } from './device.js';
import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js';
import { Expose } from 'class-transformer';
import { EventEmitter } from 'events';
Expand All @@ -9,11 +9,7 @@ import { logError } from '../util/error.js';
import Logger from '../logging/Logger.js';
import { asyncHandler, promiseWithTimeout } from '../util/async.js';

export type InferBleDeviceAttributes<D extends BleDevice<any, any, any>> =
D extends BleDevice<infer TAttrs, any, any> ? TAttrs : DeviceAttributes;

export type InferBleDeviceConfig<D extends BleDevice<any, any, any>> =
D extends BleDevice<any, any, infer TCfg> ? TCfg : AnyDeviceConfig;
export type AnyBleDevice = WithUntypedAttributes<BleDevice>;

export default abstract class BleDevice<
TAttributes extends DeviceAttributes = DeviceAttributes,
Expand Down Expand Up @@ -78,6 +74,10 @@ export default abstract class BleDevice<
this.peripheral.on('disconnect', this.reconnectHandler);
}

public getPeripheral(): Peripheral {
return this.peripheral;
}

private async requestRssiUpdate(): Promise<void> {
if (this.closing || this.peripheral.state === 'disconnected') {
return;
Expand Down
17 changes: 7 additions & 10 deletions src/device/device.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,12 @@
import { Exclude, Expose } from 'class-transformer';
import DeviceState from './deviceState.js';
import DeviceAttribute from './attribute/deviceAttribute.js';
import DeviceAttribute, { AttributeValue } from './attribute/deviceAttribute.js';
import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js';
import { EventEmitter } from 'events';
import type { DeviceId } from './deviceId.js';
import type { JsonObject } from '../types.js';
import { DropFirst } from '../types.js';

export type InferDeviceAttributes<D extends Device<DeviceAttributes, DeviceNotifications, AnyDeviceConfig>> =
D extends Device<infer TAttrs, any, any> ? TAttrs : DeviceAttributes;

export type InferDeviceNotifications<D extends Device<DeviceAttributes, DeviceNotifications, AnyDeviceConfig>> =
D extends Device<any, infer TNotifs, any> ? TNotifs : AnyDeviceNotifications;

export type InferDeviceConfig<D extends Device<DeviceAttributes, DeviceNotifications, AnyDeviceConfig>> =
D extends Device<any, any, infer TCfg> ? TCfg : AnyDeviceConfig;

// An attribute value can be DeviceAttribute or undefined because we want to allow Partial<>
export type DeviceAttributes = Record<string, DeviceAttribute | undefined>;

Expand Down Expand Up @@ -58,6 +49,12 @@ export type DeviceEventMap<
[DeviceEvent.deviceNotification]: [device: TDevice, notification: DeviceNotification<TNotifications>];
}

export type WithUntypedAttributes<D extends Device<any, any, any>> = Omit<D, 'setAttribute'> & {
setAttribute(attributeName: string, value: AttributeValue): Promise<AttributeValue>;
};

export type AnyDevice = WithUntypedAttributes<Device>;

@Exclude()
export default abstract class Device<
TAttributes extends DeviceAttributes = DeviceAttributes,
Expand Down
Loading
Loading