Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
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
275 changes: 275 additions & 0 deletions docs/used-resources.md

Large diffs are not rendered by default.

323 changes: 36 additions & 287 deletions package-lock.json

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions packages/adapter/src/lib/adapter/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9097,6 +9097,88 @@ export class AdapterClass extends EventEmitter {
return this.#async.registerNotification(scope, category, message, options);
}

/**
* Register an exclusive resource (serial port, TCP/UDP port, USB device, ...) as used by this instance.
*
* Exclusive resources are the ones that cannot be occupied by more than one instance at the same time.
* The information is forwarded to the host this instance runs on and stored under
* `system.host.<hostname>.usedResources.<type>`, so the user gets an overview of the occupied resources
* and can pick a free one when configuring a new instance.
*
* Registering is **additive**: call it once per occupied resource, in any order and from any number of
* async init paths. The host drops what this instance registered before whenever the instance starts, so
* a stale registration from a previous configuration cannot survive a restart.
*
* @param type the kind of resource, e.g. "serialPort" or "tcpPort"
* @param data the strictly typed payload describing the resource, e.g. `{ port: '/dev/ttyUSB0' }`
*/
async registerUsedResource<T extends ioBroker.UsedResourceType>(
type: T,
data: ioBroker.UsedResourceData<T>,
): Promise<void> {
return this.#async.registerUsedResource(type, data);
}

/**
* Free all exclusive resources this instance registered, across all types.
*
* This is not needed on start-up (the host already resets the registrations of a starting instance) nor
* on shutdown (the host marks them as no longer held). Use it when the instance drops everything it
* occupied while it keeps running, e.g. on a reconfiguration.
*/
async clearUsedResources(): Promise<void> {
return this.#async.clearUsedResources();
}

/**
* Free previously registered exclusive resources of this instance.
*
* `data` is a **filter, not the exact payload**: every field it names must match, fields it does not name
* are ignored. `freeUsedResource('tcpPort', { port: 8080 })` therefore also frees an entry registered as
* `{ port: 8080, bind: '0.0.0.0' }`, and if `data` is omitted, all registered resources of the given
* `type` for this instance are freed. The change is forwarded to the host this instance runs on and
* reflected in `system.host.<hostname>.usedResources.<type>`; a filter that matches nothing is logged by
* the host.
*
* @param type the kind of resource, e.g. "serialPort" or "tcpPort"
* @param data the fields identifying the resources to free; if omitted, all resources of `type` are freed
*/
async freeUsedResource<T extends ioBroker.UsedResourceType>(
type: T,
data?: Partial<ioBroker.UsedResourceData<T>>,
): Promise<void> {
return this.#async.freeUsedResource(type, data);
}

/**
* Query the exclusive resources currently registered as used on the **host** this instance runs on.
*
* Unlike `registerUsedResource`/`freeUsedResource`/`clearUsedResources`, which only ever touch the
* resources of this instance, this returns the resources of **all** instances of this host, so the user
* (or an admin UI) can present an overview of what is occupied and pick something free.
*
* Reading is done directly from the state's DB (`system.host.<hostname>.usedResources.<type>`), which the
* host keeps up to date; only the mutating calls go through the host to keep the registry consistent.
*
* @param type resource type to read, e.g. "serialPort"
* @returns the list of registered resources of that type (across all instances of this host)
*/
async getHostUsedResources<T extends ioBroker.UsedResourceType>(type: T): Promise<ioBroker.RegisteredResource<T>[]>;
/**
* Query the exclusive resources of every type currently registered as used on the host this instance runs on.
*
* @returns the list of registered resources (across all instances and types of this host)
*/
async getHostUsedResources(): Promise<ioBroker.RegisteredResource[]>;

/**
* @param type resource type to read; if omitted, the resources of every type are returned
* @returns the list of registered resources (across all instances of this host)
*/
async getHostUsedResources(type?: ioBroker.UsedResourceType): Promise<ioBroker.RegisteredResource[]> {
return type === undefined ? this.#async.getHostUsedResources() : this.#async.getHostUsedResources(type);
}

// external signatures
/**
* Writes value into states DB.
Expand Down
70 changes: 70 additions & 0 deletions packages/adapter/src/lib/adapter/asyncAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
import type { AdapterContext } from '@/lib/adapter/context.js';
import { CertificateManager } from '@/lib/adapter/managers/CertificateManager.js';
import { MessagingManager } from '@/lib/adapter/managers/MessagingManager.js';
import { ResourceManager } from '@/lib/adapter/managers/ResourceManager.js';
import { Validator } from '@/lib/adapter/validator.js';

/**
Expand All @@ -19,6 +20,7 @@ export class AsyncAdapter {
readonly #ctx: AdapterContext;
#messagingInstance?: MessagingManager;
#certificatesInstance?: CertificateManager;
#resourcesInstance?: ResourceManager;

/**
* @param ctx Shared adapter context providing live runtime state
Expand All @@ -37,6 +39,11 @@ export class AsyncAdapter {
return (this.#certificatesInstance ??= new CertificateManager(this.#ctx));
}

/** Lazily-constructed exclusive-resource manager. */
get #resources(): ResourceManager {
return (this.#resourcesInstance ??= new ResourceManager(this.#ctx));
}

