Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
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
29 changes: 14 additions & 15 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,49 +104,50 @@ 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] });
});

settingsManager.on(SettingsEventType.changed, (settings: Settings) => {
io.emit(SettingsEventType.changed, serializer.transform<SerializedSettings>(settings));

deviceManager
.onSettingsChanged()
.catch(e => logError(logger, 'Failed to apply device enabled/disabled changes', e));

container.get('device.provider.loader')
.loadFromSettings(settings)
.catch(e => logError(logger, 'Failed to reload device sources after settings change', e));
});

// Automation events
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 logger = container.get('logger.default');
const settings = container.get('settings');
const deviceProviderManager = container.get('device.provider.loader');

deviceProviderManager.loadFromSettings(settings);

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));
};

const buildCorsOptions = (allowedOrigins: string[]): CorsOptions => ({
Expand Down Expand Up @@ -259,8 +260,6 @@ 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();
container.get('health.metricsCollector').stop();

Expand Down
6 changes: 3 additions & 3 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
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
13 changes: 8 additions & 5 deletions src/device/bleDevice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,17 @@ import { Peripheral } from '@stoprocent/noble';
import BaseError from 'modern-errors';
import Device, { DeviceAttributes, DeviceNotifications, NoDeviceNotifications } from './device.js';
import { AnyDeviceConfig, NoDeviceConfig } from './deviceConfig.js';
import { AttributeValue } from './attribute/deviceAttribute.js';
import { Expose } from 'class-transformer';
import { EventEmitter } from 'events';
import { DeviceId } from './deviceId.js';
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 = Omit<BleDevice, 'setAttribute'> & {
setAttribute(attributeName: string, value: AttributeValue): Promise<AttributeValue>;
};

export default abstract class BleDevice<
TAttributes extends DeviceAttributes = DeviceAttributes,
Expand Down Expand Up @@ -78,6 +77,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
15 changes: 5 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,10 @@ export type DeviceEventMap<
[DeviceEvent.deviceNotification]: [device: TDevice, notification: DeviceNotification<TNotifications>];
}

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

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