diff --git a/.gitignore b/.gitignore index 8f0e8a9..fe3ba7b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,5 @@ *.code-workspace node_modules nbproject -build/ Thumbs.db diff --git a/.vscode/settings.json b/.vscode/settings.json index af09d1c..6e92909 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,7 @@ "editor.defaultFormatter": "esbenp.prettier-vscode", "[typescript]": { "editor.codeActionsOnSave": { - "source.organizeImports": true + "source.organizeImports": "explicit" } } } diff --git a/README.md b/README.md index 375c807..05eb2a2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Utility classes for ioBroker adapters to support [ioBroker.device-manager](https Add in your `io-package.json` the property `deviceManager: true` to `common.supportedMessages`. Note: If you don't have a `common.supportedMessages` property yet, you have to add it. -Also, if you don't have a `common.supportedMessages.messagebox: true` property yet, you have to add it. If common.messagebox exists, you can remove it. (see +Also, if you have a `common.messagebox` property for the adapter-specific messages, you can remove it and add `common.supportedMessages.custom: true`. (see https://github.com/ioBroker/ioBroker.js-controller/blob/274f9e8f84dbdaaba9830a6cc00ddf083e989090/schemas/io-package.json#L754C104-L754C178) In your ioBroker adapter, add a subclass of `DeviceManagement` and override the methods you need (see next chapters): @@ -30,7 +30,7 @@ class MyAdapter extends utils.Adapter { public constructor(options: Partial = {}) { super({ ...options, - name: "my-adapter", + name: 'my-adapter', }); this.deviceManagement = new DmTestDeviceManagement(this); @@ -62,7 +62,7 @@ the `DeviceManagement` implementation's `handleXxxAction()` is called, and the a ### Controls -The device manager tab allows the user to control devices too. If devices are controllable, the device manager tab shows a control elements in the device card. +The device manager tab allows the user to control devices too. If devices are controllable, the device manager tab shows the control elements in the device card. When the user clicks on a control (i.e., a button in the UI), the `DeviceManagement` implementation's `handleXxxAction()` is called, and the adapter can perform arbitrary actions @@ -73,7 +73,8 @@ the `DeviceManagement` implementation's `handleXxxAction()` is called, and the a The communication between the `ioBroker.device-manager` tab and the adapter happens through `sendTo`. **IMPORTANT:** make sure your adapter doesn't handle `sendTo` messages starting with `dm:`, otherwise the communication will not work. -- Use for Example this on the top of your onMessage Methode: + +- Use, for example, this on the top of your onMessage Methode: ```js if (obj.command?.startsWith('dm:')) { @@ -88,12 +89,13 @@ You can access all adapter methods like `getState()` or `getStateAsync()` via `t Example: `this.getState()` -> `this.adapter.getState()` ### Error Codes -| Code | Description | -|------|------------------------------------------------------------------------------------------------------------------------------| -| 101 | Instance action ${actionId} was called before getInstanceInfo() was called. This could happen if the instance has restarted. | -| 102 | Instance action ${actionId} is unknown. | -| 103 | Instance action ${actionId} is disabled because it has no handler. | -| 201 | Device action ${actionId} was called before listDevices() was called. This could happen if the instance has restarted. | + +| Code | Description | +| ---- | ---------------------------------------------------------------------------------------------------------------------------- | +| 101 | Instance action ${actionId} was called before getInstanceInfo() was called. This could happen if the instance has restarted. | +| 102 | Instance action ${actionId} is unknown. | +| 103 | Instance action ${actionId} is disabled because it has no handler. | +| 201 | Device action ${actionId} was called before loadDevices() was called. This could happen if the instance has restarted. | | 202 | Device action ${actionId} was called on unknown device: ${deviceId}. | | 203 | Device action ${actionId} doesn't exist on device ${deviceId}. | | 204 | Device action ${actionId} on ${deviceId} is disabled because it has no handler. | @@ -101,6 +103,7 @@ Example: `this.getState()` -> `this.adapter.getState()` ## Examples To get an idea of how to use `dm-utils`, please have a look at: + - [the folder "examples"](examples/dm-test.ts) or - [ioBroker.dm-test](https://github.com/UncleSamSwiss/ioBroker.dm-test) @@ -110,30 +113,33 @@ All methods can either return an object of the defined value or a `Promise` reso This allows you to implement the method synchronously or asynchronously, depending on your implementation. -### `listDevices()` +### `loadDevices(context: DeviceLoadContext)` This method must always be overridden (as it is abstract in the base class). -You must return an array with information about all devices of this adapter's instance. +You must fill the `context` with information about all devices of this adapter's instance. + +You may call `context.setTotalDevices(count: number)` as soon as possible to let the GUI know how many devices in total will be loaded. This allows the GUI to show the loading progress. This method is called when the user expands an instance in the list. -In most cases, you will get all states of your instance and fill the array with the relevant information. +In most cases, you will get all states of your instance and fill the `context` with the relevant information. -Every array entry is an object of type `DeviceInfo` which has the following properties: +Every item is an object of type `DeviceInfo` which has the following properties: -- `id` (string): a unique (human-readable) identifier of the device (it must be unique for your adapter instance only) +- `id` (JSON object): a unique identifier of the device (it must be unique for your adapter instance only) +- `identifier` (optional): a human-readable identifier of the device - `name` (string or translations): the human-readable name of this device - `status` (optional): the current status of the device, which has to be an object containing: - `connection` (string): alowed values are: `"connected"` / `"disconnected"` - `rssi` (number): rssi value of the connection - - `battery` (boolean / number): if boolean: false: Battery empty. If number: battery level of the device (shows also a battery symbol at card) - - `warning` (boolean / string): if boolean: true indicates a warning. If string: shows also the warning with mouseover + - `battery` (boolean / number): if boolean: false - the battery is empty. If number: the battery level of the device (shows also a battery symbol on the card) + - `warning` (boolean / string): if boolean: true indicates a warning. If a string: shows also the warning with mouseover - `actions` (array, optional): an array of actions that can be performed on the device; each object contains: - - `id` (string): unique identifier to recognize an action (never shown to the user) - - `icon` (string): an icon shown on the button (see below for details) - - `description` (string, optional): a text that will be shown as a tooltip on the button - - `disabled` (boolean, optional): if set to `true`, the button can't be clicked but is shown to the user + - `id` (string): unique identifier to recognize an action (never shown to the user) + - `icon` (string): an icon shown on the button (see below for details) + - `description` (string, optional): a text that will be shown as a tooltip on the button + - `handler` (function, optional): function that will be called when the user clicks on the button; if not given, the button will be disabled in the UI - `hasDetails` (boolean, optional): if set to `true`, the row of the device can be expanded and details are shown below Possible strings for device icons are here: [TYPE ICONS](https://github.com/ioBroker/adapter-react-v5/blob/main/src/Components/DeviceType/DeviceTypeIcon.tsx#L68) @@ -142,28 +148,30 @@ Possible strings for action icons are here: [ACTION NAMES](https://github.com/io
Possible strings for configuration icons are here: [CONFIGURATION TYPES](https://github.com/ioBroker/dm-utils/blob/b3e54ecfaedd6a239beec59c5deb8117d1d59d7f/src/types/common.ts#L110)
+ ### `getInstanceInfo()` This method allows the device manager tab to gather some general information about the instance. It is called when the user opens the tab. If you override this method, the returned object must contain: -- `apiVersion` (string): the supported API version; must be `"v1"` or `"v2"` (if "backend to GUI communication" is used or IDs instead of values) +- `apiVersion` (string): the supported API version; must be `"v3"` - `actions` (array, optional): an array of actions that can be performed on the instance; each object contains: - - `id` (string): unique identifier to recognize an action (never shown to the user) - - `icon` (string): an icon shown on the button (see below for details) - - `title` (string): the title shown next to the icon on the button - - `description` (string, optional): a text that will be shown as a tooltip on the button - - `disabled` (boolean, optional): if set to `true`, the button can't be clicked but is shown to the user -- `communicationStateId` (string) (optional): the ID of the state that is used by backend for communication with front-end (only API v2) + - `id` (string): unique identifier to recognize an action (never shown to the user) + - `icon` (string): an icon shown on the button (see below for details) + - `title` (string): the title shown next to the icon on the button + - `description` (string, optional): a text that will be shown as a tooltip on the button + - `handler` (function, optional): function that will be called when the user clicks on the button; if not given, the button will be disabled in the UI +- `communicationStateId` (string, optional): the ID of the state that is used by backend for communication with front-end +- `identifierLabel` (string or translations, optional): the human-readable label next to the identifier -### `getDeviceDetails(id: string)` +### `getDeviceDetails(id: DeviceId)` This method is called if a device's `hasDetails` is set to `true` and the user clicks on the expander. The returned object must contain: -- `id` (string): the `id` given as parameter to the method call +- `id` (JSON object): the `id` given as parameter to the method call - `schema` (Custom JSON form schema): the schema of the Custom JSON form to show below the device information - `data` (object, optional): the data used to populate the Custom JSON form @@ -171,31 +179,37 @@ For more details about the schema, see [here](https://github.com/ioBroker/ioBrok Please keep in mind that there is no "Save" button, so in most cases, the form shouldn't contain editable fields, but you may use `sendTo` objects to send data to the adapter. -### `handleInstanceAction(actionId: string, context: ActionContext) +## `DeviceManagement` handlers -This method is called when to user clicks on an action (i.e., button) for an adapter instance. +### InstanceInfo action handlers + +These functions are called when the user clicks on an action (i.e., button) for an adapter instance. + +The parameters of this function are: -The parameters of this method are: -- `actionId` (string): the `id` that was given in `getInstanceInfo()` --> `actions[].id` - `context` (object): object containing helper methods that can be used when executing the action +- `options` (object): object containing the action `value` (if given) The returned object must contain: + - `refresh` (boolean): set this to `true` if you want the list to be reloaded after this action This method can be implemented asynchronously and can take a lot of time to complete. See below for how to interact with the user. -### `handleDeviceAction(deviceId: string, actionId: string, context: ActionContext) +### DeviceInfo action handlers -This method is called when the user clicks on an action (i.e., button) for a device. +These functions are called when the user clicks on an action (i.e., button) for an adapter instance. -The parameters of this method are: -- `deviceId` (string): the `id` that was given in `listDevices()` --> `[].id` -- `actionId` (string): the `id` that was given in `listDevices()` --> `[].actions[].id` +The parameters of this function are: + +- `deviceId` (JSON object): the `id` of the device - `context` (object): object containing helper methods that can be used when executing the action +- `options` (object): object containing the action `value` (if given) The returned object must contain: + - `refresh` (string / boolean): the following values are allowed: - `"device"`: if you want the device details to be reloaded after this action - `"instance"`: if you want the entire device list to be reloaded after this action @@ -205,32 +219,32 @@ This method can be implemented asynchronously and can take a lot of time to comp See below for how to interact with the user. -### `handleDeviceControl(deviceId: string, controlId: string, state: ControlState, context: MessageContext) +### DeviceInfo control handlers -This method is called when the user clicks on a control (i.e., slider) in the device card. +These functions are called when the user clicks on a control (i.e., slider) in the device card. The parameters of this method are: -- `deviceId` (string): the `id` that was given in `listDevices()` --> `[].id` -- `controlId` (string): the `id` that was given in `listDevices()` --> `[].controls[].id`. There are some reserved control names, you can find the list below. -- `state` (string | number | boolean): new state for the control, that will be sent to a real device + +- `deviceId` (JSON object): the `id` that was given in `loadDevices()` --> `[].id` +- `controlId` (string): the `id` that was given in `loadDevices()` --> `[].controls[].id`. There are some reserved control names, you can find the list below. +- `newState` (string | number | boolean): new state for the control, that will be sent to a real device - `context` (object): object containing helper methods that can be used when executing the action -The returned object must contain: -- `state`: ioBroker state object +The returned object must be an ioBroker state object. This method can be implemented asynchronously and can take a lot of time to complete. -### `handleDeviceControlState(deviceId: string, controlId: string, context: MessageContext) +### DeviceInfo getState handlers -This method is called when GUI requests the update of the state. +These functions are called when GUI requests the update of the state. The parameters of this method are: -- `deviceId` (string): the `id` that was given in `listDevices()` --> `[].id` -- `controlId` (string): the `id` that was given in `listDevices()` --> `[].controls[].id` + +- `deviceId` (JSON object): the `id` that was given in `loadDevices()` --> `[].id` +- `controlId` (string): the `id` that was given in `loadDevices()` --> `[].controls[].id` - `context` (object): object containing helper methods that can be used when executing the action -The returned object must contain: -- `state`: ioBroker state object +The returned object must be an ioBroker state object. This method can be implemented asynchronously and can take a lot of time to complete. @@ -244,6 +258,7 @@ Inside an action method (`handleInstanceAction()` or `handleDeviceAction()`) you For interactions, there are methods you can call on `context`: There are some reserved action names, you can find the list below: + - `status` - This action is called when the user clicks on the status icon. So to implement the "click-on-status" functionality, the developer has to implement this action. - `disable` - This action will be called when the user clicks on the `enabled` icon. `disable` and `enable` actions cannot be together. - `enable` - This action will be called when the user clicks on the `disabled` icon. `disable` and `enable` actions cannot be together. @@ -253,6 +268,7 @@ There are some reserved action names, you can find the list below: Shows a message to the user. The method has the following parameter: + - `text` (string or translation): the text to show to the user This asynchronous method returns (or rather: the Promise is resolved) once the user has clicked on "OK". @@ -262,23 +278,28 @@ This asynchronous method returns (or rather: the Promise is resolved) once the u Lets the user confirm an action by showing a message with an "OK" and "Cancel" button. The method has the following parameter: + - `text` (string or translation): the text to show to the user This asynchronous method returns (or rather: the Promise is resolved) once the user has clicked a button in the dialog: + - `true` if the user clicked "OK" - `false` if the user clicked "Cancel" -### `showForm(schema: JsonFormSchema, options?: { data?: JsonFormData; title?: string })` +### `showForm(schema: JsonFormSchema, options?: { data?: JsonFormData; title?: string; ignoreApplyDisabled?: boolean })` Shows a dialog with a Custom JSON form that can be edited by the user. The method has the following parameters: + - `schema` (Custom JSON form schema): the schema of the Custom JSON form to show in the dialog - `options` (object, optional): options to configure the dialog further - - `data` (object, optional): the data used to populate the Custom JSON form - - `title` (string, optional): the dialog title + - `data` (object, optional): the data used to populate the Custom JSON form + - `title` (string, optional): the dialog title + - `ignoreApplyDisabled` (boolean, optional): set to `true` to always enable the "OK" button even if the form is unchanged This asynchronous method returns (or rather: the Promise is resolved) once the user has clicked a button in the dialog: + - the form data, if the user clicked "OK" - `undefined`, if the user clicked "Cancel" @@ -287,11 +308,12 @@ This asynchronous method returns (or rather: the Promise is resolved) once the u Shows a dialog with a linear progress bar to the user. There is no way for the user to dismiss this dialog. The method has the following parameters: + - `title` (string): the dialog title - `options` (object, optional): options to configure the dialog further - - `indeterminate` (boolean, optional): set to `true` to visualize an unspecified wait time - - `value` (number, optional): the progress value to show to the user (if set, it must be a value between 0 and 100) - - `label` (string, optional): label to show to the right of the progress bar; you may show the progress value in a human-readable way (e.g. "42%") or show the current step in a multi-step progress (e.g. "Logging in...") + - `indeterminate` (boolean, optional): set to `true` to visualize an unspecified wait time + - `value` (number, optional): the progress value to show to the user (if set, it must be a value between 0 and 100) + - `label` (string, optional): the label to show to the right of the progress bar; you may show the progress value in a human-readable way (e.g. "42%") or show the current step in multi-step progress (e.g. "Logging in...") This method returns a promise that resolves to a `ProgressDialog` object. @@ -300,158 +322,209 @@ This method returns a promise that resolves to a `ProgressDialog` object. `ProgressDialog` has two methods: - `update(update: { title?: string; indeterminate?: boolean; value?:number; label?: string; })` - - Updates the progress dialog with new values - - The method has the following parameter: - - `update` (object): what to update in the dialog - - `title` (string, optional): change the dialog title - - `indeterminate` (boolean, optional): change whether the progress is indeterminate - - `value` (number, optional): change the progress value - - `label` (string, optional): change the label to the right of the progress bar + - Updates the progress dialog with new values + - The method has the following parameter: + - `update` (object): what to update in the dialog + - `title` (string, optional): change the dialog title + - `indeterminate` (boolean, optional): change whether the progress is indeterminate + - `value` (number, optional): change the progress value (if set, it must be a value between 0 and 100) + - `label` (string, optional): change the label to the right of the progress bar - `close()` - - Closes the progress dialog (and allows you to open other dialogs) + - Closes the progress dialog (and allows you to open other dialogs) ### `sendCommandToGui(command: BackendToGuiCommand)` -Sends command to GUI to add/update/delete devices or to update the status of device. +Sends command to GUI to add/update/delete devices or to update the status of a device. + +**It is suggested** to use the state's ID directly in the DeviceInfo structure instead of sending the command every time to GUI on status update. -**It is suggested** to use the state's ID directly in DeviceInfo structure instead of sending the command every time to GUI on status update. +See the example below: -See example below: ```ts class MyAdapterDeviceManagement extends DeviceManagement { - protected listDevices(): RetVal{ + protected loadDevices(context: DeviceLoadContext): void { const deviceInfo: DeviceInfo = { - id: 'uniqieID', - name: 'My device', - icon: 'node', // find possible icons here: https://github.com/ioBroker/adapter-react-v5/blob/main/src/Components/DeviceType/DeviceTypeIcon.tsx#L68 - manufacturer: { objectId: 'uniqieID', property: 'native.manufacturer' }, - model: { objectId: 'uniqieID', property: 'native.model' }, - status: { - battery: { stateId: 'uniqieID.DevicePower0.BatteryPercent' }, - connection: { stateId: 'uniqieID.online', mapping: {'true': 'connected', 'false': 'disconnected'} }, - rssi: { stateId: 'uniqieID.rssi' }, - }, - hasDetails: true, + id: 'uniqieID', + name: 'My device', + icon: 'node', // find possible icons here: https://github.com/ioBroker/adapter-react-v5/blob/main/src/Components/DeviceType/DeviceTypeIcon.tsx#L68 + manufacturer: { objectId: 'uniqieID', property: 'native.manufacturer' }, + model: { objectId: 'uniqieID', property: 'native.model' }, + status: { + battery: { stateId: 'uniqieID.DevicePower0.BatteryPercent' }, + connection: { stateId: 'uniqieID.online', mapping: { true: 'connected', false: 'disconnected' } }, + rssi: { stateId: 'uniqieID.rssi' }, + }, + hasDetails: true, }; - return [deviceInfo]; + context.addDevice(deviceInfo); } } ``` - + +## Migration from 1.x to 2.x + +Between versions 1.x and 2.x, there are some breaking changes. Please also have a look at the changelog below for more information. + +### Incremental loading of devices + +In version 1.x, the `listDevices()` method had to return the full list of devices. +In version 2.x, this method was replaced by `loadDevices(context: DeviceLoadContext)` that allows incremental loading of devices. + +Instead of creating and returning an array of `DeviceInfo` objects, you have to call `context.addDevice(deviceInfo)` for each device you want to add to the list. + +You may also call `context.setTotalDevices(count: number)` as soon as possible to let the GUI know how many devices in total will be loaded. + +### Refresh response of device actions + +In version 2.x, the refresh response of device actions has changed. + +| Version 1.x | Version 2.x | Description | +| ------------ | ------------ | --------------------------------------------------------------------------- | +| `true` | `'all'` | the instance information as well as the entire device list will be reloaded | +| `false` | `'none'` | nothing will be reloaded | +| `'device'` | `'devices'` | the entire device list will be reloaded | +| `'instance'` | `'instance'` | (unchanged) only the instance information will be reloaded | + +## Changelog + -## Changelog + +### **WORK IN PROGRESS** + +- (@UncleSamSwiss) Enabled incremental loading of devices +- (@UncleSamSwiss) Removed direct access to `DeviceManagement.handleXxx()` methods (use `handler` and similar properties instead) +- (@UncleSamSwiss) Added `identifier` property to `DeviceInfo` for human-readable identifiers +- (@UncleSamSwiss) Device refresh responses can no longer be a `boolean` and `'device'` was renamed to `'devices'`. +- (@UncleSamSwiss) Added `info` icon and possibility for actions to be a link (by providing a `url` property instead of a `handler` function) + ### 2.0.2 (2026-01-28) -* (@GermanBluefox) BREAKING: Admin/GUI must have version 9 (or higher) of `dm-gui-components` -* (@GermanBluefox) Added types to update status of device directly from state -* (@GermanBluefox) Added backend to GUI communication possibility -* (@GermanBluefox) Added `dm:deviceInfo` command -* (@GermanBluefox) Added `dm:deviceStatus` command + +- (@GermanBluefox) BREAKING: Admin/GUI must have version 9 (or higher) of `dm-gui-components` +- (@GermanBluefox) Added types to update the status of a device directly from the state +- (@GermanBluefox) Added backend to GUI communication possibility +- (@GermanBluefox) Added `dm:deviceInfo` command +- (@GermanBluefox) Added `dm:deviceStatus` command ### 1.0.16 (2026-01-02) -* (@GermanBluefox) Added `ignoreApplyDisabled` flag -* (@GermanBluefox) Added `update` icon + +- (@GermanBluefox) Added `ignoreApplyDisabled` flag +- (@GermanBluefox) Added `update` icon ### 1.0.13 (2025-10-21) -* (@GermanBluefox) Updated packages + +- (@GermanBluefox) Updated packages ### 1.0.10 (2025-05-05) -* (@GermanBluefox) Added timeout property to actions -* (@GermanBluefox) Updated packages +- (@GermanBluefox) Added timeout property to actions +- (@GermanBluefox) Updated packages ### 1.0.9 (2025-01-25) -* (@GermanBluefox) Added copyToClipboard dialog button +- (@GermanBluefox) Added copyToClipboard dialog button ### 1.0.8 (2025-01-24) -* (@GermanBluefox) Removed `headerTextColor` to device info +- (@GermanBluefox) Removed `headerTextColor` to device info ### 1.0.6 (2025-01-14) - -* (@GermanBluefox) Added the connection type indication + +- (@GermanBluefox) Added the connection type indication ### 1.0.5 (2025-01-11) -* (@GermanBluefox) Added action ENABLE_DISABLE and `enabled` status +- (@GermanBluefox) Added action ENABLE_DISABLE and `enabled` status ### 1.0.0 (2025-01-08) -* (@GermanBluefox) Added `disabled` options for a device -* (@GermanBluefox) Major release just because is good enough. No breaking changes. +- (@GermanBluefox) Added `disabled` options for a device +- (@GermanBluefox) Major release just because it is good enough. No breaking changes. ### 0.6.11 (2024-12-11) -* (@GermanBluefox) Do not close handler for progress +- (@GermanBluefox) Do not close handler for progress ### 0.6.10 (2024-12-10) -* (@GermanBluefox) Export `BackEndCommandJsonFormOptions` type +- (@GermanBluefox) Export `BackEndCommandJsonFormOptions` type ### 0.6.9 (2024-11-22) -* (@GermanBluefox) Added a max-width option for form +- (@GermanBluefox) Added a max-width option for form ### 0.6.8 (2024-11-22) -* (@GermanBluefox) Allowed grouping of devices +- (@GermanBluefox) Allowed grouping of devices ### 0.6.7 (2024-11-20) -* (@GermanBluefox) Updated types +- (@GermanBluefox) Updated types ### 0.6.6 (2024-11-18) -* (@GermanBluefox) Added configurable buttons for form +- (@GermanBluefox) Added configurable buttons for form ### 0.6.0 (2024-11-17) -* (@GermanBluefox) used new ioBroker/eslint-config lib and changed prettifier settings -* (@GermanBluefox) updated JsonConfig types +- (@GermanBluefox) used new ioBroker/eslint-config lib and changed prettifier settings +- (@GermanBluefox) updated JsonConfig types ### 0.5.0 (2024-08-30) -* (bluefox) Migrated to eslint 9 + +- (bluefox) Migrated to eslint 9 ### 0.4.0 (2024-08-30) -* (bluefox) Added `state` type for JSON config + +- (bluefox) Added `state` type for JSON config ### 0.3.1 (2024-07-18) -* (bluefox) Added qrCode type for JSON config + +- (bluefox) Added qrCode type for JSON config ### 0.3.0 (2024-07-17) -* (bluefox) packages updated -* (bluefox) Updated JSON config types + +- (bluefox) packages updated +- (bluefox) Updated JSON config types ### 0.2.2 (2024-06-26) -* (bluefox) packages updated + +- (bluefox) packages updated ### 0.2.0 (2024-05-29) -* (bluefox) enhanced type exports -* (bluefox) added confirmation and input text options + +- (bluefox) enhanced type exports +- (bluefox) added confirmation and input text options ### 0.1.9 (2023-12-25) -* (foxriver76) enhanced type exports + +- (foxriver76) enhanced type exports ### 0.1.8 (2023-12-17) -* (bluefox) corrected control error + +- (bluefox) corrected control error ### 0.1.7 (2023-12-17) -* (bluefox) added channel info + +- (bluefox) added channel info ### 0.1.5 (2023-12-16) -* (bluefox) extended controls with unit and new control types + +- (bluefox) extended controls with unit and new control types ### 0.1.4 (2023-12-13) -* (bluefox) added error codes + +- (bluefox) added error codes ### 0.1.3 (2023-12-10) -* (bluefox) added some fields to DeviceInfo interface -* (bluefox) added control possibilities + +- (bluefox) added some fields to DeviceInfo interface +- (bluefox) added control possibilities ## License + MIT License Copyright (c) 2023-2026 ioBroker Community Developers diff --git a/build/ActionContext.d.ts b/build/ActionContext.d.ts new file mode 100644 index 0000000..7dd80d6 --- /dev/null +++ b/build/ActionContext.d.ts @@ -0,0 +1,8 @@ +import type { BackEndCommandJsonFormOptions, JsonFormData, JsonFormSchema, ProgressOptions } from '.'; +import type { ProgressDialog } from './ProgressDialog'; +export interface ActionContext { + showMessage(text: ioBroker.StringOrTranslated): Promise; + showConfirmation(text: ioBroker.StringOrTranslated): Promise; + showForm(schema: JsonFormSchema, options?: BackEndCommandJsonFormOptions): Promise; + openProgress(title: string, options?: ProgressOptions): Promise; +} diff --git a/build/ActionContext.js b/build/ActionContext.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/build/ActionContext.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/build/DeviceManagement.d.ts b/build/DeviceManagement.d.ts new file mode 100644 index 0000000..4241553 --- /dev/null +++ b/build/DeviceManagement.d.ts @@ -0,0 +1,56 @@ +import type { AdapterInstance } from '@iobroker/adapter-core'; +import type { ActionContext } from './ActionContext'; +import type { ProgressDialog } from './ProgressDialog'; +import { type ActionButton, type DeviceDetails, type DeviceId, type DeviceInfo, type DeviceStatus, type ErrorResponse, type InstanceDetails, type JsonFormData, type JsonFormSchema, type RetVal } from './types'; +import type { BackendToGuiCommand, DeviceRefreshResponse, InstanceRefreshResponse } from './types/base'; +import type { ProgressOptions } from './types/common'; +export type DeviceLoadContext = { + addDevice(device: DeviceInfo): void; + setTotalDevices(count: number): void; +}; +export declare abstract class DeviceManagement { + protected readonly adapter: TAdapter; + private instanceInfo?; + private devices?; + private readonly communicationStateId; + private readonly deviceLoadContexts; + private readonly messageContexts; + constructor(adapter: TAdapter, communicationStateId?: string | boolean); + private ensureCommunicationState; + protected sendCommandToGui(command: BackendToGuiCommand): Promise; + protected get log(): ioBroker.Log; + protected getInstanceInfo(): RetVal; + protected abstract loadDevices(context: DeviceLoadContext): RetVal; + protected getDeviceInfo(_deviceId: TId): RetVal>; + protected getDeviceStatus(_deviceId: TId): RetVal; + protected getDeviceDetails(id: TId): RetVal | null | { + error: string; + }>; + private handleInstanceAction; + private handleDeviceAction; + private handleDeviceControl; + private handleDeviceControlState; + private onMessage; + private handleMessage; + private sendReply; +} +export declare class MessageContext implements ActionContext { + private readonly adapter; + private hasOpenProgressDialog; + private lastMessage?; + private progressHandler?; + constructor(msg: ioBroker.Message, adapter: AdapterInstance); + showMessage(text: ioBroker.StringOrTranslated): Promise; + showConfirmation(text: ioBroker.StringOrTranslated): Promise; + showForm(schema: JsonFormSchema, options?: { + data?: JsonFormData; + title?: ioBroker.StringOrTranslated; + buttons?: (ActionButton | 'apply' | 'cancel' | 'close')[]; + }): Promise; + openProgress(title: ioBroker.StringOrTranslated, options?: ProgressOptions): Promise; + sendFinalResult(result: ErrorResponse | DeviceRefreshResponse<'api', TId> | InstanceRefreshResponse): void; + sendControlResult(deviceId: TId, controlId: string, result: ErrorResponse | ioBroker.State): void; + handleProgress(message: ioBroker.Message): void; + private checkPreconditions; + private send; +} diff --git a/build/DeviceManagement.js b/build/DeviceManagement.js new file mode 100644 index 0000000..ec6ff0f --- /dev/null +++ b/build/DeviceManagement.js @@ -0,0 +1,540 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MessageContext = exports.DeviceManagement = void 0; +const types_1 = require("./types"); +class DeviceManagement { + constructor(adapter, communicationStateId) { + this.adapter = adapter; + this.deviceLoadContexts = new Map(); + this.messageContexts = new Map(); + adapter.on('message', this.onMessage.bind(this)); + if (communicationStateId === true) { + // use standard ID `info.deviceManager` + this.communicationStateId = 'info.deviceManager'; + } + else if (communicationStateId) { + this.communicationStateId = communicationStateId; + } + if (this.communicationStateId) { + this.ensureCommunicationState().catch(e => this.log().error(`Cannot initialize communication state: ${e}`)); + } + } + async ensureCommunicationState() { + let stateObj = await this.adapter.getObjectAsync(this.communicationStateId); + if (!stateObj) { + stateObj = { + _id: this.communicationStateId, + type: 'state', + common: { + expert: true, + name: 'Communication with GUI for device manager', + type: 'string', + role: 'state', + def: '', + read: true, + write: false, + }, + native: {}, + }; + await this.adapter.setObjectAsync(this.communicationStateId, stateObj); + } + } + async sendCommandToGui(command) { + if (this.communicationStateId) { + await this.adapter.setState(this.communicationStateId, JSON.stringify(command), true); + } + else { + throw new Error('Communication state not found'); + } + } + get log() { + return this.adapter.log; + } + getInstanceInfo() { + // Overload this method if your adapter does not use BackendToGui communication and States/Objects in DeviceInfo + return { apiVersion: 'v3', communicationStateId: this.communicationStateId || undefined }; + } + getDeviceInfo(_deviceId) { + throw new Error('Do not send "infoUpdate" or "delete" command without implementing getDeviceInfo method!'); + } + getDeviceStatus(_deviceId) { + throw new Error('Do not send "statusUpdate" command without implementing getDeviceStatus method!'); + } + getDeviceDetails(id) { + return { id, schema: {} }; + } + handleInstanceAction(actionId, context, options) { + var _a; + if (!this.instanceInfo) { + this.log.warn(`Instance action ${actionId} was called before getInstanceInfo()`); + return { + error: { + code: types_1.ErrorCodes.E_INSTANCE_ACTION_NOT_INITIALIZED, + message: `Instance action ${actionId} was called before getInstanceInfo()`, + }, + }; + } + const action = (_a = this.instanceInfo.actions) === null || _a === void 0 ? void 0 : _a.find(a => a.id === actionId); + if (!action) { + this.log.warn(`Instance action ${actionId} is unknown`); + return { + error: { + code: types_1.ErrorCodes.E_INSTANCE_ACTION_UNKNOWN, + message: `Instance action ${actionId} is unknown`, + }, + }; + } + if (!('handler' in action) || !action.handler) { + this.log.warn(`Instance action ${actionId} is disabled because it has no handler`); + return { + error: { + code: types_1.ErrorCodes.E_INSTANCE_ACTION_NO_HANDLER, + message: `Instance action ${actionId} is disabled because it has no handler`, + }, + }; + } + return action.handler(context, options); + } + handleDeviceAction(deviceId, actionId, context, options) { + var _a; + if (!this.devices) { + this.log.warn(`Device action ${actionId} was called before loadDevices()`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_ACTION_NOT_INITIALIZED, + message: `Device action ${actionId} was called before loadDevices()`, + }, + }; + } + const jsonId = JSON.stringify(deviceId); + const device = this.devices.get(jsonId); + if (!device) { + this.log.warn(`Device action ${actionId} was called on unknown device: ${jsonId}`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_ACTION_DEVICE_UNKNOWN, + message: `Device action ${actionId} was called on unknown device: ${jsonId}`, + }, + }; + } + const action = (_a = device.actions) === null || _a === void 0 ? void 0 : _a.find(a => a.id === actionId); + if (!action) { + this.log.warn(`Device action ${actionId} doesn't exist on device ${jsonId}`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_ACTION_UNKNOWN, + message: `Device action ${actionId} doesn't exist on device ${jsonId}`, + }, + }; + } + if (!('handler' in action) || !action.handler) { + this.log.warn(`Device action ${actionId} on ${jsonId} is disabled because it has no handler`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_ACTION_NO_HANDLER, + message: `Device action ${actionId} on ${jsonId} is disabled because it has no handler`, + }, + }; + } + return action.handler(deviceId, context, options); + } + handleDeviceControl(deviceId, controlId, newState, context) { + var _a; + if (!this.devices) { + this.log.warn(`Device control ${controlId} was called before loadDevices()`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_CONTROL_NOT_INITIALIZED, + message: `Device control ${controlId} was called before loadDevices()`, + }, + }; + } + const jsonId = JSON.stringify(deviceId); + const device = this.devices.get(jsonId); + if (!device) { + this.log.warn(`Device control ${controlId} was called on unknown device: ${jsonId}`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_CONTROL_DEVICE_UNKNOWN, + message: `Device control ${controlId} was called on unknown device: ${jsonId}`, + }, + }; + } + const control = (_a = device.controls) === null || _a === void 0 ? void 0 : _a.find(a => a.id === controlId); + if (!control) { + this.log.warn(`Device control ${controlId} doesn't exist on device ${jsonId}`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_CONTROL_UNKNOWN, + message: `Device control ${controlId} doesn't exist on device ${jsonId}`, + }, + }; + } + if (!control.handler) { + this.log.warn(`Device control ${controlId} on ${jsonId} is disabled because it has no handler`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_CONTROL_NO_HANDLER, + message: `Device control ${controlId} on ${jsonId} is disabled because it has no handler`, + }, + }; + } + return control.handler(deviceId, controlId, newState, context); + } + // request state of control + handleDeviceControlState(deviceId, controlId, context) { + var _a; + if (!this.devices) { + this.log.warn(`Device get state ${controlId} was called before loadDevices()`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_GET_STATE_NOT_INITIALIZED, + message: `Device control ${controlId} was called before loadDevices()`, + }, + }; + } + const jsonId = JSON.stringify(deviceId); + const device = this.devices.get(jsonId); + if (!device) { + this.log.warn(`Device get state ${controlId} was called on unknown device: ${jsonId}`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_GET_STATE_DEVICE_UNKNOWN, + message: `Device control ${controlId} was called on unknown device: ${jsonId}`, + }, + }; + } + const control = (_a = device.controls) === null || _a === void 0 ? void 0 : _a.find(a => a.id === controlId); + if (!control) { + this.log.warn(`Device get state ${controlId} doesn't exist on device ${jsonId}`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_GET_STATE_UNKNOWN, + message: `Device control ${controlId} doesn't exist on device ${jsonId}`, + }, + }; + } + if (!control.getStateHandler) { + this.log.warn(`Device get state ${controlId} on ${jsonId} is disabled because it has no handler`); + return { + error: { + code: types_1.ErrorCodes.E_DEVICE_GET_STATE_NO_HANDLER, + message: `Device get state ${controlId} on ${jsonId} is disabled because it has no handler`, + }, + }; + } + return control.getStateHandler(deviceId, controlId, context); + } + onMessage(obj) { + if (!obj.command.startsWith('dm:')) { + return; + } + void this.handleMessage(obj).catch(this.log.error); + } + async handleMessage(msg) { + var _a; + this.log.debug(`DeviceManagement received: ${JSON.stringify(msg)}`); + switch (msg.command) { + case 'dm:instanceInfo': { + this.instanceInfo = await this.getInstanceInfo(); + this.sendReply(Object.assign(Object.assign({}, this.instanceInfo), { actions: convertActions(this.instanceInfo.actions) }), msg); + return; + } + case 'dm:loadDevices': { + const context = new DeviceLoadContextImpl(msg, this.adapter); + this.deviceLoadContexts.set(msg._id, context); + await this.loadDevices(context); + if (context.complete()) { + this.deviceLoadContexts.delete(msg._id); + } + this.devices = context.devices.reduce((map, value) => { + const jsonId = JSON.stringify(value.id); + if (map.has(jsonId)) { + throw new Error(`Device ID ${jsonId} is not unique`); + } + map.set(jsonId, value); + return map; + }, new Map()); + return; + } + case 'dm:deviceInfo': { + const deviceInfo = await this.getDeviceInfo(msg.message); + this.sendReply(Object.assign(Object.assign({}, deviceInfo), { actions: convertActions(deviceInfo.actions), controls: convertControls(deviceInfo.controls) }), msg); + return; + } + case 'dm:deviceStatus': { + const deviceStatus = await this.getDeviceStatus(msg.message); + this.sendReply(deviceStatus, msg); + return; + } + case 'dm:deviceDetails': { + const details = await this.getDeviceDetails(msg.message); + this.sendReply(details, msg); + return; + } + case 'dm:instanceAction': { + const action = msg.message; + const context = new MessageContext(msg, this.adapter); + this.messageContexts.set(msg._id, context); + const result = await this.handleInstanceAction(action.actionId, context, { value: action.value }); + this.messageContexts.delete(msg._id); + context.sendFinalResult(result); + return; + } + case 'dm:deviceAction': { + const action = msg.message; + const context = new MessageContext(msg, this.adapter); + this.messageContexts.set(msg._id, context); + const result = await this.handleDeviceAction(action.deviceId, action.actionId, context, { + value: action.value, + }); + this.messageContexts.delete(msg._id); + if ('update' in result) { + // special handling for update responses (we need to update our cache and convert actions/controls before sending to GUI) + const update = result.update; + (_a = this.devices) === null || _a === void 0 ? void 0 : _a.set(JSON.stringify(update.id), update); + context.sendFinalResult({ + update: Object.assign(Object.assign({}, update), { actions: convertActions(update.actions), controls: convertControls(update.controls) }), + }); + } + else { + context.sendFinalResult(result); + } + return; + } + case 'dm:deviceControl': { + const control = msg.message; + const context = new MessageContext(msg, this.adapter); + this.messageContexts.set(msg._id, context); + const result = await this.handleDeviceControl(control.deviceId, control.controlId, control.state, context); + this.messageContexts.delete(msg._id); + context.sendControlResult(control.deviceId, control.controlId, result); + return; + } + case 'dm:deviceControlState': { + const control = msg.message; + const context = new MessageContext(msg, this.adapter); + this.messageContexts.set(msg._id, context); + const result = await this.handleDeviceControlState(control.deviceId, control.controlId, context); + this.messageContexts.delete(msg._id); + context.sendControlResult(control.deviceId, control.controlId, result); + return; + } + case 'dm:deviceLoadProgress': { + const { origin } = msg.message; + const context = this.deviceLoadContexts.get(origin); + if (!context) { + this.log.warn(`Unknown message origin: ${origin}`); + this.sendReply({ error: 'Unknown load progress origin' }, msg); + return; + } + if (context.handleProgress(msg)) { + this.deviceLoadContexts.delete(origin); + } + return; + } + case 'dm:actionProgress': { + const { origin } = msg.message; + const context = this.messageContexts.get(origin); + if (!context) { + this.log.warn(`Unknown message origin: ${origin}`); + this.sendReply({ error: 'Unknown action origin' }, msg); + return; + } + context.handleProgress(msg); + return; + } + } + } + sendReply(reply, msg) { + this.adapter.sendTo(msg.from, msg.command, reply, msg.callback); + } +} +exports.DeviceManagement = DeviceManagement; +class DeviceLoadContextImpl { + constructor(msg, adapter) { + this.adapter = adapter; + this.minBatchSize = 8; + this.devices = []; + this.sendNext = []; + this.completed = false; + this.respondTo = msg; + this.id = msg._id; + } + addDevice(device) { + this.devices.push(device); + this.sendNext.push(Object.assign(Object.assign({}, device), { actions: convertActions(device.actions), controls: convertControls(device.controls) })); + this.flush(); + } + setTotalDevices(count) { + this.totalDevices = count; + this.flush(); + } + complete() { + this.completed = true; + return this.flush(); + } + handleProgress(message) { + this.respondTo = message; + return this.flush(); + } + flush() { + if (this.sendNext.length <= this.minBatchSize && !this.completed) { + return false; + } + if (!this.respondTo) { + return false; + } + const reply = { + add: this.sendNext, + total: this.totalDevices, + next: this.completed ? undefined : { origin: this.id }, + }; + this.sendNext = []; + const msg = this.respondTo; + this.respondTo = undefined; + this.adapter.sendTo(msg.from, msg.command, reply, msg.callback); + return this.completed; + } +} +class MessageContext { + constructor(msg, adapter) { + this.adapter = adapter; + this.hasOpenProgressDialog = false; + this.lastMessage = msg; + } + showMessage(text) { + this.checkPreconditions(); + const promise = new Promise(resolve => { + this.progressHandler = () => resolve(); + }); + this.send({ type: 'message', message: text }); + return promise; + } + showConfirmation(text) { + this.checkPreconditions(); + const promise = new Promise(resolve => { + this.progressHandler = msg => resolve(!!msg.confirm); + }); + this.send({ type: 'confirm', confirm: text }); + return promise; + } + showForm(schema, options) { + this.checkPreconditions(); + const promise = new Promise(resolve => { + this.progressHandler = msg => resolve(msg.data); + }); + this.send({ + type: 'form', + form: Object.assign({ schema }, options), + }); + return promise; + } + openProgress(title, options) { + this.checkPreconditions(); + this.hasOpenProgressDialog = true; + const dialog = { + update: (update) => { + const promise = new Promise(resolve => { + this.progressHandler = () => resolve(); + }); + this.send({ type: 'progress', progress: update }, true); + return promise; + }, + close: () => { + const promise = new Promise(resolve => { + this.progressHandler = () => { + this.hasOpenProgressDialog = false; + resolve(); + }; + }); + this.send({ type: 'progress', progress: { open: false } }); + return promise; + }, + }; + const promise = new Promise(resolve => { + this.progressHandler = () => resolve(dialog); + }); + this.send({ type: 'progress', progress: Object.assign(Object.assign({ title }, options), { open: true }) }, true); + return promise; + } + sendFinalResult(result) { + this.send({ type: 'result', result }); + } + sendControlResult(deviceId, controlId, result) { + if (typeof result === 'object' && 'error' in result) { + this.send({ + type: 'result', + result: { + error: result.error, + deviceId, + controlId, + }, + }); + } + else { + this.send({ + type: 'result', + result: { + state: result, + deviceId, + controlId, + }, + }); + } + } + handleProgress(message) { + const currentHandler = this.progressHandler; + if (currentHandler && typeof message.message !== 'string') { + this.lastMessage = message; + this.progressHandler = undefined; + currentHandler(message.message); + } + } + checkPreconditions() { + if (this.hasOpenProgressDialog) { + throw new Error("Can't show another dialog while a progress dialog is open. Please call 'close()' on the dialog before opening another dialog."); + } + } + send(message, doNotClose) { + if (!this.lastMessage) { + throw new Error("No outstanding message, can't send a new one"); + } + this.adapter.sendTo(this.lastMessage.from, this.lastMessage.command, Object.assign(Object.assign({}, message), { origin: this.lastMessage.message.origin || this.lastMessage._id }), this.lastMessage.callback); + if (!doNotClose) { + // "progress" is exception. It will be closed with "close" flag + this.lastMessage = undefined; + } + } +} +exports.MessageContext = MessageContext; +function convertActions(actions) { + if (!actions) { + return undefined; + } + // detect duplicate IDs + const ids = new Set(); + actions.forEach(a => { + if (ids.has(a.id)) { + throw new Error(`Action ID ${a.id} is used twice, this would lead to unexpected behavior`); + } + ids.add(a.id); + }); + // remove handler function to send it as JSON + return actions.map((a) => (Object.assign(Object.assign({}, a), { handler: undefined, disabled: !a.handler && !a.url }))); +} +function convertControls(controls) { + if (!controls) { + return undefined; + } + // detect duplicate IDs + const ids = new Set(); + controls.forEach(a => { + if (ids.has(a.id)) { + throw new Error(`Control ID ${a.id} is used twice, this would lead to unexpected behavior`); + } + ids.add(a.id); + }); + // remove handler function to send it as JSON + return controls.map((a) => (Object.assign(Object.assign({}, a), { handler: undefined, getStateHandler: undefined }))); +} diff --git a/build/ProgressDialog.d.ts b/build/ProgressDialog.d.ts new file mode 100644 index 0000000..b0d7e06 --- /dev/null +++ b/build/ProgressDialog.d.ts @@ -0,0 +1,5 @@ +import type { ProgressUpdate } from './types/common'; +export interface ProgressDialog { + update(update: ProgressUpdate): Promise; + close(): Promise; +} diff --git a/build/ProgressDialog.js b/build/ProgressDialog.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/build/ProgressDialog.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/build/index.d.ts b/build/index.d.ts new file mode 100644 index 0000000..9cde45e --- /dev/null +++ b/build/index.d.ts @@ -0,0 +1,3 @@ +export type * from './ActionContext'; +export * from './DeviceManagement'; +export * from './types'; diff --git a/build/index.js b/build/index.js new file mode 100644 index 0000000..12e28ac --- /dev/null +++ b/build/index.js @@ -0,0 +1,19 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./DeviceManagement"), exports); +// don't export * from "./MessageContext" as it is private +__exportStar(require("./types"), exports); diff --git a/build/types/adapter.d.ts b/build/types/adapter.d.ts new file mode 100644 index 0000000..97325f0 --- /dev/null +++ b/build/types/adapter.d.ts @@ -0,0 +1,8 @@ +import type * as base from './base'; +import type { DeviceId } from './common'; +export type ActionBase = base.ActionBase<'adapter'>; +export type InstanceAction = base.InstanceAction<'adapter'>; +export type DeviceAction = base.DeviceAction<'adapter', TId>; +export type InstanceDetails = base.InstanceDetails<'adapter'>; +export type DeviceInfo = base.DeviceInfo<'adapter', TId>; +export type DeviceControl = base.DeviceControl<'adapter', TId>; diff --git a/build/types/adapter.js b/build/types/adapter.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/build/types/adapter.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/build/types/api.d.ts b/build/types/api.d.ts new file mode 100644 index 0000000..9c87ed1 --- /dev/null +++ b/build/types/api.d.ts @@ -0,0 +1,65 @@ +import type * as base from './base'; +import type { ActionButton, DeviceId, ErrorResponse, JsonFormSchema, ProgressUpdate } from './common'; +export type ActionBase = base.ActionBase<'api'>; +export type InstanceAction = base.InstanceAction<'api'>; +export type DeviceAction = base.DeviceAction<'api'>; +export type InstanceDetails = base.InstanceDetails<'api'>; +export type DeviceInfo = base.DeviceInfo<'api'>; +export type DeviceControl = base.DeviceControl<'api'>; +export type DeviceRefreshResponse = base.DeviceRefreshResponse<'api'>; +export type InstanceRefreshResponse = base.InstanceRefreshResponse; +export type DeviceLoadIncrement = { + add: DeviceInfo[]; + total?: number; + next?: { + origin: number; + }; +}; +export type DmResponseBase = { + origin: number; +}; +export type DmControlResponse = DmResponseBase & { + type: 'result'; + result: { + deviceId: DeviceId; + controlId: string; + } & (ErrorResponse | { + state: ioBroker.State; + }); +}; +export type DmActionResultResponse = DmResponseBase & { + type: 'result'; + result: ErrorResponse | DeviceRefreshResponse | InstanceRefreshResponse; +}; +export type DmActionMessageResponse = DmResponseBase & { + type: 'message'; + message: ioBroker.StringOrTranslated; +}; +export type DmActionConfirmResponse = DmResponseBase & { + type: 'confirm'; + confirm: ioBroker.StringOrTranslated; +}; +export interface CommunicationForm { + title?: ioBroker.StringOrTranslated | null | undefined; + label?: ioBroker.StringOrTranslated | null | undefined; + noTranslation?: boolean; + schema: JsonFormSchema; + data?: Record; + buttons?: (ActionButton | 'apply' | 'cancel' | 'close')[]; + maxWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + /** Minimal width of the dialog */ + minWidth?: number; + /** Always allow the apply button. Even when nothing was changed */ + ignoreApplyDisabled?: boolean; +} +export type DmActionFormResponse = DmResponseBase & { + type: 'form'; + form: CommunicationForm; +}; +export type DmActionProgressResponse = DmResponseBase & { + type: 'progress'; + progress: ProgressUpdate & { + open?: boolean; + }; +}; +export type DmActionResponse = DmActionResultResponse | DmActionMessageResponse | DmActionConfirmResponse | DmActionFormResponse | DmActionProgressResponse; diff --git a/build/types/api.js b/build/types/api.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/build/types/api.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/build/types/base.d.ts b/build/types/base.d.ts new file mode 100644 index 0000000..754d6ed --- /dev/null +++ b/build/types/base.d.ts @@ -0,0 +1,178 @@ +import type { ActionContext, ConfigConnectionType, ErrorResponse, MessageContext, ValueOrObject, ValueOrState, ValueOrStateOrObject } from '..'; +import type { ApiVersion, DeviceId, DeviceStatus, RetVal } from './common'; +type ActionType = 'api' | 'adapter'; +export type Color = 'primary' | 'secondary' | (string & {}); +export type ControlState = string | number | boolean | null; +/** Reserved action names */ +export declare const ACTIONS: { + /** This action will be called when user clicks on connection icon */ + STATUS: string; + /** This action will be called when the user clicks on enabled/disabled icon. The enabled/disabled icon will be shown only if the node status has "enabled" flag set to false or true */ + ENABLE_DISABLE: string; +}; +export interface ActionBase { + /** Unique (for this adapter) action ID. It could be the name from ACTIONS too, but in this case some predefined appearance will be applied */ + id: string; + /** + * This can either be base64 or the URL to an icon. + */ + icon?: 'edit' | 'rename' | 'delete' | 'refresh' | 'newDevice' | 'new' | 'add' | 'discover' | 'search' | 'unpairDevice' | 'pairDevice' | 'identify' | 'play' | 'stop' | 'pause' | 'forward' | 'next' | 'rewind' | 'previous' | 'lamp' | 'light' | 'backlight' | 'dimmer' | 'socket' | 'settings' | 'users' | 'group' | 'user' | 'info' | (string & {}); + description?: ioBroker.StringOrTranslated; + disabled?: T extends 'api' ? boolean : never; + color?: Color; + backgroundColor?: Color; + /** If true, the user will be asked for confirmation before executing the action */ + confirmation?: boolean | ioBroker.StringOrTranslated; + /** If defined, before the action is triggered, the non-empty text or number or checkbox will be asked */ + inputBefore?: { + /** This label will be shown for the text input */ + label: ioBroker.StringOrTranslated; + /** This type of input will be shown. Default is type */ + type?: 'text' | 'number' | 'checkbox' | 'select' | 'slider' | 'color'; + /** If a type is "select", the options must be defined */ + options?: { + label: ioBroker.StringOrTranslated; + value: string; + }[]; + /** Default value for the input */ + defaultValue?: string | number | boolean; + /** If true, the input could be empty */ + allowEmptyValue?: boolean; + /** Minimum value for the input (number or slider) */ + min?: number; + /** Maximum value for the input (number or slider) */ + max?: number; + /** Step value for the input (number or slider) */ + step?: number; + }; + /** Timeout in ms for waiting an answer from backend */ + timeout?: number; +} +export interface ChannelInfo { + name: ioBroker.StringOrTranslated; + description?: ioBroker.StringOrTranslated; + icon?: string; + color?: Color; + backgroundColor?: Color; + order?: number; +} +export interface ControlBase { + id: string; + type: 'button' | 'switch' | 'slider' | 'select' | 'icon' | 'color' | 'text' | 'number' | 'info'; + state?: ioBroker.State; + stateId?: string; + icon?: string; + iconOn?: string; + min?: number; + max?: number; + step?: number; + unit?: string; + label?: ioBroker.StringOrTranslated; + labelOn?: ioBroker.StringOrTranslated; + description?: ioBroker.StringOrTranslated; + color?: Color; + colorOn?: Color; + controlDelay?: number; + options?: { + label: ioBroker.StringOrTranslated; + value: ControlState; + icon?: string; + color?: Color; + }[]; + channel?: ChannelInfo; +} +export interface DeviceControl extends ControlBase { + handler?: TType extends 'api' ? never : (deviceId: TId, actionId: string, state: ControlState, context: MessageContext) => RetVal; + getStateHandler?: TType extends 'api' ? never : (deviceId: TId, actionId: string, context: MessageContext) => RetVal; +} +export type InstanceRefreshResponse = { + refresh: boolean; +}; +export type WithHandlerOrUrl = { + handler?: TType extends 'api' ? never : THandler; +} | { + url: ioBroker.StringOrTranslated; +}; +export type InstanceAction = ActionBase & WithHandlerOrUrl) => RetVal> & { + title: ioBroker.StringOrTranslated; +}; +export type DeviceUpdate = { + update: DeviceInfo; +}; +export type DeviceDelete = { + delete: TId; +}; +export type DeviceRefresh = 'all' | 'devices' | 'instance' | 'none'; +export type DeviceRefreshResponse = { + refresh: DeviceRefresh; +} | DeviceUpdate | DeviceDelete; +export type DeviceAction = ActionBase & WithHandlerOrUrl) => RetVal>>; +export interface InstanceDetails { + /** API Version: 1 - till 2025 (including), 2 - from 2026 */ + apiVersion: ApiVersion; + actions?: InstanceAction[]; + /** ID of state used for communication with GUI */ + communicationStateId?: string; + /** Human-readable label next to the identifier */ + identifierLabel?: ioBroker.StringOrTranslated; +} +export interface DeviceInfo { + /** ID of the device. Must be unique only in one adapter. Other adapters could have same IDs */ + id: TId; + /** Human-readable identifier of the device */ + identifier?: ValueOrObject; + /** Name of the device. It will be shown in the card header */ + name: ValueOrObject; + /** base64 or url icon for device card */ + icon?: ValueOrState; + manufacturer?: ValueOrStateOrObject; + model?: ValueOrStateOrObject; + /** Color or 'primary', 'secondary' for the text in the card header */ + color?: ValueOrState; + /** Background color of card header (you can use primary, secondary or color rgb value or hex) */ + backgroundColor?: ValueOrState; + status?: DeviceStatus | DeviceStatus[]; + /** Connection type, how the device is connected */ + connectionType?: ValueOrStateOrObject; + /** If this flag is true or false, the according indication will be shown. Additionally, if ACTIONS.ENABLE_DISABLE is implemented, this action will be sent to backend by clicking on this indication */ + enabled?: ValueOrState; + /** List of actions on the card */ + actions?: DeviceAction[]; + /** List of controls on the card. The difference of controls and actions is that the controls can show status (e.g. on/off) and can work directly with states */ + controls?: DeviceControl[]; + /** If true, the button `more` will be shown on the card and called `dm:deviceDetails` action to get the details */ + hasDetails?: ValueOrStateOrObject; + /** Device type for grouping */ + group?: { + key: string; + name?: ioBroker.StringOrTranslated; + icon?: string; + }; +} +export interface BackendToGuiCommandDeviceInfoUpdate { + /** Used for updating and for adding new device */ + command: 'infoUpdate'; + /** Device ID */ + deviceId: TId; + /** Backend can send directly new information about device to avoid extra request from GUI */ + info?: DeviceInfo; +} +export interface BackendToGuiCommandDeviceStatusUpdate { + /** Status of device was updated */ + command: 'statusUpdate'; + /** Device ID */ + deviceId: TId; + /** Backend can send directly new status to avoid extra request from GUI */ + status?: DeviceStatus; +} +export interface BackendToGuiCommandDeviceDelete { + /** Device was deleted */ + command: 'delete'; + deviceId: TId; +} +export interface BackendToGuiCommandAllUpdate { + /** Read ALL information about all devices anew */ + command: 'all'; +} +export type BackendToGuiCommand = BackendToGuiCommandDeviceInfoUpdate | BackendToGuiCommandDeviceStatusUpdate | BackendToGuiCommandDeviceDelete | BackendToGuiCommandAllUpdate; +export {}; diff --git a/build/types/base.js b/build/types/base.js new file mode 100644 index 0000000..113503a --- /dev/null +++ b/build/types/base.js @@ -0,0 +1,10 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ACTIONS = void 0; +/** Reserved action names */ +exports.ACTIONS = { + /** This action will be called when user clicks on connection icon */ + STATUS: 'status', + /** This action will be called when the user clicks on enabled/disabled icon. The enabled/disabled icon will be shown only if the node status has "enabled" flag set to false or true */ + ENABLE_DISABLE: 'enable/disable', +}; diff --git a/build/types/common.d.ts b/build/types/common.d.ts new file mode 100644 index 0000000..63114b4 --- /dev/null +++ b/build/types/common.d.ts @@ -0,0 +1,951 @@ +export type ApiVersion = 'v3'; +export type ConfigConnectionType = 'lan' | 'wifi' | 'bluetooth' | 'thread' | 'z-wave' | 'zigbee' | 'other'; +export interface ComplexDeviceId { + [key: string | number]: string | number | ComplexDeviceId; +} +export type DeviceId = string | number | ComplexDeviceId; +export type ValueOrObject = T | { + objectId: string; + property: string; +}; +export type ValueOrState = T | { + stateId: string; + mapping?: Record; +}; +export type ValueOrStateOrObject = T | ValueOrObject | ValueOrState; +export type DeviceStatus = 'connected' | 'disconnected' | { + battery?: ValueOrState; + connection?: ValueOrState<'connected' | 'disconnected'>; + rssi?: ValueOrState; + warning?: ValueOrState; +}; +export type ErrorResponse = { + error: { + code: number; + message: string; + }; +}; +export type RetVal = T | Promise; +export interface ProgressOptions { + indeterminate?: boolean; + value?: number; + label?: ioBroker.StringOrTranslated; +} +export interface ProgressUpdate extends ProgressOptions { + title?: ioBroker.StringOrTranslated; +} +type CustomCSSProperties = Record; +interface ObjectBrowserCustomFilter { + type?: ioBroker.ObjectType | ioBroker.ObjectType[]; + common?: { + type?: ioBroker.CommonType | ioBroker.CommonType[]; + role?: string | string[]; + custom?: '_' | '_dataSources' | true | (string & {}) | string[]; + }; +} +export type ObjectBrowserType = 'state' | 'instance' | 'channel' | 'device' | 'chart'; +export type ConfigItemType = 'accordion' | 'alive' | 'autocomplete' | 'autocompleteSendTo' | 'certCollection' | 'certificate' | 'certificates' | 'checkDocker' | 'checkLicense' | 'checkbox' | 'chips' | 'color' | 'coordinates' | 'cron' | 'custom' | 'datePicker' | 'deviceManager' | 'divider' | 'file' | 'fileSelector' | 'func' | 'header' | 'iframe' | 'iframeSendTo' | 'image' | 'imageSendTo' | 'infoBox' | 'instance' | 'interface' | 'ip' | 'jsonEditor' | 'language' | 'license' | 'number' | 'oauth2' | 'objectId' | 'panel' | 'password' | 'pattern' | 'port' | 'qrCode' | 'room' | 'select' | 'selectSendTo' | 'sendto' | 'setState' | 'slider' | 'state' | 'staticImage' | 'staticInfo' | 'staticLink' | 'staticText' | 'table' | 'tabs' | 'text' | 'textSendTo' | 'timePicker' | 'topic' | 'user' | 'uuid' | 'yamlEditor'; +export type ConfigIconType = 'add' | 'backlight' | 'book' | 'delete' | 'dimmer' | 'edit' | 'error' | 'group' | 'help' | 'identify' | 'info' | 'light' | 'lines' | 'next' | 'pair' | 'pause' | 'play' | 'previous' | 'qrcode' | 'refresh' | 'search' | 'send' | 'settings' | 'socket' | 'stop' | 'unpair' | 'upload' | 'user' | 'warning' | 'web' | string; +export interface ConfigItemConfirmData { + condition: string; + text?: ioBroker.StringOrTranslated; + title?: ioBroker.StringOrTranslated; + ok?: ioBroker.StringOrTranslated; + cancel?: ioBroker.StringOrTranslated; + type?: 'info' | 'warning' | 'error' | 'none'; + alsoDependsOn?: string[]; +} +export interface ConfigItem { + /** Type of the JSON config item */ + type: ConfigItemType; + /** Width of the control on "extra small" displays */ + xs?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** Width of the control on "small" displays */ + sm?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** Width of the control on "medium" displays */ + md?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** Width of the control on "large" displays */ + lg?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** Width of the control on "extra large" displays */ + xl?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + /** If the control should be shown in a new line */ + newLine?: boolean; + /** Label of the control */ + label?: ioBroker.StringOrTranslated; + /** @deprecated use label */ + text?: ioBroker.StringOrTranslated; + /** Formula or false to hide the control: "data.attr === 5" */ + hidden?: string | boolean; + /** If true and the control is hidden, the place of the control will be still reserved for it */ + hideOnlyControl?: boolean; + /** JS function to calculate if the control is disabled. You can write "true" too */ + disabled?: string | boolean; + /** Help text of the control */ + help?: ioBroker.StringOrTranslated; + /** Link that will be opened by clicking on the help text */ + helpLink?: string; + style?: CustomCSSProperties; + darkStyle?: CustomCSSProperties; + validator?: string; + validatorErrorText?: string; + validatorNoSaveOnError?: boolean; + tooltip?: ioBroker.StringOrTranslated; + default?: boolean | number | string; + defaultFunc?: string; + defaultSendTo?: string; + /** Allow saving of configuration even with an error */ + allowSaveWithError?: boolean; + data?: string | number | boolean; + jsonData?: string; + button?: ioBroker.StringOrTranslated; + buttonTooltip?: ioBroker.StringOrTranslated; + buttonTooltipNoTranslation?: boolean; + placeholder?: ioBroker.StringOrTranslated; + noTranslation?: boolean; + onChange?: { + alsoDependsOn: string[]; + calculateFunc: string; + ignoreOwnChanges?: boolean; + }; + doNotSave?: boolean; + /** If the control should be shown ONLY in the expert mode */ + expertMode?: boolean; + noMultiEdit?: boolean; + confirm?: ConfigItemConfirmData; + icon?: ConfigIconType; + width?: string | number; + confirmDependsOn?: ConfigItemIndexed[]; + onChangeDependsOn?: ConfigItemIndexed[]; + hiddenDependsOn?: ConfigItemIndexed[]; + labelDependsOn?: ConfigItemIndexed[]; + helpDependsOn?: ConfigItemIndexed[]; +} +interface ConfigItemIndexed extends ConfigItem { + attr?: string; +} +interface ConfigItemTableIndexed extends ConfigItem { + attr?: string; + /** show filter options in the header of the table */ + filter?: boolean; + /** show sorting options in the header of the table */ + sort?: boolean; + /** tooltip in the header of the table */ + title?: string; +} +export interface ConfigItemAlive extends ConfigItem { + type: 'alive'; + /** check if the instance is alive. If not defined, it will be used current instance. You can use the ` $ {data.number} ` pattern in the text. */ + instance?: string; + /** the default text is `Instance %s is alive`, where %s will be replaced by `ADAPTER.0`. The translation must exist in i18n files. */ + textAlive?: string; + /** the default text is `Instance %s is not alive`, where %s will be replaced by `ADAPTER.0`. The translation must exist in i18n files. */ + textNotAlive?: string; +} +export interface ConfigItemSelectOption { + /** Label of option */ + label: ioBroker.StringOrTranslated; + /** Value of option */ + value: number | string; + /** Color of value */ + color?: string; + /** Formula or boolean value to show or hide the option */ + hidden?: string | boolean; +} +export interface ConfigItemPanel extends ConfigItem { + type: 'panel' | never; + /** Label of tab */ + label?: ioBroker.StringOrTranslated; + items: Record; + /** only possible as not part of tabs */ + collapsable?: boolean; + /** color of collapsable header `primary` or `secondary` or nothing */ + color?: 'primary' | 'secondary'; + /** CSS Styles in React format (`marginLeft` and not `margin-left`) for the Panel component */ + innerStyle?: CustomCSSProperties; + /** i18n definitions: true - load from a file, string - name of subdirectory, object - translations */ + i18n?: boolean | string | Record>; + command?: string; +} +export interface ConfigItemPattern extends ConfigItem { + type: 'pattern'; + /** if true - show copy button */ + copyToClipboard?: boolean; + /** pattern like 'https://${data.ip}:${data.port}' */ + pattern: string; +} +export interface ConfigItemChip extends ConfigItem { + type: 'chips'; + /** if it is defined, so the option will be stored as string with delimiter instead of an array. E.g., by `delimiter=;` you will get `a;b;c` instead of `['a', 'b', 'c']` */ + delimiter?: string; +} +export interface ConfigItemTabs extends ConfigItem { + type: 'tabs'; + /** Object with panels `{"tab1": {}, "tab2": {}...}` */ + items: Record; + /** `bottom`, `end`, `start` or `top`. Only for panels that has `icon` attribute. Default: `start` */ + iconPosition?: 'bottom' | 'end' | 'start' | 'top'; + /** CSS Styles in React format (`marginLeft` and not `margin-left`) for the Mui-Tabs component */ + tabsStyle?: CustomCSSProperties; + /** i18n definitions: true - load from a file, string - name of subdirectory, object - translations */ + i18n?: boolean | string | Record>; + command?: string; +} +export interface ConfigItemText extends ConfigItem { + type: 'text'; + /** max length of the text in the field */ + maxLength?: number; + /** @deprecated use maxLength */ + max?: number; + /** read-only field */ + readOnly?: boolean; + /** show copy-to-clipboard button, but only if disabled or read-only */ + copyToClipboard?: boolean; + /** default is true. Set this attribute to `false` if trim is not desired. */ + trim?: boolean; + /** the default is 1. Set this attribute to `2` or more if you want to have a textarea with more than one row. */ + minRows?: number; + /** max rows of textarea. Used only if `minRows` > 1. */ + maxRows?: number; + /** if true, the clear button will not be shown */ + noClearButton?: boolean; + /** if true, the text will be validated as JSON */ + validateJson?: boolean; + /** if true, the JSON will be validated only if the value is not empty */ + allowEmpty?: boolean; + /** the value is time in ms or a string. Used only with readOnly flag */ + time?: boolean; +} +export interface ConfigItemColor extends ConfigItem { + type: 'color'; + /** if true, the clear button will not be shown */ + noClearButton?: boolean; +} +export interface ConfigItemCheckbox extends ConfigItem { + type: 'checkbox'; + /** Same as disabled */ + readOnly?: boolean; +} +export interface ConfigItemNumber extends ConfigItem { + type: 'number'; + min?: number; + max?: number; + step?: number; + readOnly?: boolean; + /** Unit */ + unit?: string; +} +export interface ConfigItemOAuth2 extends ConfigItem { + type: 'oauth2'; + saveTokenIn?: string; + identifier: 'spotify' | 'google' | 'dropbox' | 'microsoft' | string; + scope?: string; + refreshLabel?: ioBroker.StringOrTranslated; +} +export interface ConfigItemQrCode extends ConfigItem { + type: 'qrCode'; + /** Data to show in the QR code */ + data: string; + /** Size of the QR code */ + size?: number; + /** Foreground color */ + fgColor?: string; + /** Background color */ + bgColor?: string; + /** QR code level */ + level?: 'L' | 'M' | 'Q' | 'H'; +} +export interface ConfigItemPassword extends ConfigItem { + type: 'password'; + /** repeat password must be compared with password */ + repeat?: boolean; + /** true if allow viewing the password by toggling the view button (only for a new password while entering) */ + visible?: boolean; + /** The read-only flag. Visible is automatically true if readOnly is true */ + readOnly?: boolean; + /** max length of the text in the field */ + maxLength?: number; + /** @deprecated use maxLength */ + max?: number; +} +export interface ConfigItemObjectId extends ConfigItem { + type: 'objectId'; + /** Desired type: `channel`, `device`, ... (has only `state` by default). It is plural, because `type` is already occupied. */ + types?: ObjectBrowserType | ObjectBrowserType[]; + /** Show only this root object and its children */ + root?: string; + /** + * Cannot be used together with `type` settings. It is an object and not a JSON string. Examples + * - `{common: {custom: true}}` - show only objects with some custom settings + * - `{common: {custom: 'sql.0'}}` - show only objects with sql.0 custom settings (only of the specific instance) + * - `{common: {custom: '_dataSources'}}` - show only objects of adapters `influxdb` or `sql` or `history` + * - `{common: {custom: 'adapterName.'}}` - show only objects of custom settings of specific adapter (all instances) + * - `{type: 'channel'}` - show only channels + * - `{type: ['channel', 'device']}` - show only channels and devices + * - `{common: {type: 'number'}` - show only states of type 'number + * - `{common: {type: ['number', 'string']}` - show only states of type 'number and string + * - `{common: {role: 'switch'}` - show only states with roles starting from switch + * - `{common: {role: ['switch', 'button']}` - show only states with roles starting from `switch` and `button` + */ + customFilter?: ObjectBrowserCustomFilter; + /** some predefined search filters */ + filters?: { + id?: string; + name?: string; + room?: string[]; + func?: string[]; + role?: string[]; + type?: string[]; + custom?: string[]; + }; + /** Cannot be used together with `type` settings. It is a function that will be called for every object and must return true or false. Example: `obj.common.type === 'number'` */ + filterFunc?: (obj: ioBroker.Object) => boolean; +} +export interface ConfigItemSlider extends ConfigItem { + type: 'slider'; + min?: number; + max?: number; + step?: number; + /** Unit of slider */ + unit?: string; +} +export interface ConfigItemTopic extends ConfigItem { + type: 'topic'; + /** max length of the text in the field */ + maxLength?: number; + /** @deprecated use maxLength */ + max?: number; +} +export interface ConfigItemIP extends ConfigItem { + type: 'ip'; + listenOnAllPorts?: boolean; + onlyIp4?: boolean; + onlyIp6?: boolean; + noInternal?: boolean; +} +export interface ConfigItemUser extends ConfigItem { + type: 'user'; + /** without "system.user." */ + short?: boolean; +} +export interface ConfigItemStaticDivider extends ConfigItem { + type: 'divider'; + color?: 'primary' | 'secondary' | string; + height?: string | number; +} +export interface ConfigItemStaticHeader extends ConfigItem { + type: 'header'; + size?: 1 | 2 | 3 | 4 | 5; + text: ioBroker.StringOrTranslated; + noTranslation?: boolean; +} +export interface ConfigItemStaticImage extends ConfigItem { + type: 'staticImage'; + /** name of picture (from admin directory) */ + src: string; + /** optional HTTP link */ + href?: string; +} +export interface ConfigItemStaticText extends Omit { + type: 'staticText'; + /** multi-language text */ + text: ioBroker.StringOrTranslated; + /** @deprecated use text */ + label?: ioBroker.StringOrTranslated; + /** link. Link could be dynamic like `#tab-objects/customs/${data.parentId} */ + href?: string; + /** target of the link: _self, _blank or window name. For relative links the default is _self and for absolute - _blank */ + target?: string; + /** If the GUI should be closed after a link was opened (only if the target is equal to '_self') */ + close?: boolean; + /** show a link as a button */ + button?: boolean; + /** type of button (`outlined`, `contained`, `text`) */ + variant?: 'contained' | 'outlined' | 'text'; + /** color of button (e.g. `primary`) */ + color?: 'primary' | 'secondary' | 'grey'; + /** if icon should be shown: `auth`, `send`, `web`, `warning`, `error`, `info`, `search`, `book`, `help`, `upload`. You can use `base64` icons (it starts with `data:image/svg+xml;base64,...`) or `jpg/png` images (ends with `.png`) . (Request via issue if you need more icons) */ + icon?: ConfigIconType; + /** styles for the button */ + controlStyle?: CustomCSSProperties; +} +export interface ConfigItemStaticInfo extends Omit { + type: 'staticInfo'; + /** multi-language text or value */ + data: ioBroker.StringOrTranslated | number | boolean; + /** Base64 icon */ + labelIcon?: string; + /** Unit */ + unit?: ioBroker.StringOrTranslated; + /** Normally, the title and value are shown on the left and right of the line. With this flag, the value will appear just after the label*/ + narrow?: boolean; + /** Add to the label the colon at the end if not exist in the label */ + addColon?: boolean; + /** Value should blink when updated (true or color) */ + blinkOnUpdate?: boolean | string; + /** Value should blink continuously (true or color) */ + blink?: boolean | string; + /** Show a copy-to-clipboard button for value */ + copyToClipboard?: boolean; + /** Label style */ + styleLabel?: CustomCSSProperties; + /** Value style */ + styleValue?: CustomCSSProperties; + /** Unit style */ + styleUnit?: CustomCSSProperties; + /** Font size */ + size?: number | 'small' | 'normal' | 'large'; + /** Highlight line on mouse over */ + highlight?: boolean; + /** Show boolean values as a checkbox */ + booleanAsCheckbox?: boolean; + /** Show string values as HTML */ + html?: boolean; +} +export interface ConfigItemInfoBox extends ConfigItem { + type: 'infoBox'; + /** multi-language text */ + text: ioBroker.StringOrTranslated; + /** multi-language title */ + title?: ioBroker.StringOrTranslated; + /** The type determines the color and symbol */ + boxType?: 'warning' | 'info' | 'error' | 'ok'; + /** If the box is closeable */ + closeable?: boolean; + /** Use together with `closeable: true`. If the box is closed or not. In this case, it will be controlled from outside */ + closed?: boolean; + /** Icon position */ + iconPosition?: 'top' | 'middle'; +} +export interface ConfigItemRoom extends ConfigItem { + type: 'room'; + short?: boolean; + allowDeactivate?: boolean; +} +export interface ConfigItemFunc extends ConfigItem { + type: 'func'; + short?: boolean; + allowDeactivate?: boolean; +} +export interface ConfigItemSelect extends ConfigItem { + type: 'select'; + /** + * `[{label: {en: "option 1"}, value: 1}, ...]` or + * `[{"items": [{"label": "Val1", "value": 1}, {"label": "Val2", value: "2}], "name": "group1"}, {"items": [{"label": "Val3", "value": 3}, {"label": "Val4", value: "4}], "name": "group2"}, {"label": "Val5", "value": 5}]` + */ + options: (ConfigItemSelectOption | { + items: ConfigItemSelectOption[]; + label: ioBroker.StringOrTranslated; + value?: number | string; + color?: string; + hidden?: string | boolean; + })[]; + attr?: string; + /** If multiple selection is possible. In this case, the value will be an array */ + multiple?: boolean; + /** show an item even if no label was found for it (by multiple), default=`true` */ + showAllValues?: boolean; +} +export interface ConfigItemAutocomplete extends ConfigItem { + type: 'autocomplete'; + options: (string | ConfigItemSelectOption)[]; + freeSolo?: boolean; +} +export interface ConfigItemSetState extends ConfigItem { + type: 'setState'; + /** `system.adapter.myAdapter.%INSTANCE%.test`, you can use the placeholder `%INSTANCE%` to replace it with the current instance name */ + id: string; + /** false (default false) */ + ack?: boolean; + /** '${data.myText}_test' or number. Type will be detected automatically from the state type and converting done too */ + val: ioBroker.StateValue; + /** Alert, which will be shown by pressing the button */ + okText?: ioBroker.StringOrTranslated; + variant?: 'contained' | 'outlined'; + color?: 'primary' | 'secondary' | 'grey'; + /** Error translations */ + error?: { + [error: string]: ioBroker.StringOrTranslated; + }; +} +export interface ConfigItemAutocompleteSendTo extends Omit { + type: 'autocompleteSendTo'; + command?: string; + jsonData?: string; + options?: (string | ConfigItemSelectOption)[]; + data?: Record; + freeSolo?: boolean; + /** max length of the text in the field */ + maxLength?: number; + /** @deprecated use maxLength */ + max?: string; + alsoDependsOn?: string[]; +} +export interface ConfigItemAccordion extends ConfigItem { + type: 'accordion'; + /** Title shown on the accordion */ + titleAttr?: string; + /** If delete or add disabled, If noDelete is false, add, delete and move up/down should work */ + noDelete?: boolean; + /** If the clone button should be shown. If true, the clone button will be shown. If attribute name, this name will be unique. */ + clone?: boolean | string; + /** Items of accordion */ + items: ConfigItemIndexed[]; +} +export interface ConfigItemDivider extends ConfigItem { + type: 'divider'; + color?: 'primary' | 'secondary' | string; + height?: string | number; +} +export interface ConfigItemHeader extends ConfigItem { + type: 'header'; + text?: ioBroker.StringOrTranslated; + size?: 1 | 2 | 3 | 4 | 5 | 6; +} +export interface ConfigItemCoordinates extends ConfigItem { + type: 'coordinates'; + /** divider between latitude and longitude. Default "," (Used if longitudeName and latitudeName are not defined) */ + divider?: string; + /** init field with current coordinates if empty */ + autoInit?: boolean; + /** if defined, the longitude will be stored in this attribute, divider will be ignored */ + longitudeName?: string; + /** if defined, the latitude will be stored in this attribute, divider will be ignored */ + latitudeName?: string; + /** if defined, the checkbox with "Use system settings" will be shown and latitude, longitude will be read from system.config, a boolean will be saved to the given name */ + useSystemName?: string; + /** max length of the text in the field */ + maxLength?: number; + /** @deprecated use maxLength */ + max?: number; +} +export interface ConfigItemCustom extends ConfigItem { + type: 'custom'; + /** location of Widget, like "custom/customComponents.js" */ + url: string; + /** New format for components written in TypeScript */ + bundlerType?: 'module'; + /** Component name, like "ConfigCustomBackItUpSet/Components/AdapterExist" */ + name: string; + /** i18n */ + i18n: boolean | Record; + /** custom properties */ + custom?: { + [prop: string]: any; + }; +} +export interface ConfigItemDatePicker extends ConfigItem { + type: 'datePicker'; + /** max length of the text in the field */ + maxLength?: number; + /** @deprecated use maxLength */ + max?: number; +} +export interface ConfigItemDeviceManager extends ConfigItem { + type: 'deviceManager'; +} +export interface ConfigItemLanguage extends ConfigItem { + type: 'language'; + system?: boolean; + changeGuiLanguage?: boolean; +} +export interface ConfigItemPort extends ConfigItem { + type: 'port'; + min?: number; + max?: number; + readOnly?: boolean; +} +export interface ConfigItemIFrame extends ConfigItem { + type: 'iframe'; + url?: string; + sandbox?: string; + allowFullscreen?: boolean; + frameBorder?: number | string; + /** if true, the iframe will be loaded only when it becomes visible */ + lazyLoad?: 'lazy' | 'eager'; + /** if true, the iframe will be reloaded every time it becomes visible */ + reloadOnShow?: boolean; + /** CSS Styles in React format (`marginLeft` and not `margin-left`) for the IFrame component */ + innerStyle?: CustomCSSProperties; +} +export interface ConfigItemIFrameSendTo extends Omit { + type: 'iframeSendTo'; + command?: string; + alsoDependsOn?: string[]; + data?: Record; +} +export interface ConfigItemImageSendTo extends Omit { + type: 'imageSendTo'; + command?: string; + alsoDependsOn?: string[]; + height?: number | string; + data?: Record; +} +export interface ConfigItemSendTo extends Omit { + type: 'sendto'; + command?: string; + jsonData?: string; + data?: Record; + /** Translations for possible result codes. E.g. `{"RESULT_OK": "Operation successful"}`. The translation must exist in i18n files. */ + result?: Record; + /** Translations for possible error codes. E.g. `{"ERR_NO_OBJECT": "Object not found"}`. The translation must exist in i18n files. */ + error?: Record; + variant?: 'contained' | 'outlined'; + openUrl?: boolean; + reloadBrowser?: boolean; + window?: string; + icon?: ConfigIconType; + useNative?: boolean; + showProcess?: boolean; + timeout?: number; + onLoaded?: boolean; + color?: 'primary' | 'secondary'; + /** button tooltip */ + title?: ioBroker.StringOrTranslated; + alsoDependsOn?: string[]; + container?: 'text' | 'div' | 'html'; + copyToClipboard?: boolean; + /** Styles for the button itself */ + controlStyle?: CustomCSSProperties; +} +export interface ConfigItemState extends ConfigItem { + type: 'state'; + /** Describes which object ID should be taken for the controlling. The ID is without `ADAPTER.I.` prefix */ + oid: string; + /** The `oid` is absolute and no need to add `ADAPTER.I` or `system.adapter.ADAPTER.I.` to oid */ + foreign?: boolean; + /** If true, the state will be taken from `system.adapter.ADAPTER.I` and not from `ADAPTER.I` */ + system?: boolean; + /** How the value of the state should be shown */ + control?: 'text' | 'html' | 'input' | 'slider' | 'select' | 'button' | 'switch' | 'number'; + /** If true, the state will be shown as switch, select, button, slider or text input. Used only if no control property is defined */ + controlled?: boolean; + /** Add a unit to the value */ + unit?: string; + /** this text will be shown if the value is true */ + trueText?: string; + /** Style of the text if the value is true */ + trueTextStyle?: CustomCSSProperties; + /** this text will be shown if the value is false or if the control is a "button" */ + falseText?: string; + /** Style of the text if the value is false or if the control is a "button" */ + falseTextStyle?: CustomCSSProperties; + /** This image will be shown if the value is true */ + trueImage?: string; + /** This image will be shown if the value is false or if the control is a "button" */ + falseImage?: string; + /** Minimum value for control type slider or number */ + min?: number; + /** Maximum value for control type slider or number */ + max?: number; + /** Step value for control type slider or number */ + step?: number; + /** delay in ms for slider or number */ + controlDelay?: number; + /** Variant of button */ + variant?: 'contained' | 'outlined' | 'text'; + /** Defines if the control is read-only. Applied only to 'input', 'slider', 'select', 'button', 'switch', 'number' */ + readOnly?: boolean; + /** Base64 icon */ + labelIcon?: string; + /** Normally, the title and value are shown on the left and right of the line. With this flag, the value will appear just after the label*/ + narrow?: boolean; + /** Add to the label the colon at the end if not exist in the label */ + addColon?: boolean; + /** Value should blink when updated (true or color) */ + blinkOnUpdate?: boolean | string; + /** Font size */ + size?: number | 'small' | 'normal' | 'large'; + /** Optional value that will be sent for the button */ + buttonValue?: ioBroker.StateValue; + /** Show the SET button. The value in this case will be sent only when the button is pressed. You can define the text of the button. The default text is "Set" */ + showEnterButton?: boolean | ioBroker.StringOrTranslated; + /** The value in this case will be sent only when the "Enter" button is pressed. It can be combined with `showEnterButton` */ + setOnEnterKey?: boolean; + /** Options for `select`. If not defiled, the `common.states` in the object must exist. */ + options?: (string | ConfigItemSelectOption)[]; +} +export interface ConfigItemTextSendTo extends Omit { + type: 'textSendTo'; + container?: 'text' | 'div'; + /** if true - show copy-to-clipboard button */ + copyToClipboard?: boolean; + /** by change of which attributes, the command must be resent */ + alsoDependsOn?: string[]; + /** sendTo command */ + command?: string; + /** string - `{"subject1": "${data.subject}", "options1": {"host": "${data.host}"}}`. This data will be sent to the backend */ + jsonData?: string; + /** object - `{"subject1": 1, "data": "static"}`. You can specify jsonData or data, but not both. This data will be sent to the backend if jsonData is not defined. */ + data?: Record; +} +export interface ConfigItemSelectSendTo extends Omit { + type: 'selectSendTo'; + /** allow manual editing. Without a drop-down menu (if the instance is offline). Default `true`. */ + manual?: boolean; + /** Multiple choice select */ + multiple?: boolean; + /** show an item even if no label was found for it (by multiple), default=`true` */ + showAllValues?: boolean; + /** if true, the clear button will not be shown */ + noClearButton?: boolean; + /** sendTo command */ + command?: string; + /** string - `{"subject1": "${data.subject}", "options1": {"host": "${data.host}"}}`. This data will be sent to the backend */ + jsonData?: string; + /** object - `{"subject1": 1, "data": "static"}`. You can specify jsonData or data, but not both. This data will be sent to the backend if jsonData is not defined. */ + data?: Record; + /** by change of which attributes, the command must be resent */ + alsoDependsOn?: string[]; +} +export interface ConfigItemTable extends ConfigItem { + type: 'table'; + items?: ConfigItemTableIndexed[]; + /** Define the name of the attribute of the item which should be shown as a title of the item in cards mode. */ + titleAttribute?: string; + /** If delete or add disabled, If noDelete is false, add, delete and move up/down should work */ + noDelete?: boolean; + /** @deprecated don't use */ + objKeyName?: string; + /** @deprecated don't use */ + objValueName?: string; + /** If add allowed even if a filter is set */ + allowAddByFilter?: boolean; + /** The number of lines from which the second adding button at the bottom of the table will be shown. Default 5 */ + showSecondAddAt?: number; + /** Show the first plus button on top of the first column and not on the left. */ + showFirstAddOnTop?: boolean; + /** If the clone button should be shown. If true, the clone button will be shown. If attribute name, this name will be unique. */ + clone?: boolean | string; + /** If export button should be shown. Export as a csv file. */ + export?: boolean; + /** If import button should be shown. Import from csv file. */ + import?: boolean; + /** Show table in compact mode */ + compact?: boolean; + /** Specify the 'attr' name of columns which need to be unique */ + uniqueColumns?: string[]; + /** These items will be encrypted before saving with a simple (not SHA) encryption method */ + encryptedAttributes?: string[]; + /** Breakpoint that will be rendered as cards */ + useCardFor?: ('xs' | 'sm' | 'md' | 'lg' | 'xl')[]; +} +export interface ConfigItemTimePicker extends ConfigItem { + type: 'timePicker'; + /** format passed to the date picker defaults to `HH:mm:ss` */ + format?: string; + views?: ('hours' | 'minutes' | 'seconds')[]; + /** Represent the available time steps for each view. Defaults to `{ hours: 1, minutes: 5, seconds: 5 }` */ + timeSteps?: { + hours?: number; + minutes?: number; + seconds?: number; + }; + /** @deprecated use timeSteps */ + timesteps?: { + hours?: number; + minutes?: number; + seconds?: number; + }; + /** `fullDate` or `HH:mm:ss`. Defaults to full date for backward compatibility reasons */ + returnFormat?: string; +} +export interface ConfigItemCertCollection extends ConfigItem { + type: 'certCollection'; + leCollectionName?: string; +} +export interface ConfigItemCRON extends ConfigItem { + type: 'cron'; + /** show CRON with "minutes", "seconds" and so on */ + complex?: boolean; + /** show simple CRON settings */ + simple?: boolean; +} +export interface ConfigItemCertificateSelect extends ConfigItem { + type: 'certificate'; +} +export interface ConfigItemLicense extends ConfigItem { + type: 'license'; + /** array of paragraphs with texts, which will be shown each as a separate paragraph */ + texts?: string[]; + /** URL to the license file (e.g. https://raw.githubusercontent.com/ioBroker/ioBroker.docs/master/LICENSE) */ + licenseUrl?: string; + /** Title of the license dialog */ + title?: string; + /** Text of the agreed button */ + agreeText?: string; + /** If defined, the checkbox with the given name will be shown. If checked, the agreed button will be enabled. */ + checkBox?: string; +} +export interface ConfigItemCertificates extends ConfigItem { + type: 'certificates'; + leCollectionName?: string; + certPublicName?: string; + certPrivateName?: string; + certChainedName?: string; +} +export interface ConfigItemCheckLicense extends ConfigItem { + type: 'checkLicense'; + /** Check UUID */ + uuid?: boolean; + /** Check version */ + version?: boolean; + variant?: 'text' | 'outlined' | 'contained'; + color?: 'primary' | 'secondary'; +} +export interface ConfigItemCheckDocker extends ConfigItem { + type: 'checkDocker'; + /** Hide the version of docker */ + hideVersion?: boolean; +} +export interface ConfigItemUUID extends ConfigItem { + type: 'uuid'; +} +export interface ConfigItemJsonEditor extends ConfigItem { + type: 'jsonEditor'; + /** if false, the text will be not validated as JSON */ + validateJson?: boolean; + /** if true, the JSON will be validated only if the value is not empty */ + allowEmpty?: boolean; + /** Allow JSON5 format. Default is disabled */ + json5?: boolean; + /** Do not allow saving the value if error in JSON or JSON5 */ + doNotApplyWithError?: boolean; + /** Open the editor in read-only mode - editor can be opened but content cannot be modified */ + readOnly?: boolean; +} +export interface ConfigItemYamlEditor extends ConfigItem { + type: 'yamlEditor'; + /** if false, the text will be not validated as YAML */ + validateYaml?: boolean; + /** if true, the YAML will be validated only if the value is not empty */ + allowEmpty?: boolean; + /** Do not allow saving the value if error in YAML */ + doNotApplyWithError?: boolean; + /** Open the editor in read-only mode - editor can be opened but content cannot be modified */ + readOnly?: boolean; +} +export interface ConfigItemInterface extends ConfigItem { + type: 'interface'; + /** do not show loopback interface (127.0.0.1) */ + ignoreLoopback?: boolean; + /** do not show internal interfaces (normally it is 127.0.0.1 too) */ + ignoreInternal?: boolean; +} +export interface ConfigItemImageUpload extends ConfigItem { + type: 'image'; + /** the name of a file is the structure name. In the below example `login-bg.png` is the file name for `writeFile("myAdapter.INSTANCE", "login-bg.png")` */ + filename?: string; + /** HTML accept attribute, like `{ 'image/**': [], 'application/pdf': ['.pdf'] }`, default `{ 'image/*': [] }` */ + accept?: Record; + /** maximal size of a file to upload */ + maxSize?: number; + /** if true, the image will be saved as data-url in attribute, elsewise as binary in file storage */ + base64?: boolean; + /** if true, allow the user to crop the image */ + crop?: boolean; +} +export interface ConfigItemInstanceSelect extends ConfigItem { + type: 'instance'; + /** name of adapter. With the special name `_dataSources` you can get all adapters with the flag `common.getHistory`. */ + adapter?: string; + /** optional list of adapters that should be shown. If not defined, all adapters will be shown. Only active if the ` adapter ` attribute is not defined. */ + adapters?: string[]; + /** if true. The additional option "deactivate" is shown */ + allowDeactivate?: boolean; + /** if true. Only enabled instances will be shown */ + onlyEnabled?: boolean; + /** value will look like `system.adapter.ADAPTER.0` and not `ADAPTER.0` */ + long?: boolean; + /** value will look like `0` and not `ADAPTER.0` */ + short?: boolean; + /** Add to the options "all" option with value `*` */ + all?: boolean; +} +export interface ConfigItemFile extends ConfigItem { + type: 'file'; + /** if a user can manually enter the file name and not only through a select dialog */ + disableEdit?: boolean; + /** limit selection to one specific object of type `meta` and the following path (not mandatory) */ + limitPath?: string; + /** like `['png', 'svg', 'bmp', 'jpg', 'jpeg', 'gif']` */ + filterFiles?: string[]; + /** allowed upload of files */ + allowUpload?: boolean; + /** allowed download of files (default true) */ + allowDownload?: boolean; + /** allowed creation of folders */ + allowCreateFolder?: boolean; + /** allowed tile view (default true) */ + allowView?: boolean; + /** show toolbar (default true) */ + showToolbar?: boolean; + /** user can select only folders (e.g., for the uploading path) */ + selectOnlyFolders?: boolean; + /** trim the filename */ + trim?: boolean; + /** max length of the file name */ + maxLength?: number; + /** @deprecated use maxLength */ + max?: number; +} +export interface ConfigItemFileSelector extends ConfigItem { + type: 'fileSelector'; + /** File extension pattern. Allowed `**\/*.ext` to show all files from subfolders too, `*.ext` to show from the root folder or `folderName\/*.ext` to show all files in subfolder `folderName`. Default `**\/*.*`. */ + pattern: string; + /** type of files: `audio`, `image`, `text` */ + fileTypes?: 'audio' | 'image' | 'text'; + /** Object ID of the type `meta`. You can use special placeholder `%INSTANCE%`: like `myAdapter.%INSTANCE%.files` */ + objectID?: string; + /** path, where the uploaded files will be stored. Like `folderName`. If not defined, no upload field will be shown. To upload in the root, set this field to `/`. */ + upload?: string; + /** Show the refresh button near the select. */ + refresh?: boolean; + /** max file size (default 2MB) */ + maxSize?: number; + /** show folder name even if all files in the same folder */ + withFolder?: boolean; + /** Allow deletion of files */ + delete?: boolean; + /** Do not show `none` option */ + noNone?: boolean; + /** Do not show the size of files */ + noSize?: boolean; +} +export type ConfigItemAny = ConfigItemAlive | ConfigItemAutocomplete | ConfigItemAutocompleteSendTo | ConfigItemPanel | ConfigItemTabs | ConfigItemText | ConfigItemNumber | ConfigItemOAuth2 | ConfigItemColor | ConfigItemCheckbox | ConfigItemSlider | ConfigItemIP | ConfigItemInfoBox | ConfigItemUser | ConfigItemRoom | ConfigItemFunc | ConfigItemSelect | ConfigItemAccordion | ConfigItemCoordinates | ConfigItemDivider | ConfigItemHeader | ConfigItemCustom | ConfigItemDatePicker | ConfigItemDeviceManager | ConfigItemLanguage | ConfigItemPort | ConfigItemSendTo | ConfigItemState | ConfigItemTable | ConfigItemTimePicker | ConfigItemTextSendTo | ConfigItemSelectSendTo | ConfigItemCertCollection | ConfigItemCertificateSelect | ConfigItemCertificates | ConfigItemUUID | ConfigItemCheckDocker | ConfigItemCheckLicense | ConfigItemPattern | ConfigItemChip | ConfigItemCRON | ConfigItemFile | ConfigItemFileSelector | ConfigItemIFrame | ConfigItemIFrameSendTo | ConfigItemImageSendTo | ConfigItemInstanceSelect | ConfigItemImageUpload | ConfigItemInterface | ConfigItemJsonEditor | ConfigItemYamlEditor | ConfigItemLicense | ConfigItemPassword | ConfigItemSetState | ConfigItemStaticDivider | ConfigItemStaticHeader | ConfigItemStaticInfo | ConfigItemStaticImage | ConfigItemStaticText | ConfigItemTopic | ConfigItemObjectId | ConfigItemQrCode; +export type ActionButton = { + label: ioBroker.StringOrTranslated; + type: 'apply' | 'cancel' | 'copyToClipboard'; + noTranslation?: boolean; + /** base64 or icon name */ + icon?: ConfigIconType; + variant?: 'contained' | 'outlined' | 'text'; + style?: Record; + /** Name of the attribute in data that should be copied to clipboard */ + color?: 'primary' | 'secondary'; + /** Name of the attribute in data that should be copied to clipboard */ + copyToClipboardAttr?: string; +}; +export type BackEndCommandJsonFormOptions = { + /** Data for the form */ + data?: JsonFormData; + /** Form title */ + title?: ioBroker.StringOrTranslated; + /** Buttons that will be shown on the bottom of the form */ + buttons?: (ActionButton | 'apply' | 'cancel' | 'close')[]; + /** Maximal form width */ + maxWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + /** Minimal width of the dialog */ + minWidth?: number; + /** Do not translate title */ + noTranslation?: boolean; + /** Always allow the apply button. Even when nothing was changed */ + ignoreApplyDisabled?: boolean; +}; +export type JsonFormSchema = ConfigItemPanel | ConfigItemTabs; +export type JsonFormData = Record; +export interface DeviceDetails { + id: TId; + schema: JsonFormSchema; + data?: JsonFormData; +} +export {}; diff --git a/build/types/common.js b/build/types/common.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/build/types/common.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/build/types/errorCodes.d.ts b/build/types/errorCodes.d.ts new file mode 100644 index 0000000..2abdae6 --- /dev/null +++ b/build/types/errorCodes.d.ts @@ -0,0 +1,17 @@ +export declare enum ErrorCodes { + E_INSTANCE_ACTION_NOT_INITIALIZED = 101, + E_INSTANCE_ACTION_UNKNOWN = 102, + E_INSTANCE_ACTION_NO_HANDLER = 103, + E_DEVICE_ACTION_NOT_INITIALIZED = 201, + E_DEVICE_ACTION_DEVICE_UNKNOWN = 202, + E_DEVICE_ACTION_UNKNOWN = 203, + E_DEVICE_ACTION_NO_HANDLER = 204, + E_DEVICE_CONTROL_NOT_INITIALIZED = 301, + E_DEVICE_CONTROL_DEVICE_UNKNOWN = 302, + E_DEVICE_CONTROL_UNKNOWN = 303, + E_DEVICE_CONTROL_NO_HANDLER = 304, + E_DEVICE_GET_STATE_NOT_INITIALIZED = 401, + E_DEVICE_GET_STATE_DEVICE_UNKNOWN = 402, + E_DEVICE_GET_STATE_UNKNOWN = 403, + E_DEVICE_GET_STATE_NO_HANDLER = 404 +} diff --git a/build/types/errorCodes.js b/build/types/errorCodes.js new file mode 100644 index 0000000..c53793a --- /dev/null +++ b/build/types/errorCodes.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ErrorCodes = void 0; +var ErrorCodes; +(function (ErrorCodes) { + ErrorCodes[ErrorCodes["E_INSTANCE_ACTION_NOT_INITIALIZED"] = 101] = "E_INSTANCE_ACTION_NOT_INITIALIZED"; + ErrorCodes[ErrorCodes["E_INSTANCE_ACTION_UNKNOWN"] = 102] = "E_INSTANCE_ACTION_UNKNOWN"; + ErrorCodes[ErrorCodes["E_INSTANCE_ACTION_NO_HANDLER"] = 103] = "E_INSTANCE_ACTION_NO_HANDLER"; + ErrorCodes[ErrorCodes["E_DEVICE_ACTION_NOT_INITIALIZED"] = 201] = "E_DEVICE_ACTION_NOT_INITIALIZED"; + ErrorCodes[ErrorCodes["E_DEVICE_ACTION_DEVICE_UNKNOWN"] = 202] = "E_DEVICE_ACTION_DEVICE_UNKNOWN"; + ErrorCodes[ErrorCodes["E_DEVICE_ACTION_UNKNOWN"] = 203] = "E_DEVICE_ACTION_UNKNOWN"; + ErrorCodes[ErrorCodes["E_DEVICE_ACTION_NO_HANDLER"] = 204] = "E_DEVICE_ACTION_NO_HANDLER"; + ErrorCodes[ErrorCodes["E_DEVICE_CONTROL_NOT_INITIALIZED"] = 301] = "E_DEVICE_CONTROL_NOT_INITIALIZED"; + ErrorCodes[ErrorCodes["E_DEVICE_CONTROL_DEVICE_UNKNOWN"] = 302] = "E_DEVICE_CONTROL_DEVICE_UNKNOWN"; + ErrorCodes[ErrorCodes["E_DEVICE_CONTROL_UNKNOWN"] = 303] = "E_DEVICE_CONTROL_UNKNOWN"; + ErrorCodes[ErrorCodes["E_DEVICE_CONTROL_NO_HANDLER"] = 304] = "E_DEVICE_CONTROL_NO_HANDLER"; + ErrorCodes[ErrorCodes["E_DEVICE_GET_STATE_NOT_INITIALIZED"] = 401] = "E_DEVICE_GET_STATE_NOT_INITIALIZED"; + ErrorCodes[ErrorCodes["E_DEVICE_GET_STATE_DEVICE_UNKNOWN"] = 402] = "E_DEVICE_GET_STATE_DEVICE_UNKNOWN"; + ErrorCodes[ErrorCodes["E_DEVICE_GET_STATE_UNKNOWN"] = 403] = "E_DEVICE_GET_STATE_UNKNOWN"; + ErrorCodes[ErrorCodes["E_DEVICE_GET_STATE_NO_HANDLER"] = 404] = "E_DEVICE_GET_STATE_NO_HANDLER"; +})(ErrorCodes || (exports.ErrorCodes = ErrorCodes = {})); diff --git a/build/types/index.d.ts b/build/types/index.d.ts new file mode 100644 index 0000000..d150005 --- /dev/null +++ b/build/types/index.d.ts @@ -0,0 +1,4 @@ +export type * from './adapter'; +export { ACTIONS, type ChannelInfo, type Color, type ControlBase, type ControlState, type BackendToGuiCommand, type BackendToGuiCommandAllUpdate, type BackendToGuiCommandDeviceDelete, type BackendToGuiCommandDeviceStatusUpdate, type BackendToGuiCommandDeviceInfoUpdate, } from './base'; +export type * from './common'; +export * from './errorCodes'; diff --git a/build/types/index.js b/build/types/index.js new file mode 100644 index 0000000..9a2e036 --- /dev/null +++ b/build/types/index.js @@ -0,0 +1,20 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ACTIONS = void 0; +var base_1 = require("./base"); +Object.defineProperty(exports, "ACTIONS", { enumerable: true, get: function () { return base_1.ACTIONS; } }); +__exportStar(require("./errorCodes"), exports); diff --git a/examples/dm-test.ts b/examples/dm-test.ts index a08b16e..2d8bfc5 100644 --- a/examples/dm-test.ts +++ b/examples/dm-test.ts @@ -1,11 +1,11 @@ import { type ActionContext, type DeviceDetails, + type DeviceLoadContext, DeviceManagement, - type DeviceRefresh, type JsonFormSchema, } from '../src'; -import type * as base from '../src/types/base'; +import { type DeviceRefreshResponse } from '../src/types/base'; const demoFormSchema: JsonFormSchema = { type: 'tabs', @@ -80,76 +80,115 @@ const demoFormSchema: JsonFormSchema = { }; export class DmTestDeviceManagement extends DeviceManagement { - protected listDevices(): Promise[]> { - return Promise.resolve([ - { id: 'test-123', name: 'Test 123', status: 'connected' }, - { id: 'test-345', name: 'Test 345', status: 'disconnected', hasDetails: true, actions: [] }, - { - id: 'test-789', - name: 'Test 789', - status: 'connected', - actions: [ - { - id: 'play', - icon: 'fas fa-play', + protected loadDevices(context: DeviceLoadContext): void { + context.addDevice({ + id: 'test-123', + identifier: 'test-123', + name: 'Test 123', + status: 'connected', + actions: [ + { + id: 'update', + icon: 'forward', + handler: (deviceId: string): DeviceRefreshResponse<'adapter', string> => { + this.log.info(`Update was pressed on ${deviceId}`); + return { + update: { + id: 'test-123', + identifier: 'test-123 (*)', + name: `Updated name for ${deviceId}`, + status: 'disconnected', + }, + }; }, - { - id: 'pause', - icon: 'fa-pause', - description: 'Pause device', + }, + ], + }); + context.addDevice({ + id: 'test-345', + identifier: 'test-345', + name: 'Test 345', + status: 'disconnected', + hasDetails: true, + actions: [ + { + id: 'info', + icon: 'settings', + // instead of handler, url can be provided to open a link when action is clicked + url: 'https://www.iobroker.net', + }, + { + id: 'infoTranslated', + icon: 'info', + // The URL can also be translated, so it can be different for different languages + url: { + en: 'https://www.iobroker.net/#en', + de: 'https://www.iobroker.net/#de', + ru: 'https://www.iobroker.net/#ru', + 'zh-cn': 'https://www.iobroker.net/#zh-cn', }, - { - id: 'forward', - icon: 'forward', - description: 'Forward', + }, + ], + }); + context.addDevice({ + id: 'test-789', + identifier: 'test-789', + name: 'Test 789', + status: 'connected', + actions: [ + { + id: 'play', + icon: 'play', + handler: (deviceId: string) => { + this.log.info(`Play was pressed on ${deviceId}`); + return { refresh: 'none' }; }, - ], - }, - { - id: 'test-ABC', - name: 'Test ABC', - status: 'connected', - actions: [ - { - id: 'forms', - icon: 'fab fa-wpforms', - description: 'Show forms flow', + }, + { + id: 'pause', + icon: 'pause', + description: 'Pause device', + handler: async (deviceId: string, context: ActionContext) => { + this.log.info(`Pause was pressed on ${deviceId}`); + const confirm = await context.showConfirmation('Do you want to refresh the device list only?'); + return { refresh: confirm ? 'devices' : 'instance' }; }, - ], - }, - ]); - } - - protected override async handleDeviceAction( - deviceId: string, - actionId: string, - context: ActionContext, - ): Promise<{ refresh: DeviceRefresh }> { - switch (actionId) { - case 'play': - this.log.info(`Play was pressed on ${deviceId}`); - return { refresh: false }; - case 'pause': { - this.log.info(`Pause was pressed on ${deviceId}`); - const confirm = await context.showConfirmation('Do you want to refresh the device only?'); - return { refresh: confirm ? 'device' : 'instance' }; - } - case 'forms': { - this.log.info(`Forms was pressed on ${deviceId}`); - const data = await context.showForm(demoFormSchema, { data: { myPort: 8081, secondPort: 8082 } }); - if (!data) { - await context.showMessage('You cancelled the previous form!'); - } else { - await context.showMessage(`You entered: ${JSON.stringify(data)}`); - } - return { refresh: false }; - } - default: - throw new Error(`Unknown action ${actionId}`); - } + }, + { + id: 'forward', + icon: 'forward', + description: 'Forward', + }, + ], + }); + context.addDevice({ + id: 'test-ABC', + identifier: 'test-ABC', + name: 'Test ABC', + status: 'connected', + actions: [ + { + id: 'forms', + icon: 'fab fa-wpforms', + description: 'Show forms flow', + handler: async (deviceId: string, context: ActionContext) => { + this.log.info(`Forms was pressed on ${deviceId}`); + const data = await context.showForm(demoFormSchema, { + data: { myPort: 8081, secondPort: 8082 }, + }); + if (!data) { + await context.showMessage('You cancelled the previous form!'); + } else { + await context.showMessage(`You entered: ${JSON.stringify(data)}`); + } + return { refresh: 'none' }; + }, + }, + ], + }); } - protected override getDeviceDetails(id: string): Promise { + protected override getDeviceDetails(id: string): Promise> { const schema: JsonFormSchema = { type: 'panel', items: { diff --git a/package.json b/package.json index 6724dad..1ed21be 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "build": "tsc -p tsconfig.json", "test:package": "node -e \"process.exit(0)\"", "lint": "eslint -c eslint.config.mjs src", - "updateCommonTs": "node tasks.js", + "updateCommonTs": "npx -y tsx tasks.ts", "prettier": "prettier -u -w examples src", "release": "release-script", "release-patch": "release-script patch --yes", diff --git a/src/ActionContext.ts b/src/ActionContext.ts index fc3c511..8ca6e32 100644 --- a/src/ActionContext.ts +++ b/src/ActionContext.ts @@ -1,12 +1,9 @@ -import type { BackEndCommandJsonFormOptions, JsonFormData, JsonFormSchema } from '.'; +import type { BackEndCommandJsonFormOptions, JsonFormData, JsonFormSchema, ProgressOptions } from '.'; import type { ProgressDialog } from './ProgressDialog'; export interface ActionContext { showMessage(text: ioBroker.StringOrTranslated): Promise; showConfirmation(text: ioBroker.StringOrTranslated): Promise; showForm(schema: JsonFormSchema, options?: BackEndCommandJsonFormOptions): Promise; - openProgress( - title: string, - options?: { indeterminate?: boolean; value?: number; label?: ioBroker.StringOrTranslated }, - ): Promise; + openProgress(title: string, options?: ProgressOptions): Promise; } diff --git a/src/DeviceManagement.ts b/src/DeviceManagement.ts index adec7c6..822a0fe 100644 --- a/src/DeviceManagement.ts +++ b/src/DeviceManagement.ts @@ -2,31 +2,50 @@ import type { AdapterInstance } from '@iobroker/adapter-core'; import type { ActionContext } from './ActionContext'; import type { ProgressDialog } from './ProgressDialog'; import { + ErrorCodes, type ActionBase, + type ActionButton, type DeviceDetails, + type DeviceId, type DeviceInfo, + type DeviceStatus, type ErrorResponse, type InstanceDetails, type JsonFormData, type JsonFormSchema, - type RefreshResponse, type RetVal, - type ActionButton, - ErrorCodes, - type DeviceStatus, } from './types'; import type * as api from './types/api'; -import type { BackendToGuiCommand, ControlState, DeviceControl } from './types/base'; - -export abstract class DeviceManagement { +import type { + BackendToGuiCommand, + ControlState, + DeviceControl, + DeviceRefreshResponse, + InstanceRefreshResponse, +} from './types/base'; +import type { ProgressOptions, ProgressUpdate } from './types/common'; + +export type DeviceLoadContext = { + addDevice(device: DeviceInfo): void; + setTotalDevices(count: number): void; +}; + +// Based on https://tkdodo.eu/blog/omit-for-discriminated-unions-in-type-script +type DistributiveOmit = T extends any ? Omit : never; + +export abstract class DeviceManagement< + TAdapter extends AdapterInstance = AdapterInstance, + TId extends DeviceId = string, +> { private instanceInfo?: InstanceDetails; - private devices?: Map; + private devices?: Map>; private readonly communicationStateId: string; - private readonly contexts = new Map(); + private readonly deviceLoadContexts = new Map>(); + private readonly messageContexts = new Map>(); constructor( - protected readonly adapter: T, + protected readonly adapter: TAdapter, communicationStateId?: string | boolean, ) { adapter.on('message', this.onMessage.bind(this)); @@ -62,7 +81,7 @@ export abstract class DeviceManagement { + protected async sendCommandToGui(command: BackendToGuiCommand): Promise { if (this.communicationStateId) { await this.adapter.setState(this.communicationStateId, JSON.stringify(command), true); } else { @@ -76,28 +95,28 @@ export abstract class DeviceManagement { // Overload this method if your adapter does not use BackendToGui communication and States/Objects in DeviceInfo - return { apiVersion: 'v2', communicationStateId: this.communicationStateId || undefined }; + return { apiVersion: 'v3', communicationStateId: this.communicationStateId || undefined }; } - protected abstract listDevices(): RetVal; + protected abstract loadDevices(context: DeviceLoadContext): RetVal; - protected getDeviceInfo(deviceId: string): RetVal { + protected getDeviceInfo(_deviceId: TId): RetVal> { throw new Error('Do not send "infoUpdate" or "delete" command without implementing getDeviceInfo method!'); } - protected getDeviceStatus(deviceId: string): RetVal { + protected getDeviceStatus(_deviceId: TId): RetVal { throw new Error('Do not send "statusUpdate" command without implementing getDeviceStatus method!'); } - protected getDeviceDetails(id: string): RetVal { + protected getDeviceDetails(id: TId): RetVal | null | { error: string }> { return { id, schema: {} as JsonFormSchema }; } - protected handleInstanceAction( + private handleInstanceAction( actionId: string, context?: ActionContext, options?: { value?: number | string | boolean; [key: string]: any }, - ): RetVal | RetVal { + ): RetVal | RetVal { if (!this.instanceInfo) { this.log.warn(`Instance action ${actionId} was called before getInstanceInfo()`); return { @@ -117,7 +136,7 @@ export abstract class DeviceManagement | RetVal { + ): RetVal | RetVal> { if (!this.devices) { - this.log.warn(`Device action ${actionId} was called before listDevices()`); + this.log.warn(`Device action ${actionId} was called before loadDevices()`); return { error: { code: ErrorCodes.E_DEVICE_ACTION_NOT_INITIALIZED, - message: `Device action ${actionId} was called before listDevices()`, + message: `Device action ${actionId} was called before loadDevices()`, }, }; } - const device = this.devices.get(deviceId); + const jsonId = JSON.stringify(deviceId); + const device = this.devices.get(jsonId); if (!device) { - this.log.warn(`Device action ${actionId} was called on unknown device: ${deviceId}`); + this.log.warn(`Device action ${actionId} was called on unknown device: ${jsonId}`); return { error: { code: ErrorCodes.E_DEVICE_ACTION_DEVICE_UNKNOWN, - message: `Device action ${actionId} was called on unknown device: ${deviceId}`, + message: `Device action ${actionId} was called on unknown device: ${jsonId}`, }, }; } const action = device.actions?.find(a => a.id === actionId); if (!action) { - this.log.warn(`Device action ${actionId} doesn't exist on device ${deviceId}`); + this.log.warn(`Device action ${actionId} doesn't exist on device ${jsonId}`); return { error: { code: ErrorCodes.E_DEVICE_ACTION_UNKNOWN, - message: `Device action ${actionId} doesn't exist on device ${deviceId}`, + message: `Device action ${actionId} doesn't exist on device ${jsonId}`, }, }; } - if (!action.handler) { - this.log.warn(`Device action ${actionId} on ${deviceId} is disabled because it has no handler`); + if (!('handler' in action) || !action.handler) { + this.log.warn(`Device action ${actionId} on ${jsonId} is disabled because it has no handler`); return { error: { code: ErrorCodes.E_DEVICE_ACTION_NO_HANDLER, - message: `Device action ${actionId} on ${deviceId} is disabled because it has no handler`, + message: `Device action ${actionId} on ${jsonId} is disabled because it has no handler`, }, }; } @@ -178,48 +198,49 @@ export abstract class DeviceManagement, ): RetVal { if (!this.devices) { - this.log.warn(`Device control ${controlId} was called before listDevices()`); + this.log.warn(`Device control ${controlId} was called before loadDevices()`); return { error: { code: ErrorCodes.E_DEVICE_CONTROL_NOT_INITIALIZED, - message: `Device control ${controlId} was called before listDevices()`, + message: `Device control ${controlId} was called before loadDevices()`, }, }; } - const device = this.devices.get(deviceId); + const jsonId = JSON.stringify(deviceId); + const device = this.devices.get(jsonId); if (!device) { - this.log.warn(`Device control ${controlId} was called on unknown device: ${deviceId}`); + this.log.warn(`Device control ${controlId} was called on unknown device: ${jsonId}`); return { error: { code: ErrorCodes.E_DEVICE_CONTROL_DEVICE_UNKNOWN, - message: `Device control ${controlId} was called on unknown device: ${deviceId}`, + message: `Device control ${controlId} was called on unknown device: ${jsonId}`, }, }; } const control = device.controls?.find(a => a.id === controlId); if (!control) { - this.log.warn(`Device control ${controlId} doesn't exist on device ${deviceId}`); + this.log.warn(`Device control ${controlId} doesn't exist on device ${jsonId}`); return { error: { code: ErrorCodes.E_DEVICE_CONTROL_UNKNOWN, - message: `Device control ${controlId} doesn't exist on device ${deviceId}`, + message: `Device control ${controlId} doesn't exist on device ${jsonId}`, }, }; } if (!control.handler) { - this.log.warn(`Device control ${controlId} on ${deviceId} is disabled because it has no handler`); + this.log.warn(`Device control ${controlId} on ${jsonId} is disabled because it has no handler`); return { error: { code: ErrorCodes.E_DEVICE_CONTROL_NO_HANDLER, - message: `Device control ${controlId} on ${deviceId} is disabled because it has no handler`, + message: `Device control ${controlId} on ${jsonId} is disabled because it has no handler`, }, }; } @@ -228,47 +249,48 @@ export abstract class DeviceManagement, ): RetVal { if (!this.devices) { - this.log.warn(`Device get state ${controlId} was called before listDevices()`); + this.log.warn(`Device get state ${controlId} was called before loadDevices()`); return { error: { code: ErrorCodes.E_DEVICE_GET_STATE_NOT_INITIALIZED, - message: `Device control ${controlId} was called before listDevices()`, + message: `Device control ${controlId} was called before loadDevices()`, }, }; } - const device = this.devices.get(deviceId); + const jsonId = JSON.stringify(deviceId); + const device = this.devices.get(jsonId); if (!device) { - this.log.warn(`Device get state ${controlId} was called on unknown device: ${deviceId}`); + this.log.warn(`Device get state ${controlId} was called on unknown device: ${jsonId}`); return { error: { code: ErrorCodes.E_DEVICE_GET_STATE_DEVICE_UNKNOWN, - message: `Device control ${controlId} was called on unknown device: ${deviceId}`, + message: `Device control ${controlId} was called on unknown device: ${jsonId}`, }, }; } const control = device.controls?.find(a => a.id === controlId); if (!control) { - this.log.warn(`Device get state ${controlId} doesn't exist on device ${deviceId}`); + this.log.warn(`Device get state ${controlId} doesn't exist on device ${jsonId}`); return { error: { code: ErrorCodes.E_DEVICE_GET_STATE_UNKNOWN, - message: `Device control ${controlId} doesn't exist on device ${deviceId}`, + message: `Device control ${controlId} doesn't exist on device ${jsonId}`, }, }; } if (!control.getStateHandler) { - this.log.warn(`Device get state ${controlId} on ${deviceId} is disabled because it has no handler`); + this.log.warn(`Device get state ${controlId} on ${jsonId} is disabled because it has no handler`); return { error: { code: ErrorCodes.E_DEVICE_GET_STATE_NO_HANDLER, - message: `Device get state ${controlId} on ${deviceId} is disabled because it has no handler`, + message: `Device get state ${controlId} on ${jsonId} is disabled because it has no handler`, }, }; } @@ -289,100 +311,125 @@ export abstract class DeviceManagement( - { ...this.instanceInfo, actions: this.convertActions(this.instanceInfo.actions) }, + { ...this.instanceInfo, actions: convertActions(this.instanceInfo.actions) }, msg, ); return; } - case 'dm:listDevices': { - const deviceList = await this.listDevices(); + case 'dm:loadDevices': { + const context = new DeviceLoadContextImpl(msg, this.adapter); + this.deviceLoadContexts.set(msg._id, context); + await this.loadDevices(context); + if (context.complete()) { + this.deviceLoadContexts.delete(msg._id); + } - this.devices = deviceList.reduce((map, value) => { - if (map.has(value.id)) { - throw new Error(`Device ID ${value.id} is not unique`); + this.devices = context.devices.reduce((map, value) => { + const jsonId = JSON.stringify(value.id); + if (map.has(jsonId)) { + throw new Error(`Device ID ${jsonId} is not unique`); } - map.set(value.id, value); + map.set(jsonId, value); return map; - }, new Map()); - - const apiDeviceList: api.DeviceInfo[] = deviceList.map(d => ({ - ...d, - actions: this.convertActions(d.actions), - controls: this.convertControls(d.controls), - })); - - this.sendReply(apiDeviceList, msg); + }, new Map>()); return; } case 'dm:deviceInfo': { - const deviceInfo = await this.getDeviceInfo(msg.message as string); + const deviceInfo = await this.getDeviceInfo(msg.message as TId); this.sendReply( { ...deviceInfo, - actions: this.convertActions(deviceInfo.actions), - controls: this.convertControls(deviceInfo.controls), + actions: convertActions(deviceInfo.actions), + controls: convertControls(deviceInfo.controls), }, msg, ); return; } case 'dm:deviceStatus': { - const deviceStatus = await this.getDeviceStatus(msg.message as string); + const deviceStatus = await this.getDeviceStatus(msg.message as TId); this.sendReply(deviceStatus, msg); return; } case 'dm:deviceDetails': { - const details = await this.getDeviceDetails(msg.message as string); + const details = await this.getDeviceDetails(msg.message as TId); this.sendReply(details, msg); return; } case 'dm:instanceAction': { const action = msg.message as { actionId: string; value: number | string | boolean }; const context = new MessageContext(msg, this.adapter); - this.contexts.set(msg._id, context); + this.messageContexts.set(msg._id, context); const result = await this.handleInstanceAction(action.actionId, context, { value: action.value }); - this.contexts.delete(msg._id); + this.messageContexts.delete(msg._id); context.sendFinalResult(result); return; } case 'dm:deviceAction': { - const action = msg.message as { actionId: string; deviceId: string; value: number | string | boolean }; + const action = msg.message as { actionId: string; deviceId: TId; value: number | string | boolean }; const context = new MessageContext(msg, this.adapter); - this.contexts.set(msg._id, context); + this.messageContexts.set(msg._id, context); const result = await this.handleDeviceAction(action.deviceId, action.actionId, context, { value: action.value, }); - this.contexts.delete(msg._id); - context.sendFinalResult(result); + this.messageContexts.delete(msg._id); + if ('update' in result) { + // special handling for update responses (we need to update our cache and convert actions/controls before sending to GUI) + const update = result.update; + this.devices?.set(JSON.stringify(update.id), update); + context.sendFinalResult({ + update: { + ...update, + actions: convertActions(update.actions), + controls: convertControls(update.controls), + }, + }); + } else { + context.sendFinalResult(result); + } return; } case 'dm:deviceControl': { - const control = msg.message as { deviceId: string; controlId: string; state: ControlState }; + const control = msg.message as { deviceId: TId; controlId: string; state: ControlState }; const context = new MessageContext(msg, this.adapter); - this.contexts.set(msg._id, context); + this.messageContexts.set(msg._id, context); const result = await this.handleDeviceControl( control.deviceId, control.controlId, control.state, context, ); - this.contexts.delete(msg._id); + this.messageContexts.delete(msg._id); context.sendControlResult(control.deviceId, control.controlId, result); return; } case 'dm:deviceControlState': { - const control = msg.message as { deviceId: string; controlId: string }; + const control = msg.message as { deviceId: TId; controlId: string }; const context = new MessageContext(msg, this.adapter); - this.contexts.set(msg._id, context); + this.messageContexts.set(msg._id, context); const result = await this.handleDeviceControlState(control.deviceId, control.controlId, context); - this.contexts.delete(msg._id); + this.messageContexts.delete(msg._id); context.sendControlResult(control.deviceId, control.controlId, result); return; } + case 'dm:deviceLoadProgress': { + const { origin } = msg.message as { origin: number }; + const context = this.deviceLoadContexts.get(origin); + if (!context) { + this.log.warn(`Unknown message origin: ${origin}`); + this.sendReply({ error: 'Unknown load progress origin' }, msg); + return; + } + + if (context.handleProgress(msg)) { + this.deviceLoadContexts.delete(origin); + } + return; + } case 'dm:actionProgress': { const { origin } = msg.message as { origin: number }; - const context = this.contexts.get(origin); + const context = this.messageContexts.get(origin); if (!context) { this.log.warn(`Unknown message origin: ${origin}`); this.sendReply({ error: 'Unknown action origin' }, msg); @@ -395,52 +442,77 @@ export abstract class DeviceManagement(actions?: T[]): undefined | U[] { - if (!actions) { - return undefined; - } + private sendReply(reply: T, msg: ioBroker.Message): void { + this.adapter.sendTo(msg.from, msg.command, reply, msg.callback); + } +} - // detect duplicate IDs - const ids = new Set(); +class DeviceLoadContextImpl implements DeviceLoadContext { + private readonly minBatchSize = 8; + public readonly devices: DeviceInfo[] = []; + private readonly id: number; + private sendNext: api.DeviceInfo[] = []; + private totalDevices?: number; + private completed = false; + private respondTo?: ioBroker.Message; - actions.forEach(a => { - if (ids.has(a.id)) { - throw new Error(`Action ID ${a.id} is used twice, this would lead to unexpected behavior`); - } - ids.add(a.id); + constructor( + msg: ioBroker.Message, + private readonly adapter: AdapterInstance, + ) { + this.respondTo = msg; + this.id = msg._id; + } + + addDevice(device: DeviceInfo): void { + this.devices.push(device); + this.sendNext.push({ + ...device, + actions: convertActions(device.actions), + controls: convertControls(device.controls), }); + this.flush(); + } - // remove handler function to send it as JSON - return actions.map((a: any) => ({ ...a, handler: undefined, disabled: !a.handler })); + setTotalDevices(count: number): void { + this.totalDevices = count; + this.flush(); } - private convertControls, U extends DeviceControl<'api'>>( - controls?: T[], - ): undefined | U[] { - if (!controls) { - return undefined; - } + complete(): boolean { + this.completed = true; + return this.flush(); + } - // detect duplicate IDs - const ids = new Set(); + handleProgress(message: ioBroker.Message): boolean { + this.respondTo = message; + return this.flush(); + } - controls.forEach(a => { - if (ids.has(a.id)) { - throw new Error(`Control ID ${a.id} is used twice, this would lead to unexpected behavior`); - } - ids.add(a.id); - }); + private flush(): boolean { + if (this.sendNext.length <= this.minBatchSize && !this.completed) { + return false; + } - // remove handler function to send it as JSON - return controls.map((a: any) => ({ ...a, handler: undefined, getStateHandler: undefined })); - } + if (!this.respondTo) { + return false; + } - private sendReply(reply: T, msg: ioBroker.Message): void { + const reply: api.DeviceLoadIncrement = { + add: this.sendNext, + total: this.totalDevices, + next: this.completed ? undefined : { origin: this.id }, + }; + this.sendNext = []; + + const msg = this.respondTo; + this.respondTo = undefined; this.adapter.sendTo(msg.from, msg.command, reply, msg.callback); + return this.completed; } } -export class MessageContext implements ActionContext { +export class MessageContext implements ActionContext { private hasOpenProgressDialog = false; private lastMessage?: ioBroker.Message; private progressHandler?: (message: Record) => void; @@ -457,9 +529,7 @@ export class MessageContext implements ActionContext { const promise = new Promise(resolve => { this.progressHandler = () => resolve(); }); - this.send('message', { - message: text, - }); + this.send({ type: 'message', message: text }); return promise; } @@ -468,9 +538,7 @@ export class MessageContext implements ActionContext { const promise = new Promise(resolve => { this.progressHandler = msg => resolve(!!msg.confirm); }); - this.send('confirm', { - confirm: text, - }); + this.send({ type: 'confirm', confirm: text }); return promise; } @@ -486,30 +554,22 @@ export class MessageContext implements ActionContext { const promise = new Promise(resolve => { this.progressHandler = msg => resolve(msg.data); }); - this.send('form', { + this.send({ + type: 'form', form: { schema, ...options }, }); return promise; } - openProgress( - title: string, - options?: { indeterminate?: boolean; value?: number; label?: string }, - ): Promise { + openProgress(title: ioBroker.StringOrTranslated, options?: ProgressOptions): Promise { this.checkPreconditions(); this.hasOpenProgressDialog = true; const dialog: ProgressDialog = { - update: (update: { title?: string; indeterminate?: boolean; value?: number; label?: string }) => { + update: (update: ProgressUpdate) => { const promise = new Promise(resolve => { this.progressHandler = () => resolve(); }); - this.send( - 'progress', - { - progress: { title, ...options, ...update, open: true }, - }, - true, - ); + this.send({ type: 'progress', progress: update }, true); return promise; }, @@ -520,9 +580,7 @@ export class MessageContext implements ActionContext { resolve(); }; }); - this.send('progress', { - progress: { open: false }, - }); + this.send({ type: 'progress', progress: { open: false } }); return promise; }, }; @@ -530,25 +588,18 @@ export class MessageContext implements ActionContext { const promise = new Promise(resolve => { this.progressHandler = () => resolve(dialog); }); - this.send( - 'progress', - { - progress: { title, ...options, open: true }, - }, - true, - ); + this.send({ type: 'progress', progress: { title, ...options, open: true } }, true); return promise; } - sendFinalResult(result: ErrorResponse | RefreshResponse): void { - this.send('result', { - result, - }); + sendFinalResult(result: ErrorResponse | DeviceRefreshResponse<'api', TId> | InstanceRefreshResponse): void { + this.send({ type: 'result', result }); } - sendControlResult(deviceId: string, controlId: string, result: ErrorResponse | ioBroker.State): void { + sendControlResult(deviceId: TId, controlId: string, result: ErrorResponse | ioBroker.State): void { if (typeof result === 'object' && 'error' in result) { - this.send('result', { + this.send({ + type: 'result', result: { error: result.error, deviceId, @@ -556,7 +607,8 @@ export class MessageContext implements ActionContext { }, }); } else { - this.send('result', { + this.send({ + type: 'result', result: { state: result, deviceId, @@ -583,7 +635,10 @@ export class MessageContext implements ActionContext { } } - private send(type: string, message: any, doNotClose?: boolean): void { + private send( + message: DistributiveOmit, + doNotClose?: boolean, + ): void { if (!this.lastMessage) { throw new Error("No outstanding message, can't send a new one"); } @@ -592,7 +647,6 @@ export class MessageContext implements ActionContext { this.lastMessage.command, { ...message, - type, origin: this.lastMessage.message.origin || this.lastMessage._id, }, this.lastMessage.callback, @@ -603,3 +657,43 @@ export class MessageContext implements ActionContext { } } } + +function convertActions(actions?: T[]): undefined | U[] { + if (!actions) { + return undefined; + } + + // detect duplicate IDs + const ids = new Set(); + + actions.forEach(a => { + if (ids.has(a.id)) { + throw new Error(`Action ID ${a.id} is used twice, this would lead to unexpected behavior`); + } + ids.add(a.id); + }); + + // remove handler function to send it as JSON + return actions.map((a: any) => ({ ...a, handler: undefined, disabled: !a.handler && !a.url })); +} + +function convertControls, U extends DeviceControl<'api'>>( + controls?: T[], +): undefined | U[] { + if (!controls) { + return undefined; + } + + // detect duplicate IDs + const ids = new Set(); + + controls.forEach(a => { + if (ids.has(a.id)) { + throw new Error(`Control ID ${a.id} is used twice, this would lead to unexpected behavior`); + } + ids.add(a.id); + }); + + // remove handler function to send it as JSON + return controls.map((a: any) => ({ ...a, handler: undefined, getStateHandler: undefined })); +} diff --git a/src/ProgressDialog.ts b/src/ProgressDialog.ts index db2277f..fe26760 100644 --- a/src/ProgressDialog.ts +++ b/src/ProgressDialog.ts @@ -1,10 +1,7 @@ +import type { ProgressUpdate } from './types/common'; + export interface ProgressDialog { - update(update: { - title?: ioBroker.StringOrTranslated; - indeterminate?: boolean; - value?: number; - label?: ioBroker.StringOrTranslated; - }): Promise; + update(update: ProgressUpdate): Promise; close(): Promise; } diff --git a/src/types/adapter.ts b/src/types/adapter.ts index 40c616f..bd46908 100644 --- a/src/types/adapter.ts +++ b/src/types/adapter.ts @@ -1,8 +1,9 @@ import type * as base from './base'; +import type { DeviceId } from './common'; export type ActionBase = base.ActionBase<'adapter'>; export type InstanceAction = base.InstanceAction<'adapter'>; -export type DeviceAction = base.DeviceAction<'adapter'>; +export type DeviceAction = base.DeviceAction<'adapter', TId>; export type InstanceDetails = base.InstanceDetails<'adapter'>; -export type DeviceInfo = base.DeviceInfo<'adapter'>; -export type DeviceControl = base.DeviceControl<'adapter'>; +export type DeviceInfo = base.DeviceInfo<'adapter', TId>; +export type DeviceControl = base.DeviceControl<'adapter', TId>; diff --git a/src/types/api.ts b/src/types/api.ts index f613bd5..37d1976 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,4 +1,5 @@ import type * as base from './base'; +import type { ActionButton, DeviceId, ErrorResponse, JsonFormSchema, ProgressUpdate } from './common'; export type ActionBase = base.ActionBase<'api'>; export type InstanceAction = base.InstanceAction<'api'>; @@ -6,3 +7,74 @@ export type DeviceAction = base.DeviceAction<'api'>; export type InstanceDetails = base.InstanceDetails<'api'>; export type DeviceInfo = base.DeviceInfo<'api'>; export type DeviceControl = base.DeviceControl<'api'>; +export type DeviceRefreshResponse = base.DeviceRefreshResponse<'api'>; +export type InstanceRefreshResponse = base.InstanceRefreshResponse; + +export type DeviceLoadIncrement = { + add: DeviceInfo[]; + total?: number; + next?: { origin: number }; +}; + +export type DmResponseBase = { + origin: number; +}; + +export type DmControlResponse = DmResponseBase & { + type: 'result'; + result: { + deviceId: DeviceId; + controlId: string; + } & ( + | ErrorResponse + | { + state: ioBroker.State; + } + ); +}; + +export type DmActionResultResponse = DmResponseBase & { + type: 'result'; + result: ErrorResponse | DeviceRefreshResponse | InstanceRefreshResponse; +}; + +export type DmActionMessageResponse = DmResponseBase & { + type: 'message'; + message: ioBroker.StringOrTranslated; +}; + +export type DmActionConfirmResponse = DmResponseBase & { + type: 'confirm'; + confirm: ioBroker.StringOrTranslated; +}; + +export interface CommunicationForm { + title?: ioBroker.StringOrTranslated | null | undefined; + label?: ioBroker.StringOrTranslated | null | undefined; // same as title + noTranslation?: boolean; // Do not translate title/label + schema: JsonFormSchema; + data?: Record; + buttons?: (ActionButton | 'apply' | 'cancel' | 'close')[]; + maxWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + /** Minimal width of the dialog */ + minWidth?: number; + /** Always allow the apply button. Even when nothing was changed */ + ignoreApplyDisabled?: boolean; +} + +export type DmActionFormResponse = DmResponseBase & { + type: 'form'; + form: CommunicationForm; +}; + +export type DmActionProgressResponse = DmResponseBase & { + type: 'progress'; + progress: ProgressUpdate & { open?: boolean }; +}; + +export type DmActionResponse = + | DmActionResultResponse + | DmActionMessageResponse + | DmActionConfirmResponse + | DmActionFormResponse + | DmActionProgressResponse; diff --git a/src/types/base.ts b/src/types/base.ts index 928a741..637a093 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -7,7 +7,7 @@ import type { ValueOrState, ValueOrStateOrObject, } from '..'; -import type { ApiVersion, DeviceRefresh, DeviceStatus, RetVal } from './common'; +import type { ApiVersion, DeviceId, DeviceStatus, RetVal } from './common'; type ActionType = 'api' | 'adapter'; export type Color = 'primary' | 'secondary' | (string & {}); // color (you can use primary, secondary or color rgb value or hex) @@ -28,63 +28,37 @@ export interface ActionBase { /** * This can either be base64 or the URL to an icon. */ - icon?: // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - | 'edit' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + icon?: + | 'edit' | 'rename' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'delete' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'refresh' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'newDevice' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'new' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'add' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'discover' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'search' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'unpairDevice' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'pairDevice' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'identify' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'play' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'stop' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'pause' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'forward' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'next' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'rewind' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'previous' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'lamp' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'light' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'backlight' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'dimmer' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'socket' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'settings' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'users' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'group' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'user' - | string; // base64 or url + | 'info' + | (string & {}); // base64 or url description?: ioBroker.StringOrTranslated; disabled?: T extends 'api' ? boolean : never; color?: Color; @@ -145,36 +119,59 @@ export interface ControlBase { channel?: ChannelInfo; } -export interface DeviceControl extends ControlBase { - handler?: T extends 'api' +export interface DeviceControl extends ControlBase { + handler?: TType extends 'api' ? never : ( - deviceId: string, + deviceId: TId, actionId: string, state: ControlState, - context: MessageContext, + context: MessageContext, ) => RetVal; - getStateHandler?: T extends 'api' + getStateHandler?: TType extends 'api' ? never - : (deviceId: string, actionId: string, context: MessageContext) => RetVal; + : (deviceId: TId, actionId: string, context: MessageContext) => RetVal; } -export interface InstanceAction extends ActionBase { - handler?: T extends 'api' - ? never - : (context: ActionContext, options?: Record) => RetVal<{ refresh: boolean }>; - title: ioBroker.StringOrTranslated; -} +export type InstanceRefreshResponse = { + refresh: boolean; +}; -export interface DeviceAction extends ActionBase { - handler?: T extends 'api' - ? never - : ( - deviceId: string, - context: ActionContext, - options?: Record, - ) => RetVal<{ refresh: DeviceRefresh }>; -} +export type WithHandlerOrUrl = + | { handler?: TType extends 'api' ? never : THandler } + | { url: ioBroker.StringOrTranslated }; + +export type InstanceAction = ActionBase & + WithHandlerOrUrl) => RetVal> & { + title: ioBroker.StringOrTranslated; + }; + +export type DeviceUpdate = { + update: DeviceInfo; +}; + +export type DeviceDelete = { + delete: TId; +}; + +export type DeviceRefresh = 'all' | 'devices' | 'instance' | 'none'; + +export type DeviceRefreshResponse = + | { + refresh: DeviceRefresh; + } + | DeviceUpdate + | DeviceDelete; + +export type DeviceAction = ActionBase & + WithHandlerOrUrl< + T, + ( + deviceId: TId, + context: ActionContext, + options?: Record, + ) => RetVal> + >; export interface InstanceDetails { /** API Version: 1 - till 2025 (including), 2 - from 2026 */ @@ -182,11 +179,15 @@ export interface InstanceDetails { actions?: InstanceAction[]; /** ID of state used for communication with GUI */ communicationStateId?: string; + /** Human-readable label next to the identifier */ + identifierLabel?: ioBroker.StringOrTranslated; } -export interface DeviceInfo { - /** ID of the action. Should be unique only in one adapter. Other adapters could have same names */ - id: string; +export interface DeviceInfo { + /** ID of the device. Must be unique only in one adapter. Other adapters could have same IDs */ + id: TId; + /** Human-readable identifier of the device */ + identifier?: ValueOrObject; /** Name of the device. It will be shown in the card header */ name: ValueOrObject; /** base64 or url icon for device card */ @@ -203,9 +204,9 @@ export interface DeviceInfo { /** If this flag is true or false, the according indication will be shown. Additionally, if ACTIONS.ENABLE_DISABLE is implemented, this action will be sent to backend by clicking on this indication */ enabled?: ValueOrState; /** List of actions on the card */ - actions?: DeviceAction[]; + actions?: DeviceAction[]; /** List of controls on the card. The difference of controls and actions is that the controls can show status (e.g. on/off) and can work directly with states */ - controls?: DeviceControl[]; + controls?: DeviceControl[]; /** If true, the button `more` will be shown on the card and called `dm:deviceDetails` action to get the details */ hasDetails?: ValueOrStateOrObject; /** Device type for grouping */ @@ -217,28 +218,28 @@ export interface DeviceInfo { }; } -export interface BackendToGuiCommandDeviceInfoUpdate { +export interface BackendToGuiCommandDeviceInfoUpdate { /** Used for updating and for adding new device */ command: 'infoUpdate'; /** Device ID */ - deviceId: string; + deviceId: TId; /** Backend can send directly new information about device to avoid extra request from GUI */ info?: DeviceInfo; } -export interface BackendToGuiCommandDeviceStatusUpdate { +export interface BackendToGuiCommandDeviceStatusUpdate { /** Status of device was updated */ command: 'statusUpdate'; /** Device ID */ - deviceId: string; + deviceId: TId; /** Backend can send directly new status to avoid extra request from GUI */ status?: DeviceStatus; } -export interface BackendToGuiCommandDeviceDelete { +export interface BackendToGuiCommandDeviceDelete { /** Device was deleted */ command: 'delete'; - deviceId: string; + deviceId: TId; } export interface BackendToGuiCommandAllUpdate { @@ -246,8 +247,8 @@ export interface BackendToGuiCommandAllUpdate { command: 'all'; } -export type BackendToGuiCommand = - | BackendToGuiCommandDeviceInfoUpdate - | BackendToGuiCommandDeviceStatusUpdate - | BackendToGuiCommandDeviceDelete +export type BackendToGuiCommand = + | BackendToGuiCommandDeviceInfoUpdate + | BackendToGuiCommandDeviceStatusUpdate + | BackendToGuiCommandDeviceDelete | BackendToGuiCommandAllUpdate; diff --git a/src/types/common.ts b/src/types/common.ts index fdce485..4fe018f 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,32 +1,30 @@ -export type ApiVersion = 'v1' | 'v2'; +export type ApiVersion = 'v3'; export type ConfigConnectionType = 'lan' | 'wifi' | 'bluetooth' | 'thread' | 'z-wave' | 'zigbee' | 'other'; +export interface ComplexDeviceId { + [key: string | number]: string | number | ComplexDeviceId; +} +export type DeviceId = string | number | ComplexDeviceId; + export type ValueOrObject = T | { objectId: string; property: string }; -export type ValueOrState = T | { stateId: string; mapping?: M }; +export type ValueOrState = T | { stateId: string; mapping?: Record }; export type ValueOrStateOrObject = T | ValueOrObject | ValueOrState; export type DeviceStatus = | 'connected' | 'disconnected' | { - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - battery?: ValueOrState; // in percent (0-100), or string 'charging' or value with unit as string, + battery?: ValueOrState; // in percent (0-100), or string 'charging' or value with unit as string, // or string '10V', // or string '10mV', // or string '100' in mV // or boolean true (means OK) or false (Battery warning) - connection?: ValueOrState<'connected' | 'disconnected', { [value: string]: 'connected' | 'disconnected' }>; + connection?: ValueOrState<'connected' | 'disconnected'>; rssi?: ValueOrState; // in dBm warning?: ValueOrState; // warning text or just boolean true (means warning) }; -export type DeviceRefresh = 'device' | 'instance' | false | true; - -export type RefreshResponse = { - refresh: DeviceRefresh; -}; - export type ErrorResponse = { error: { code: number; @@ -36,6 +34,16 @@ export type ErrorResponse = { export type RetVal = T | Promise; +export interface ProgressOptions { + indeterminate?: boolean; + value?: number; + label?: ioBroker.StringOrTranslated; +} + +export interface ProgressUpdate extends ProgressOptions { + title?: ioBroker.StringOrTranslated; +} + type CustomCSSProperties = Record; interface ObjectBrowserCustomFilter { @@ -43,14 +51,14 @@ interface ObjectBrowserCustomFilter { common?: { type?: ioBroker.CommonType | ioBroker.CommonType[]; role?: string | string[]; - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - custom?: '_' | '_dataSources' | true | string | string[]; + + custom?: '_' | '_dataSources' | true | (string & {}) | string[]; }; } export type ObjectBrowserType = 'state' | 'instance' | 'channel' | 'device' | 'chart'; -// -- START OF DYNAMIC GENERATED CODE FROM https://raw.githubusercontent.com/ioBroker/ioBroker.admin/master/packages/jsonConfig/src/types.d.ts +// -- START OF DYNAMIC GENERATED CODE FROM https://raw.githubusercontent.com/ioBroker/json-config/main/src/types.d.ts export type ConfigItemType = | 'accordion' | 'alive' @@ -74,6 +82,8 @@ export type ConfigItemType = | 'fileSelector' | 'func' | 'header' + | 'iframe' + | 'iframeSendTo' | 'image' | 'imageSendTo' | 'infoBox' @@ -109,7 +119,8 @@ export type ConfigItemType = | 'timePicker' | 'topic' | 'user' - | 'uuid'; + | 'uuid' + | 'yamlEditor'; export type ConfigIconType = // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents @@ -165,8 +176,6 @@ export type ConfigIconType = // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'unpair' // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - | 'update' - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'upload' // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | 'user' @@ -224,7 +233,7 @@ export interface ConfigItem { default?: boolean | number | string; defaultFunc?: string; defaultSendTo?: string; - /** Allow saving of configuration even with error */ + /** Allow saving of configuration even with an error */ allowSaveWithError?: boolean; data?: string | number | boolean; jsonData?: string; @@ -260,21 +269,21 @@ interface ConfigItemIndexed extends ConfigItem { interface ConfigItemTableIndexed extends ConfigItem { attr?: string; - /** show filter options in the header of table */ + /** show filter options in the header of the table */ filter?: boolean; - /** show sorting options in the header of table */ + /** show sorting options in the header of the table */ sort?: boolean; - /** tooltip in the header of table */ + /** tooltip in the header of the table */ title?: string; } export interface ConfigItemAlive extends ConfigItem { type: 'alive'; - /** check if the instance is alive. If not defined, it will be used current instance. You can use `${data.number}` pattern in the text. */ + /** check if the instance is alive. If not defined, it will be used current instance. You can use the ` $ {data.number} ` pattern in the text. */ instance?: string; - /** default text is `Instance %s is alive`, where %s will be replaced by `ADAPTER.0`. The translation must exist in i18n files. */ + /** the default text is `Instance %s is alive`, where %s will be replaced by `ADAPTER.0`. The translation must exist in i18n files. */ textAlive?: string; - /** default text is `Instance %s is not alive`, where %s will be replaced by `ADAPTER.0`. The translation must exist in i18n files. */ + /** the default text is `Instance %s is not alive`, where %s will be replaced by `ADAPTER.0`. The translation must exist in i18n files. */ textNotAlive?: string; } @@ -337,17 +346,17 @@ export interface ConfigItemTabs extends ConfigItem { export interface ConfigItemText extends ConfigItem { type: 'text'; - /** max length of the text in field */ + /** max length of the text in the field */ maxLength?: number; /** @deprecated use maxLength */ max?: number; /** read-only field */ readOnly?: boolean; - /** show copy to clipboard button, but only if disabled or read-only */ + /** show copy-to-clipboard button, but only if disabled or read-only */ copyToClipboard?: boolean; /** default is true. Set this attribute to `false` if trim is not desired. */ trim?: boolean; - /** default is 1. Set this attribute to `2` or more if you want to have a textarea with more than one row. */ + /** the default is 1. Set this attribute to `2` or more if you want to have a textarea with more than one row. */ minRows?: number; /** max rows of textarea. Used only if `minRows` > 1. */ maxRows?: number; @@ -414,7 +423,7 @@ export interface ConfigItemPassword extends ConfigItem { visible?: boolean; /** The read-only flag. Visible is automatically true if readOnly is true */ readOnly?: boolean; - /** max length of the text in field */ + /** max length of the text in the field */ maxLength?: number; /** @deprecated use maxLength */ max?: number; @@ -423,6 +432,7 @@ export interface ConfigItemPassword extends ConfigItem { export interface ConfigItemObjectId extends ConfigItem { type: 'objectId'; /** Desired type: `channel`, `device`, ... (has only `state` by default). It is plural, because `type` is already occupied. */ + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents types?: ObjectBrowserType | ObjectBrowserType[]; /** Show only this root object and its children */ root?: string; @@ -465,7 +475,7 @@ export interface ConfigItemSlider extends ConfigItem { export interface ConfigItemTopic extends ConfigItem { type: 'topic'; - /** max length of the text in field */ + /** max length of the text in the field */ maxLength?: number; /** @deprecated use maxLength */ max?: number; @@ -515,11 +525,11 @@ export interface ConfigItemStaticText extends Omit { label?: ioBroker.StringOrTranslated; /** link. Link could be dynamic like `#tab-objects/customs/${data.parentId} */ href?: string; - /** target of the link: _self, _blank or window name. For relative links default is _self and for absolute - _blank */ + /** target of the link: _self, _blank or window name. For relative links the default is _self and for absolute - _blank */ target?: string; /** If the GUI should be closed after a link was opened (only if the target is equal to '_self') */ close?: boolean; - /** show a link as button */ + /** show a link as a button */ button?: boolean; /** type of button (`outlined`, `contained`, `text`) */ variant?: 'contained' | 'outlined' | 'text'; @@ -541,13 +551,13 @@ export interface ConfigItemStaticInfo extends Omit { unit?: ioBroker.StringOrTranslated; /** Normally, the title and value are shown on the left and right of the line. With this flag, the value will appear just after the label*/ narrow?: boolean; - /** Add to label the colon at the end if not exist in label */ + /** Add to the label the colon at the end if not exist in the label */ addColon?: boolean; /** Value should blink when updated (true or color) */ blinkOnUpdate?: boolean | string; /** Value should blink continuously (true or color) */ blink?: boolean | string; - /** Show copy to clipboard button for value */ + /** Show a copy-to-clipboard button for value */ copyToClipboard?: boolean; /** Label style */ styleLabel?: CustomCSSProperties; @@ -559,7 +569,7 @@ export interface ConfigItemStaticInfo extends Omit { size?: number | 'small' | 'normal' | 'large'; /** Highlight line on mouse over */ highlight?: boolean; - /** Show boolean values as checkbox */ + /** Show boolean values as a checkbox */ booleanAsCheckbox?: boolean; /** Show string values as HTML */ html?: boolean; @@ -612,7 +622,7 @@ export interface ConfigItemSelect extends ConfigItem { attr?: string; /** If multiple selection is possible. In this case, the value will be an array */ multiple?: boolean; - /** show item even if no label was found for it (by multiple), default=`true` */ + /** show an item even if no label was found for it (by multiple), default=`true` */ showAllValues?: boolean; } @@ -630,7 +640,7 @@ export interface ConfigItemSetState extends ConfigItem { ack?: boolean; /** '${data.myText}_test' or number. Type will be detected automatically from the state type and converting done too */ val: ioBroker.StateValue; - /** Alert which will be shown by pressing the button */ + /** Alert, which will be shown by pressing the button */ okText?: ioBroker.StringOrTranslated; variant?: 'contained' | 'outlined'; color?: 'primary' | 'secondary' | 'grey'; @@ -645,7 +655,7 @@ export interface ConfigItemAutocompleteSendTo extends Omit { options?: (string | ConfigItemSelectOption)[]; data?: Record; freeSolo?: boolean; - /** max length of the text in field */ + /** max length of the text in the field */ maxLength?: number; /** @deprecated use maxLength */ max?: string; @@ -658,7 +668,7 @@ export interface ConfigItemAccordion extends ConfigItem { titleAttr?: string; /** If delete or add disabled, If noDelete is false, add, delete and move up/down should work */ noDelete?: boolean; - /** If clone button should be shown. If true, the clone button will be shown. If attribute name, this name will be unique. */ + /** If the clone button should be shown. If true, the clone button will be shown. If attribute name, this name will be unique. */ clone?: boolean | string; /** Items of accordion */ items: ConfigItemIndexed[]; @@ -689,7 +699,7 @@ export interface ConfigItemCoordinates extends ConfigItem { latitudeName?: string; /** if defined, the checkbox with "Use system settings" will be shown and latitude, longitude will be read from system.config, a boolean will be saved to the given name */ useSystemName?: string; - /** max length of the text in field */ + /** max length of the text in the field */ maxLength?: number; /** @deprecated use maxLength */ max?: number; @@ -711,7 +721,7 @@ export interface ConfigItemCustom extends ConfigItem { export interface ConfigItemDatePicker extends ConfigItem { type: 'datePicker'; - /** max length of the text in field */ + /** max length of the text in the field */ maxLength?: number; /** @deprecated use maxLength */ max?: number; @@ -734,6 +744,28 @@ export interface ConfigItemPort extends ConfigItem { readOnly?: boolean; } +export interface ConfigItemIFrame extends ConfigItem { + type: 'iframe'; + /* If URL is defined, the data attribute will be ignored. For static URLs */ + url?: string; + sandbox?: string; + allowFullscreen?: boolean; + frameBorder?: number | string; + /** if true, the iframe will be loaded only when it becomes visible */ + lazyLoad?: 'lazy' | 'eager'; + /** if true, the iframe will be reloaded every time it becomes visible */ + reloadOnShow?: boolean; + /** CSS Styles in React format (`marginLeft` and not `margin-left`) for the IFrame component */ + innerStyle?: CustomCSSProperties; +} + +export interface ConfigItemIFrameSendTo extends Omit { + type: 'iframeSendTo'; + command?: string; + alsoDependsOn?: string[]; + data?: Record; +} + export interface ConfigItemImageSendTo extends Omit { type: 'imageSendTo'; command?: string; @@ -747,8 +779,10 @@ export interface ConfigItemSendTo extends Omit { command?: string; jsonData?: string; data?: Record; - result?: string; - error?: string; + /** Translations for possible result codes. E.g. `{"RESULT_OK": "Operation successful"}`. The translation must exist in i18n files. */ + result?: Record; + /** Translations for possible error codes. E.g. `{"ERR_NO_OBJECT": "Object not found"}`. The translation must exist in i18n files. */ + error?: Record; variant?: 'contained' | 'outlined'; openUrl?: boolean; reloadBrowser?: boolean; @@ -764,13 +798,13 @@ export interface ConfigItemSendTo extends Omit { alsoDependsOn?: string[]; container?: 'text' | 'div' | 'html'; copyToClipboard?: boolean; - /** Styles for button itself */ + /** Styles for the button itself */ controlStyle?: CustomCSSProperties; } export interface ConfigItemState extends ConfigItem { type: 'state'; - /** Describes, which object ID should be taken for the controlling. The ID is without `ADAPTER.I.` prefix */ + /** Describes which object ID should be taken for the controlling. The ID is without `ADAPTER.I.` prefix */ oid: string; /** The `oid` is absolute and no need to add `ADAPTER.I` or `system.adapter.ADAPTER.I.` to oid */ foreign?: boolean; @@ -780,7 +814,7 @@ export interface ConfigItemState extends ConfigItem { control?: 'text' | 'html' | 'input' | 'slider' | 'select' | 'button' | 'switch' | 'number'; /** If true, the state will be shown as switch, select, button, slider or text input. Used only if no control property is defined */ controlled?: boolean; - /** Add unit to the value */ + /** Add a unit to the value */ unit?: string; /** this text will be shown if the value is true */ trueText?: string; @@ -810,15 +844,15 @@ export interface ConfigItemState extends ConfigItem { labelIcon?: string; /** Normally, the title and value are shown on the left and right of the line. With this flag, the value will appear just after the label*/ narrow?: boolean; - /** Add to label the colon at the end if not exist in label */ + /** Add to the label the colon at the end if not exist in the label */ addColon?: boolean; /** Value should blink when updated (true or color) */ blinkOnUpdate?: boolean | string; /** Font size */ size?: number | 'small' | 'normal' | 'large'; - /** Optional value, that will be sent for button */ + /** Optional value that will be sent for the button */ buttonValue?: ioBroker.StateValue; - /** Show SET button. The value in this case will be sent only when the button is pressed. You can define the text of the button. Default text is "Set" */ + /** Show the SET button. The value in this case will be sent only when the button is pressed. You can define the text of the button. The default text is "Set" */ showEnterButton?: boolean | ioBroker.StringOrTranslated; /** The value in this case will be sent only when the "Enter" button is pressed. It can be combined with `showEnterButton` */ setOnEnterKey?: boolean; @@ -829,7 +863,7 @@ export interface ConfigItemState extends ConfigItem { export interface ConfigItemTextSendTo extends Omit { type: 'textSendTo'; container?: 'text' | 'div'; - /** if true - show copy to clipboard button */ + /** if true - show copy-to-clipboard button */ copyToClipboard?: boolean; /** by change of which attributes, the command must be resent */ alsoDependsOn?: string[]; @@ -843,11 +877,11 @@ export interface ConfigItemTextSendTo extends Omit { export interface ConfigItemSelectSendTo extends Omit { type: 'selectSendTo'; - /** allow manual editing. Without drop-down menu (if instance is offline). Default `true`. */ + /** allow manual editing. Without a drop-down menu (if the instance is offline). Default `true`. */ manual?: boolean; /** Multiple choice select */ multiple?: boolean; - /** show item even if no label was found for it (by multiple), default=`true` */ + /** show an item even if no label was found for it (by multiple), default=`true` */ showAllValues?: boolean; /** if true, the clear button will not be shown */ noClearButton?: boolean; @@ -864,21 +898,23 @@ export interface ConfigItemSelectSendTo extends Omit { export interface ConfigItemTable extends ConfigItem { type: 'table'; items?: ConfigItemTableIndexed[]; + /** Define the name of the attribute of the item which should be shown as a title of the item in cards mode. */ + titleAttribute?: string; /** If delete or add disabled, If noDelete is false, add, delete and move up/down should work */ noDelete?: boolean; /** @deprecated don't use */ objKeyName?: string; /** @deprecated don't use */ objValueName?: string; - /** If add allowed even if filter is set */ + /** If add allowed even if a filter is set */ allowAddByFilter?: boolean; - /** The number of lines from which the second add button at the bottom of the table will be shown. Default 5 */ + /** The number of lines from which the second adding button at the bottom of the table will be shown. Default 5 */ showSecondAddAt?: number; - /** Show first plus button on top of the first column and not on the left. */ + /** Show the first plus button on top of the first column and not on the left. */ showFirstAddOnTop?: boolean; - /** If clone button should be shown. If true, the clone button will be shown. If attribute name, this name will be unique. */ + /** If the clone button should be shown. If true, the clone button will be shown. If attribute name, this name will be unique. */ clone?: boolean | string; - /** If export button should be shown. Export as csv file. */ + /** If export button should be shown. Export as a csv file. */ export?: boolean; /** If import button should be shown. Import from csv file. */ import?: boolean; @@ -886,7 +922,7 @@ export interface ConfigItemTable extends ConfigItem { compact?: boolean; /** Specify the 'attr' name of columns which need to be unique */ uniqueColumns?: string[]; - /** These items will be encrypted before saving with simple (not SHA) encryption method */ + /** These items will be encrypted before saving with a simple (not SHA) encryption method */ encryptedAttributes?: string[]; /** Breakpoint that will be rendered as cards */ useCardFor?: ('xs' | 'sm' | 'md' | 'lg' | 'xl')[]; @@ -972,7 +1008,19 @@ export interface ConfigItemJsonEditor extends ConfigItem { allowEmpty?: boolean; /** Allow JSON5 format. Default is disabled */ json5?: boolean; - /** Do not allow to save the value if error in JSON or JSON5 */ + /** Do not allow saving the value if error in JSON or JSON5 */ + doNotApplyWithError?: boolean; + /** Open the editor in read-only mode - editor can be opened but content cannot be modified */ + readOnly?: boolean; +} + +export interface ConfigItemYamlEditor extends ConfigItem { + type: 'yamlEditor'; + /** if false, the text will be not validated as YAML */ + validateYaml?: boolean; + /** if true, the YAML will be validated only if the value is not empty */ + allowEmpty?: boolean; + /** Do not allow saving the value if error in YAML */ doNotApplyWithError?: boolean; /** Open the editor in read-only mode - editor can be opened but content cannot be modified */ readOnly?: boolean; @@ -988,7 +1036,7 @@ export interface ConfigItemInterface extends ConfigItem { export interface ConfigItemImageUpload extends ConfigItem { type: 'image'; - /** name of a file is structure name. In the below example `login-bg.png` is file name for `writeFile("myAdapter.INSTANCE", "login-bg.png")` */ + /** the name of a file is the structure name. In the below example `login-bg.png` is the file name for `writeFile("myAdapter.INSTANCE", "login-bg.png")` */ filename?: string; /** HTML accept attribute, like `{ 'image/**': [], 'application/pdf': ['.pdf'] }`, default `{ 'image/*': [] }` */ accept?: Record; @@ -996,17 +1044,17 @@ export interface ConfigItemImageUpload extends ConfigItem { maxSize?: number; /** if true, the image will be saved as data-url in attribute, elsewise as binary in file storage */ base64?: boolean; - /** if true, allow user to crop the image */ + /** if true, allow the user to crop the image */ crop?: boolean; } export interface ConfigItemInstanceSelect extends ConfigItem { type: 'instance'; - /** name of adapter. With special name `_dataSources` you can get all adapters with flag `common.getHistory`. */ + /** name of adapter. With the special name `_dataSources` you can get all adapters with the flag `common.getHistory`. */ adapter?: string; - /** optional list of adapters, that should be shown. If not defined, all adapters will be shown. Only active if `adapter` attribute is not defined. */ + /** optional list of adapters that should be shown. If not defined, all adapters will be shown. Only active if the ` adapter ` attribute is not defined. */ adapters?: string[]; - /** if true. Additional option "deactivate" is shown */ + /** if true. The additional option "deactivate" is shown */ allowDeactivate?: boolean; /** if true. Only enabled instances will be shown */ onlyEnabled?: boolean; @@ -1020,7 +1068,7 @@ export interface ConfigItemInstanceSelect extends ConfigItem { export interface ConfigItemFile extends ConfigItem { type: 'file'; - /** if a user can manually enter the file name and not only through select dialog */ + /** if a user can manually enter the file name and not only through a select dialog */ disableEdit?: boolean; /** limit selection to one specific object of type `meta` and the following path (not mandatory) */ limitPath?: string; @@ -1036,7 +1084,7 @@ export interface ConfigItemFile extends ConfigItem { allowView?: boolean; /** show toolbar (default true) */ showToolbar?: boolean; - /** user can select only folders (e.g., for uploading path) */ + /** user can select only folders (e.g., for the uploading path) */ selectOnlyFolders?: boolean; /** trim the filename */ trim?: boolean; @@ -1048,15 +1096,15 @@ export interface ConfigItemFile extends ConfigItem { export interface ConfigItemFileSelector extends ConfigItem { type: 'fileSelector'; - /** File extension pattern. Allowed `**\/*.ext` to show all files from subfolders too, `*.ext` to show from root folder or `folderName\/*.ext` to show all files in sub-folder `folderName`. Default `**\/*.*`. */ + /** File extension pattern. Allowed `**\/*.ext` to show all files from subfolders too, `*.ext` to show from the root folder or `folderName\/*.ext` to show all files in subfolder `folderName`. Default `**\/*.*`. */ pattern: string; /** type of files: `audio`, `image`, `text` */ fileTypes?: 'audio' | 'image' | 'text'; - /** Object ID of type `meta`. You can use special placeholder `%INSTANCE%`: like `myAdapter.%INSTANCE%.files` */ + /** Object ID of the type `meta`. You can use special placeholder `%INSTANCE%`: like `myAdapter.%INSTANCE%.files` */ objectID?: string; /** path, where the uploaded files will be stored. Like `folderName`. If not defined, no upload field will be shown. To upload in the root, set this field to `/`. */ upload?: string; - /** Show refresh button near the select. */ + /** Show the refresh button near the select. */ refresh?: boolean; /** max file size (default 2MB) */ maxSize?: number; @@ -1114,11 +1162,14 @@ export type ConfigItemAny = | ConfigItemCRON | ConfigItemFile | ConfigItemFileSelector + | ConfigItemIFrame + | ConfigItemIFrameSendTo | ConfigItemImageSendTo | ConfigItemInstanceSelect | ConfigItemImageUpload | ConfigItemInterface | ConfigItemJsonEditor + | ConfigItemYamlEditor | ConfigItemLicense | ConfigItemPassword | ConfigItemSetState @@ -1131,52 +1182,6 @@ export type ConfigItemAny = | ConfigItemObjectId | ConfigItemQrCode; -// Notification GUI - -export type BackEndCommandType = 'nop' | 'refresh' | 'link' | 'message'; - -export interface BackEndCommandGeneric { - command: BackEndCommandType; - /** New GUI schema */ - schema?: ConfigItemPanel | ConfigItemTabs; - /** New GUI data */ - data?: Record; - refresh?: boolean; -} - -export interface BackEndCommandNoOperation extends BackEndCommandGeneric { - command: 'nop'; -} - -export interface BackEndCommandRefresh extends BackEndCommandGeneric { - command: 'refresh'; - /** If refresh the GUI */ - fullRefresh?: boolean; -} - -export interface BackEndCommandOpenLink extends BackEndCommandGeneric { - command: 'link'; - /** Link url. Could be relative ('#blabla') or absolute ('https://blabla') */ - url: string; - /** Target of the link. Default is `_self` for relative and '_blank' for absolute links */ - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - target?: '_self' | '_blank' | string; - /** If GUI should be closed after the link was opened (Only for target='_self') */ - close?: boolean; -} - -export interface BackEndCommandMessage extends BackEndCommandGeneric { - command: 'message'; - /** Message text */ - message: ioBroker.StringOrTranslated; - /** If GUI should be closed after the message shown */ - close?: boolean; - /** Type of message. Default is 'popup' */ - variant: 'popup' | 'dialog'; -} - -export type BackEndCommand = BackEndCommandMessage | BackEndCommandOpenLink | BackEndCommandRefresh; - // -- STOP OF DYNAMIC GENERATED CODE export type ActionButton = { @@ -1214,8 +1219,8 @@ export type JsonFormSchema = ConfigItemPanel | ConfigItemTabs; export type JsonFormData = Record; -export interface DeviceDetails { - id: string; +export interface DeviceDetails { + id: TId; schema: JsonFormSchema; data?: JsonFormData; } diff --git a/src/types/index.ts b/src/types/index.ts index 401a58a..b83243a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,15 @@ export type * from './adapter'; +export { + ACTIONS, + type ChannelInfo, + type Color, + type ControlBase, + type ControlState, + type BackendToGuiCommand, + type BackendToGuiCommandAllUpdate, + type BackendToGuiCommandDeviceDelete, + type BackendToGuiCommandDeviceStatusUpdate, + type BackendToGuiCommandDeviceInfoUpdate, +} from './base'; export type * from './common'; export * from './errorCodes'; -export { type ChannelInfo, type Color, type ControlState, type ControlBase, ACTIONS } from './base'; diff --git a/tasks.js b/tasks.ts similarity index 51% rename from tasks.js rename to tasks.ts index c9ddca0..0e2ac50 100644 --- a/tasks.js +++ b/tasks.ts @@ -1,18 +1,17 @@ -const { readFileSync, writeFileSync } = require('node:fs'); -const axios = require('axios'); -const COMMON_FILENAME = `${__dirname}/src/types/common.ts`; -const TYPES_URL = 'https://raw.githubusercontent.com/ioBroker/ioBroker.admin/master/packages/jsonConfig/src/types.d.ts'; - -async function patchCommonTs() { - let text = readFileSync(COMMON_FILENAME).toString(); - const response = await axios.get( - 'https://raw.githubusercontent.com/ioBroker/ioBroker.admin/master/packages/jsonConfig/src/types.d.ts', - ); - const typeLines = response.data.toString().split('\n'); - - const lines = text.split('\n'); - const start = []; - const end = []; +import { readFileSync, writeFileSync } from 'node:fs'; +import axios from 'axios'; + +const COMMON_FILENAME: string = `${process.cwd()}/src/types/common.ts`; +const TYPES_URL: string = 'https://raw.githubusercontent.com/ioBroker/json-config/main/src/types.d.ts'; + +async function patchCommonTs(): Promise { + const text: string = readFileSync(COMMON_FILENAME, 'utf-8'); + const response = await axios.get(TYPES_URL); + const typeLines: string[] = response.data.split('\n'); + + const lines: string[] = text.split('\n'); + const start: string[] = []; + const end: string[] = []; let found = 0; for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('// -- START OF DYNAMIC GENERATED CODE')) { @@ -43,7 +42,17 @@ async function patchCommonTs() { } typeLines.splice(0, f); + while (!typeLines[f].startsWith('export type JsonConfigContext =')) { + f++; + } + if (f >= typeLines.length) { + console.error(`Cannot find "export type JsonConfigContext =" in ${TYPES_URL})`); + process.exit(2); + } + typeLines.splice(f); + writeFileSync(COMMON_FILENAME, `${start.join('\n')}\n${typeLines.join('\n')}\n${end.join('\n')}`); } -patchCommonTs().catch(err => console.error(`Error: ${err}`)); +patchCommonTs().catch((err: unknown) => console.error(`Error: ${err}`)); +