/**
* Sends a message to another adapter instance and, unless `options.expectReply` is `false`,
* resolves with the reply when it arrives (or rejects with `Error('Timeout exceeded')` on timeout).
Expand Down Expand Up @@ -269,6 +276,69 @@ export class AsyncAdapter {
this.#certificatesInstance?.stopWatching();
}

/**
* Registers an exclusive resource (serial port, TCP/UDP port, USB device, ...) as used by this
* instance. The registration is forwarded to the host, which stores it under
* `system.host.<hostname>.usedResources.<type>`.
*
* @param type the kind of resource, e.g. "serialPort" or "tcpPort"
* @param data payload describing the resource
*/
async registerUsedResource<T extends ioBroker.UsedResourceType>(
type: T,
data: ioBroker.UsedResourceData<T>,
): Promise<void> {
Validator.assertString(type, 'type');
Validator.assertObject(data, 'data');
return this.#resources.registerUsedResource(type, data);
}

/**
* Frees all exclusive resources this instance registered, across all types.
*/
async clearUsedResources(): Promise<void> {
return this.#resources.clearUsedResources();
}

/**
* Frees previously registered exclusive resources of this instance. `data` is a filter: every field it
* names must match. If it is omitted, all registered resources of the given `type` are freed.
*
* @param type the kind of resource, e.g. "serialPort" or "tcpPort"
* @param data fields identifying the resources to free; if omitted, all resources of `type` are freed
*/
async freeUsedResource<T extends ioBroker.UsedResourceType>(
type: T,
data?: Partial<ioBroker.UsedResourceData<T>>,
): Promise<void> {
Validator.assertString(type, 'type');
if (data !== undefined) {
Validator.assertObject(data, 'data');
}
return this.#resources.freeUsedResource(type, data);
}

/**
* Reads the exclusive resources of the given type registered on this instance's host, across all
* instances of that host.
*
* @param type resource type to read, e.g. "serialPort"
*/
getHostUsedResources<T extends ioBroker.UsedResourceType>(type: T): Promise<ioBroker.RegisteredResource<T>[]>;
/** Reads the exclusive resources of every type registered on this instance's host. */
getHostUsedResources(): Promise<ioBroker.RegisteredResource[]>;

/**
* @param type resource type to read; if omitted, the resources of every type are read
*/
getHostUsedResources(type?: ioBroker.UsedResourceType): Promise<ioBroker.RegisteredResource[]> {
if (type !== undefined) {
Validator.assertString(type, 'type');
return this.#resources.getHostUsedResources(type);
}
return this.#resources.getHostUsedResources();
}

/**
* Resolves a pending reply promise for an acked messagebox message.
* Returns true if a pending entry was found and consumed.
Expand Down
178 changes: 178 additions & 0 deletions packages/adapter/src/lib/adapter/managers/ResourceManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import assert from 'node:assert/strict';
import sinon from 'sinon';
import { tools } from '@iobroker/js-controller-common';
import { ResourceManager } from './ResourceManager.js';
import type { AdapterContext } from '../context.js';

function makeContext(over: Partial<AdapterContext> = {}): AdapterContext {
return {
namespace: 'test.0',
namespaceLog: 'test.0',
logger: { silly() {}, debug() {}, info() {}, warn() {}, error() {} } as any,
uiMessagingController: {} as any,
states: null,
objects: null,
common: undefined,
config: {} as ioBroker.AdapterConfig,
host: 'localhost',
...over,
};
}

describe('ResourceManager.registerUsedResource', () => {
it('rejects with ERROR_DB_CLOSED when states is not connected', async () => {
const mgr = new ResourceManager(makeContext({ states: null }));
await assert.rejects(
() => mgr.registerUsedResource('serialPort', { port: '/dev/ttyUSB0' }),
new RegExp(tools.ERRORS.ERROR_DB_CLOSED),
);
});

it('forwards the resource to the host with the instance', async () => {
const pushMessage = sinon.stub().resolves();
const mgr = new ResourceManager(makeContext({ states: { pushMessage } as any }));

await mgr.registerUsedResource('serialPort', { port: '/dev/ttyUSB0' });

assert.equal(pushMessage.callCount, 1);
const [target, obj] = pushMessage.firstCall.args;
assert.equal(target, 'system.host.localhost');
assert.equal(obj.command, 'registerUsedResource');
assert.equal(obj.from, 'system.adapter.test.0');
assert.deepEqual(obj.message, {
type: 'serialPort',
data: { port: '/dev/ttyUSB0' },
instance: 'test.0',
});
});

it('is additive: every call forwards its own resource', async () => {
const pushMessage = sinon.stub().resolves();
const mgr = new ResourceManager(makeContext({ states: { pushMessage } as any }));

await mgr.registerUsedResource('serialPort', { port: '/dev/ttyUSB0' });
await mgr.registerUsedResource('tcpPort', { port: 1883 });

assert.equal(pushMessage.callCount, 2);
assert.deepEqual(
pushMessage.getCalls().map(call => call.args[1].message.type),
['serialPort', 'tcpPort'],
);
});
});

describe('ResourceManager.clearUsedResources', () => {
it('rejects with ERROR_DB_CLOSED when states is not connected', async () => {
const mgr = new ResourceManager(makeContext({ states: null }));
await assert.rejects(() => mgr.clearUsedResources(), new RegExp(tools.ERRORS.ERROR_DB_CLOSED));
});

it('forwards the request to the host', async () => {
const pushMessage = sinon.stub().resolves();
const mgr = new ResourceManager(makeContext({ states: { pushMessage } as any }));

await mgr.clearUsedResources();

const [target, obj] = pushMessage.firstCall.args;
assert.equal(target, 'system.host.localhost');
assert.equal(obj.command, 'clearUsedResources');
assert.equal(obj.from, 'system.adapter.test.0');
assert.deepEqual(obj.message, { instance: 'test.0' });
});
});

describe('ResourceManager.freeUsedResource', () => {
it('rejects with ERROR_DB_CLOSED when states is not connected', async () => {
const mgr = new ResourceManager(makeContext({ states: null }));
await assert.rejects(() => mgr.freeUsedResource('serialPort'), new RegExp(tools.ERRORS.ERROR_DB_CLOSED));
});

it('forwards a specific resource to free to the host', async () => {
const pushMessage = sinon.stub().resolves();
const mgr = new ResourceManager(makeContext({ states: { pushMessage } as any }));

await mgr.freeUsedResource('serialPort', { port: '/dev/ttyUSB0' });

const [target, obj] = pushMessage.firstCall.args;
assert.equal(target, 'system.host.localhost');
assert.equal(obj.command, 'freeUsedResource');
assert.deepEqual(obj.message, {
type: 'serialPort',
data: { port: '/dev/ttyUSB0' },
instance: 'test.0',
});
});

it('forwards a free-all request (no data) to the host', async () => {
const pushMessage = sinon.stub().resolves();
const mgr = new ResourceManager(makeContext({ states: { pushMessage } as any }));

await mgr.freeUsedResource('serialPort');

assert.equal(pushMessage.firstCall.args[1].message.data, undefined);
});
});

describe('ResourceManager.getHostUsedResources', () => {
it('throws when the host is unknown', async () => {
const mgr = new ResourceManager(makeContext({ host: undefined, states: {} as any }));
await assert.rejects(() => mgr.getHostUsedResources('serialPort'), /host of this instance is unknown/);
await assert.rejects(() => mgr.getHostUsedResources(), /host of this instance is unknown/);
});

it('reads and parses the resources of the given type from the host state', async () => {
const entries = [
{ type: 'serialPort', data: { port: '/dev/ttyUSB0' }, instance: 'test.0', ts: 1, isBlocked: true },
];
const getState = sinon.stub().resolves({ val: JSON.stringify(entries) });
const mgr = new ResourceManager(makeContext({ states: { getState } as any }));

const res = await mgr.getHostUsedResources('serialPort');

assert.equal(getState.firstCall.args[0], 'system.host.localhost.usedResources.serialPort');
assert.deepEqual(res, entries);
});

it('returns an empty list for a missing, empty or malformed state', async () => {
const getState = sinon.stub();
getState.onCall(0).resolves(null);
getState.onCall(1).resolves({ val: '' });
getState.onCall(2).resolves({ val: 'not-json' });
getState.onCall(3).resolves({ val: '{"not":"an array"}' });
const mgr = new ResourceManager(makeContext({ states: { getState } as any }));

assert.deepEqual(await mgr.getHostUsedResources('serialPort'), []);
assert.deepEqual(await mgr.getHostUsedResources('serialPort'), []);
assert.deepEqual(await mgr.getHostUsedResources('serialPort'), []);
assert.deepEqual(await mgr.getHostUsedResources('serialPort'), []);
});

it('collects and flattens resources across all types when no type is given', async () => {
const serial = [
{ type: 'serialPort', data: { port: '/dev/ttyUSB0' }, instance: 'test.0', ts: 1, isBlocked: true },
];
const tcp = [{ type: 'tcpPort', data: { port: 8080 }, instance: 'web.0', ts: 2, isBlocked: false }];
const getKeys = sinon
.stub()
.resolves([
'system.host.localhost.usedResources.serialPort',
'system.host.localhost.usedResources.tcpPort',
]);
const getStates = sinon.stub().resolves([{ val: JSON.stringify(serial) }, { val: JSON.stringify(tcp) }]);
const mgr = new ResourceManager(makeContext({ states: { getKeys, getStates } as any }));

const res = await mgr.getHostUsedResources();

assert.equal(getKeys.firstCall.args[0], 'system.host.localhost.usedResources.*');
assert.deepEqual(res, [...serial, ...tcp]);
});

it('returns an empty list when the host has no resource states', async () => {
const getKeys = sinon.stub().resolves([]);
const getStates = sinon.stub().resolves([]);
const mgr = new ResourceManager(makeContext({ states: { getKeys, getStates } as any }));

assert.deepEqual(await mgr.getHostUsedResources(), []);
assert.equal(getStates.callCount, 0);
});
});
Loading
Loading