From 39c89a7f9458c509b45f94aaf3801fbbd12bbb2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 22:45:32 +0000 Subject: [PATCH] Add devices-zigbee-herdsman plugin for direct Zigbee device integration Self-contained Zigbee device integration via zigbee-herdsman library. Communicates directly with USB and network-attached Zigbee coordinators (CC2652, ConBee, SLZB-06/07, etc.) without external dependencies. Backend plugin (NestJS): - ZigbeeHerdsmanAdapterService wrapping zigbee-herdsman Controller - ZigbeeHerdsmanService with IManagedPluginService lifecycle - fromZigbee message processing with property value writes - Mapping preview and device adoption services - Device platform handler with toZigbee converter dispatch - Device connectivity monitoring with offline detection - REST controller with Swagger-annotated endpoints - Config validator supporting USB serial and TCP paths - Entities with Zigbee-specific columns and TypeORM migration - Fix: shelly-ng resetReconnectInterval type error - Fix: missing @influxdata/influxdb-client dependency Admin UI (Vue.js): - Plugin registration with config and device type elements - Config form, device add/edit forms, store schemas, locales https://claude.ai/code/session_014bjB9Cn1WKASNLBeCuSbom --- apps/admin/src/app.main.ts | 2 + .../components/components.ts | 5 + .../components/types.ts | 3 + .../zigbee-herdsman-config-form.types.ts | 11 + .../zigbee-herdsman-config-form.vue | 339 ++++++++++ .../zigbee-herdsman-device-add-form.types.ts | 12 + .../zigbee-herdsman-device-add-form.vue | 182 ++++++ .../zigbee-herdsman-device-edit-form.types.ts | 9 + .../zigbee-herdsman-device-edit-form.vue | 171 +++++ .../composables/composables.ts | 1 + .../devices-zigbee-herdsman.constants.ts | 5 + .../devices-zigbee-herdsman.exceptions.ts | 13 + .../devices-zigbee-herdsman.plugin.ts | 99 +++ .../plugins/devices-zigbee-herdsman/index.ts | 12 + .../locales/en-US.json | 142 ++++ .../devices-zigbee-herdsman/locales/index.ts | 5 + .../schemas/config.schemas.ts | 22 + .../schemas/config.types.ts | 5 + .../schemas/devices.schemas.ts | 5 + .../schemas/devices.types.ts | 6 + .../schemas/schemas.ts | 3 + .../devices-zigbee-herdsman/schemas/types.ts | 2 + .../channels.properties.store.schemas.ts | 32 + .../store/channels.store.schemas.ts | 27 + .../store/config.store.schemas.ts | 76 +++ .../store/devices.store.schemas.ts | 29 + .../store/devices.store.types.ts | 5 + .../devices-zigbee-herdsman/store/keys.ts | 1 + .../devices-zigbee-herdsman/store/stores.ts | 6 + apps/backend/package.json | 364 +++++------ apps/backend/src/app.module.ts | 7 + .../1743400000000-AddZigbeeHerdsmanColumns.ts | 50 ++ .../delegates/shelly-device.delegate.ts | 45 +- ...-herdsman-discovered-devices.controller.ts | 346 ++++++++++ .../devices-zigbee-herdsman.constants.ts | 552 ++++++++++++++++ .../devices-zigbee-herdsman.exceptions.ts | 113 ++++ .../devices-zigbee-herdsman.openapi.ts | 105 +++ .../devices-zigbee-herdsman.plugin.ts | 256 ++++++++ .../dto/create-channel-property.dto.ts | 20 + .../dto/create-channel.dto.ts | 20 + .../dto/create-device.dto.ts | 20 + .../dto/mapping-preview.dto.ts | 254 ++++++++ .../dto/update-channel-property.dto.ts | 20 + .../dto/update-channel.dto.ts | 20 + .../dto/update-config.dto.ts | 183 ++++++ .../dto/update-device.dto.ts | 20 + .../devices-zigbee-herdsman.entity.ts | 171 +++++ .../plugins/devices-zigbee-herdsman/index.ts | 17 + .../interfaces/zigbee-herdsman.interface.ts | 195 ++++++ .../models/config.model.ts | 205 ++++++ .../models/zigbee-herdsman-response.model.ts | 590 +++++++++++++++++ .../zigbee-herdsman.device.platform.ts | 217 ++++++ .../services/device-adoption.service.ts | 258 ++++++++ .../device-connectivity.service.spec.ts | 101 +++ .../services/device-connectivity.service.ts | 130 ++++ .../services/mapping-preview.service.spec.ts | 110 ++++ .../services/mapping-preview.service.ts | 333 ++++++++++ .../zigbee-herdsman-adapter.service.spec.ts | 74 +++ .../zigbee-herdsman-adapter.service.ts | 505 ++++++++++++++ ...igbee-herdsman-config-validator.service.ts | 77 +++ .../services/zigbee-herdsman.service.ts | 344 ++++++++++ pnpm-lock.yaml | 615 ++++++++++++------ .../FEATURE-PLUGIN-ZIGBEE-HERDSMAN.md | 86 +-- 63 files changed, 7185 insertions(+), 468 deletions(-) create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/components/components.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/components/types.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-config-form.types.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-config-form.vue create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-add-form.types.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-add-form.vue create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-edit-form.types.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-edit-form.vue create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/composables/composables.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.constants.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.exceptions.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.plugin.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/index.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/locales/en-US.json create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/locales/index.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/schemas/config.schemas.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/schemas/config.types.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/schemas/devices.schemas.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/schemas/devices.types.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/schemas/schemas.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/schemas/types.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/store/channels.properties.store.schemas.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/store/channels.store.schemas.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/store/config.store.schemas.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/store/devices.store.schemas.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/store/devices.store.types.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/store/keys.ts create mode 100644 apps/admin/src/plugins/devices-zigbee-herdsman/store/stores.ts create mode 100644 apps/backend/src/migrations/1743400000000-AddZigbeeHerdsmanColumns.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/controllers/zigbee-herdsman-discovered-devices.controller.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.constants.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.exceptions.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.openapi.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.plugin.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-channel-property.dto.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-channel.dto.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-device.dto.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/dto/mapping-preview.dto.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-channel-property.dto.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-channel.dto.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-config.dto.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-device.dto.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/entities/devices-zigbee-herdsman.entity.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/index.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/interfaces/zigbee-herdsman.interface.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/models/config.model.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/models/zigbee-herdsman-response.model.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/platforms/zigbee-herdsman.device.platform.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/services/device-adoption.service.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/services/device-connectivity.service.spec.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/services/device-connectivity.service.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/services/mapping-preview.service.spec.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/services/mapping-preview.service.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-adapter.service.spec.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-adapter.service.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-config-validator.service.ts create mode 100644 apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman.service.ts diff --git a/apps/admin/src/app.main.ts b/apps/admin/src/app.main.ts index e6bca07114..5dc034b25c 100644 --- a/apps/admin/src/app.main.ts +++ b/apps/admin/src/app.main.ts @@ -78,6 +78,7 @@ import { DevicesShellyNgPlugin } from './plugins/devices-shelly-ng'; import { DevicesShellyV1Plugin } from './plugins/devices-shelly-v1'; import { DevicesThirdPartyPlugin } from './plugins/devices-third-party'; import { DevicesWledPlugin } from './plugins/devices-wled'; +import { DevicesZigbeeHerdsmanPlugin } from './plugins/devices-zigbee-herdsman'; import { DevicesZigbee2mqttPlugin } from './plugins/devices-zigbee2mqtt'; import { LoggerRotatingFilePlugin } from './plugins/logger-rotating-file'; import { PagesCardsPlugin } from './plugins/pages-cards'; @@ -204,6 +205,7 @@ app.use(DevicesShellyNgPlugin, pluginOptions); app.use(DevicesShellyV1Plugin, pluginOptions); app.use(SimulatorPlugin, pluginOptions); app.use(DevicesWledPlugin, pluginOptions); +app.use(DevicesZigbeeHerdsmanPlugin, pluginOptions); app.use(DevicesZigbee2mqttPlugin, pluginOptions); app.use(SpacesHomeControlPlugin, pluginOptions); app.use(SpacesSyntheticMasterPlugin, pluginOptions); diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/components/components.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/components/components.ts new file mode 100644 index 0000000000..5e31a9b6a4 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/components/components.ts @@ -0,0 +1,5 @@ +export { default as ZigbeeHerdsmanConfigForm } from './zigbee-herdsman-config-form.vue'; +export { default as ZigbeeHerdsmanDeviceAddForm } from './zigbee-herdsman-device-add-form.vue'; +export { default as ZigbeeHerdsmanDeviceEditForm } from './zigbee-herdsman-device-edit-form.vue'; + +export * from './types'; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/components/types.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/components/types.ts new file mode 100644 index 0000000000..cef6cb341c --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/components/types.ts @@ -0,0 +1,3 @@ +export * from './zigbee-herdsman-config-form.types'; +export * from './zigbee-herdsman-device-add-form.types'; +export * from './zigbee-herdsman-device-edit-form.types'; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-config-form.types.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-config-form.types.ts new file mode 100644 index 0000000000..6c40108781 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-config-form.types.ts @@ -0,0 +1,11 @@ +import type { FormResultType, LayoutType } from '../../../modules/config'; +import type { IConfigPlugin } from '../../../modules/config/store/config-plugins.store.types'; + +export interface IZigbeeHerdsmanConfigFormProps { + config: IConfigPlugin; + remoteFormSubmit?: boolean; + remoteFormResult?: FormResultType; + remoteFormReset?: boolean; + remoteFormChanged?: boolean; + layout?: LayoutType; +} diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-config-form.vue b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-config-form.vue new file mode 100644 index 0000000000..547e570c5b --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-config-form.vue @@ -0,0 +1,339 @@ + + + diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-add-form.types.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-add-form.types.ts new file mode 100644 index 0000000000..ef86e3110b --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-add-form.types.ts @@ -0,0 +1,12 @@ +import type { IPluginElement } from '../../../common'; +import type { FormResultType } from '../../../modules/devices'; +import type { IZigbeeHerdsmanDevice } from '../store/devices.store.types'; + +export interface IZigbeeHerdsmanDeviceAddFormProps { + id: IZigbeeHerdsmanDevice['id']; + type: IPluginElement['type']; + remoteFormSubmit?: boolean; + remoteFormResult?: FormResultType; + remoteFormReset?: boolean; + remoteFormChanged?: boolean; +} diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-add-form.vue b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-add-form.vue new file mode 100644 index 0000000000..9a660d4522 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-add-form.vue @@ -0,0 +1,182 @@ + + + diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-edit-form.types.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-edit-form.types.ts new file mode 100644 index 0000000000..6f038ab08c --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-edit-form.types.ts @@ -0,0 +1,9 @@ +import type { FormResultType, IDevice } from '../../../modules/devices'; + +export interface IZigbeeHerdsmanDeviceEditFormProps { + device: IDevice; + remoteFormSubmit?: boolean; + remoteFormResult?: FormResultType; + remoteFormReset?: boolean; + remoteFormChanged?: boolean; +} diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-edit-form.vue b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-edit-form.vue new file mode 100644 index 0000000000..a0f1dc1d15 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/components/zigbee-herdsman-device-edit-form.vue @@ -0,0 +1,171 @@ + + + diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/composables/composables.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/composables/composables.ts new file mode 100644 index 0000000000..ce4a8a411b --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/composables/composables.ts @@ -0,0 +1 @@ +// Composables will be added as needed diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.constants.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.constants.ts new file mode 100644 index 0000000000..9f23c35453 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.constants.ts @@ -0,0 +1,5 @@ +export const DEVICES_ZIGBEE_HERDSMAN_PLUGIN_PREFIX = 'devices-zigbee-herdsman'; + +export const DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME = 'devices-zigbee-herdsman-plugin'; + +export const DEVICES_ZIGBEE_HERDSMAN_TYPE = 'devices-zigbee-herdsman'; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.exceptions.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.exceptions.ts new file mode 100644 index 0000000000..3367650c08 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.exceptions.ts @@ -0,0 +1,13 @@ +export class DevicesZigbeeHerdsmanException extends Error {} + +export class DevicesZigbeeHerdsmanValidationException extends DevicesZigbeeHerdsmanException {} + +export class DevicesZigbeeHerdsmanApiException extends DevicesZigbeeHerdsmanException { + public code: number; + + constructor(message: string, code: number) { + super(message); + + this.code = code; + } +} diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.plugin.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.plugin.ts new file mode 100644 index 0000000000..abdb0e9aaa --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.plugin.ts @@ -0,0 +1,99 @@ +import type { App } from 'vue'; + +import { defaultsDeep } from 'lodash'; + +import type { IPluginOptions } from '../../app.types'; +import { type IPlugin, type PluginInjectionKey, injectPluginsManager } from '../../common'; +import { CONFIG_MODULE_NAME, CONFIG_MODULE_PLUGIN_TYPE, type IPluginsComponents, type IPluginsSchemas } from '../../modules/config'; +import { + DEVICES_MODULE_NAME, + type IChannelPluginsComponents, + type IChannelPluginsSchemas, + type IChannelPropertyPluginsComponents, + type IChannelPropertyPluginsSchemas, + type IDevicePluginsComponents, + type IDevicePluginsSchemas, +} from '../../modules/devices'; + +import { ZigbeeHerdsmanConfigForm, ZigbeeHerdsmanDeviceAddForm, ZigbeeHerdsmanDeviceEditForm } from './components/components'; +import { DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, DEVICES_ZIGBEE_HERDSMAN_TYPE } from './devices-zigbee-herdsman.constants'; +import { locales } from './locales'; +import { ZigbeeHerdsmanConfigEditFormSchema } from './schemas/config.schemas'; +import { ZigbeeHerdsmanDeviceAddFormSchema, ZigbeeHerdsmanDeviceEditFormSchema } from './schemas/devices.schemas'; +import { + ZigbeeHerdsmanChannelPropertyCreateReqSchema, + ZigbeeHerdsmanChannelPropertySchema, + ZigbeeHerdsmanChannelPropertyUpdateReqSchema, +} from './store/channels.properties.store.schemas'; +import { ZigbeeHerdsmanChannelCreateReqSchema, ZigbeeHerdsmanChannelSchema, ZigbeeHerdsmanChannelUpdateReqSchema } from './store/channels.store.schemas'; +import { ZigbeeHerdsmanConfigSchema, ZigbeeHerdsmanConfigUpdateReqSchema } from './store/config.store.schemas'; +import { ZigbeeHerdsmanDeviceCreateReqSchema, ZigbeeHerdsmanDeviceSchema, ZigbeeHerdsmanDeviceUpdateReqSchema } from './store/devices.store.schemas'; + +export const devicesZigbeeHerdsmanPluginKey: PluginInjectionKey< + IPlugin< + IDevicePluginsComponents & IChannelPluginsComponents & IChannelPropertyPluginsComponents & IPluginsComponents, + IDevicePluginsSchemas & IChannelPluginsSchemas & IChannelPropertyPluginsSchemas & IPluginsSchemas + > +> = Symbol('FB-Plugin-DevicesZigbeeHerdsman'); + +export default { + install: (app: App, options: IPluginOptions): void => { + const pluginsManager = injectPluginsManager(app); + + for (const [locale, translations] of Object.entries(locales)) { + const currentMessages = options.i18n.global.getLocaleMessage(locale); + const mergedMessages = defaultsDeep(currentMessages, { devicesZigbeeHerdsmanPlugin: translations }); + + options.i18n.global.setLocaleMessage(locale, mergedMessages); + } + + pluginsManager.addPlugin(devicesZigbeeHerdsmanPluginKey, { + type: DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + source: 'com.fastybird.smart-panel.plugin.devices-zigbee-herdsman', + name: 'Zigbee Herdsman', + description: 'Connect and control your Zigbee devices directly via Zigbee Herdsman from the FastyBird Smart Panel', + links: { + documentation: 'https://smart-panel.fastybird.com', + devDocumentation: 'https://smart-panel.fastybird.com', + bugsTracking: 'https://smart-panel.fastybird.com', + }, + elements: [ + { + type: CONFIG_MODULE_PLUGIN_TYPE, + components: { + pluginConfigEditForm: ZigbeeHerdsmanConfigForm, + }, + schemas: { + pluginConfigSchema: ZigbeeHerdsmanConfigSchema, + pluginConfigEditFormSchema: ZigbeeHerdsmanConfigEditFormSchema, + pluginConfigUpdateReqSchema: ZigbeeHerdsmanConfigUpdateReqSchema, + }, + modules: [CONFIG_MODULE_NAME], + }, + { + type: DEVICES_ZIGBEE_HERDSMAN_TYPE, + components: { + deviceAddForm: ZigbeeHerdsmanDeviceAddForm, + deviceEditForm: ZigbeeHerdsmanDeviceEditForm, + }, + schemas: { + deviceSchema: ZigbeeHerdsmanDeviceSchema, + deviceAddFormSchema: ZigbeeHerdsmanDeviceAddFormSchema, + deviceEditFormSchema: ZigbeeHerdsmanDeviceEditFormSchema, + deviceCreateReqSchema: ZigbeeHerdsmanDeviceCreateReqSchema, + deviceUpdateReqSchema: ZigbeeHerdsmanDeviceUpdateReqSchema, + channelSchema: ZigbeeHerdsmanChannelSchema, + channelCreateReqSchema: ZigbeeHerdsmanChannelCreateReqSchema, + channelUpdateReqSchema: ZigbeeHerdsmanChannelUpdateReqSchema, + channelPropertySchema: ZigbeeHerdsmanChannelPropertySchema, + channelPropertyCreateReqSchema: ZigbeeHerdsmanChannelPropertyCreateReqSchema, + channelPropertyUpdateReqSchema: ZigbeeHerdsmanChannelPropertyUpdateReqSchema, + }, + modules: [DEVICES_MODULE_NAME], + }, + ], + modules: [DEVICES_MODULE_NAME, CONFIG_MODULE_NAME], + isCore: true, + }); + }, +}; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/index.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/index.ts new file mode 100644 index 0000000000..e671b5039b --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/index.ts @@ -0,0 +1,12 @@ +import DevicesZigbeeHerdsmanPlugin from './devices-zigbee-herdsman.plugin'; + +// Plugin +export { DevicesZigbeeHerdsmanPlugin }; + +export * from './components/components'; +export * from './composables/composables'; +export * from './store/stores'; +export * from './schemas/schemas'; + +export * from './devices-zigbee-herdsman.constants'; +export * from './devices-zigbee-herdsman.exceptions'; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/locales/en-US.json b/apps/admin/src/plugins/devices-zigbee-herdsman/locales/en-US.json new file mode 100644 index 0000000000..2e2c576f67 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/locales/en-US.json @@ -0,0 +1,142 @@ +{ + "headings": { + "aboutPluginStatus": "Plugin status", + "aboutSerial": "Serial port configuration", + "aboutNetwork": "Network settings", + "aboutDiscovery": "Discovery settings", + "aboutDatabase": "Database", + "device": { + "deviceInfo": "Device information" + } + }, + "texts": { + "aboutPluginStatus": "Enable or disable the Zigbee Herdsman plugin. When disabled, no Zigbee devices will be monitored or controlled.", + "aboutSerial": "Configure the connection to your Zigbee coordinator. Supports USB dongles (e.g. /dev/ttyUSB0) and network adapters (e.g. tcp://192.168.1.100:6638 for SLZB-06/07).", + "aboutNetwork": "Configure Zigbee network settings.", + "aboutDiscovery": "Configure how devices are discovered and managed on the Zigbee network.", + "aboutDatabase": "Configure the path where the Zigbee network database is stored." + }, + "fields": { + "config": { + "enabled": { + "title": "Enabled" + }, + "serial": { + "path": { + "title": "Coordinator path", + "placeholder": "e.g., /dev/ttyUSB0 or tcp://192.168.1.100:6638", + "validation": { + "required": "Serial port path is required" + } + }, + "baudRate": { + "title": "Baud rate", + "validation": { + "required": "Baud rate is required", + "number": "Must be a positive integer" + } + }, + "adapterType": { + "title": "Adapter type", + "placeholder": "e.g., zstack, deconz, zigate, ezsp", + "validation": { + "required": "Adapter type is required" + } + } + }, + "network": { + "channel": { + "title": "Channel", + "validation": { + "range": "Channel must be between 11 and 26" + } + } + }, + "discovery": { + "permitJoinTimeout": { + "title": "Permit join timeout (s)", + "validation": { + "number": "Must be a number >= 0" + } + }, + "mainsDeviceTimeout": { + "title": "Mains device timeout (s)", + "validation": { + "number": "Must be a number >= 0" + } + }, + "batteryDeviceTimeout": { + "title": "Battery device timeout (s)", + "validation": { + "number": "Must be a number >= 0" + } + }, + "commandRetries": { + "title": "Command retries", + "validation": { + "number": "Must be a number >= 0" + } + }, + "syncOnStartup": { + "title": "Sync devices on startup" + } + }, + "databasePath": { + "title": "Database path", + "placeholder": "e.g., /data/zigbee.db", + "validation": { + "required": "Database path is required" + } + } + }, + "devices": { + "id": { + "title": "Device ID", + "placeholder": "Device ID" + }, + "name": { + "title": "Name", + "placeholder": "Device name", + "validation": { + "required": "Please fill in device name" + } + }, + "category": { + "title": "Category", + "placeholder": "Device category", + "validation": { + "required": "Please select device category" + } + }, + "description": { + "title": "Description", + "placeholder": "Device description" + }, + "enabled": { + "title": "Enabled" + }, + "ieeeAddress": { + "title": "IEEE address", + "placeholder": "Enter IEEE address (e.g., 0x00158d0001234567)", + "validation": { + "required": "Please enter an IEEE address" + } + } + } + }, + "messages": { + "config": { + "edited": "Zigbee Herdsman plugin configuration has been updated", + "notEdited": "Failed to update Zigbee Herdsman plugin configuration" + }, + "devices": { + "created": "Zigbee Herdsman device '{device}' has been created", + "notCreated": "Failed to create Zigbee Herdsman device '{device}'", + "edited": "Zigbee Herdsman device '{device}' has been updated", + "notEdited": "Failed to update Zigbee Herdsman device '{device}'" + } + }, + "buttons": { + "save": "Save" + } +} diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/locales/index.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/locales/index.ts new file mode 100644 index 0000000000..6cc732e5c3 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/locales/index.ts @@ -0,0 +1,5 @@ +import enUS from './en-US.json'; + +export const locales: Record> = { + 'en-US': enUS, +}; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/config.schemas.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/config.schemas.ts new file mode 100644 index 0000000000..d1c4404b94 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/config.schemas.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +import { ConfigPluginEditFormSchema } from '../../../modules/config'; + +export const ZigbeeHerdsmanConfigEditFormSchema = ConfigPluginEditFormSchema.extend({ + serial: z.object({ + path: z.string().trim().min(1), + baudRate: z.coerce.number().int().min(1), + adapterType: z.string().trim().min(1), + }), + network: z.object({ + channel: z.coerce.number().int().min(11).max(26), + }), + discovery: z.object({ + permitJoinTimeout: z.coerce.number().int().min(0), + mainsDeviceTimeout: z.coerce.number().int().min(60), + batteryDeviceTimeout: z.coerce.number().int().min(60), + commandRetries: z.coerce.number().int().min(1), + syncOnStartup: z.boolean(), + }), + databasePath: z.string().trim().min(1), +}); diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/config.types.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/config.types.ts new file mode 100644 index 0000000000..aa8c037b8e --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/config.types.ts @@ -0,0 +1,5 @@ +import type { z } from 'zod'; + +import type { ZigbeeHerdsmanConfigEditFormSchema } from './config.schemas'; + +export type IZigbeeHerdsmanConfigEditForm = z.infer; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/devices.schemas.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/devices.schemas.ts new file mode 100644 index 0000000000..05e053cf4f --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/devices.schemas.ts @@ -0,0 +1,5 @@ +import { DeviceAddFormSchema, DeviceEditFormSchema } from '../../../modules/devices'; + +export const ZigbeeHerdsmanDeviceAddFormSchema = DeviceAddFormSchema; + +export const ZigbeeHerdsmanDeviceEditFormSchema = DeviceEditFormSchema; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/devices.types.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/devices.types.ts new file mode 100644 index 0000000000..341e0b691b --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/devices.types.ts @@ -0,0 +1,6 @@ +import type { z } from 'zod'; + +import type { ZigbeeHerdsmanDeviceAddFormSchema, ZigbeeHerdsmanDeviceEditFormSchema } from './devices.schemas'; + +export type IZigbeeHerdsmanDeviceAddForm = z.infer; +export type IZigbeeHerdsmanDeviceEditForm = z.infer; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/schemas.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/schemas.ts new file mode 100644 index 0000000000..062fa9efee --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/schemas.ts @@ -0,0 +1,3 @@ +export * from './config.schemas'; +export * from './devices.schemas'; +export * from './types'; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/types.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/types.ts new file mode 100644 index 0000000000..05cfe01aa8 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/schemas/types.ts @@ -0,0 +1,2 @@ +export * from './config.types'; +export * from './devices.types'; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/store/channels.properties.store.schemas.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/store/channels.properties.store.schemas.ts new file mode 100644 index 0000000000..a81cd28536 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/store/channels.properties.store.schemas.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +import { + ChannelPropertyCreateReqSchema, + ChannelPropertyResSchema, + ChannelPropertySchema, + ChannelPropertyUpdateReqSchema, +} from '../../../modules/devices'; +import { DEVICES_ZIGBEE_HERDSMAN_TYPE } from '../devices-zigbee-herdsman.constants'; + +export const ZigbeeHerdsmanChannelPropertySchema = ChannelPropertySchema; + +// BACKEND API +// =========== + +export const ZigbeeHerdsmanChannelPropertyCreateReqSchema = ChannelPropertyCreateReqSchema.and( + z.object({ + type: z.literal(DEVICES_ZIGBEE_HERDSMAN_TYPE), + }) +); + +export const ZigbeeHerdsmanChannelPropertyUpdateReqSchema = ChannelPropertyUpdateReqSchema.and( + z.object({ + type: z.literal(DEVICES_ZIGBEE_HERDSMAN_TYPE), + }) +); + +export const ZigbeeHerdsmanChannelPropertyResSchema = ChannelPropertyResSchema.and( + z.object({ + type: z.literal(DEVICES_ZIGBEE_HERDSMAN_TYPE), + }) +); diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/store/channels.store.schemas.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/store/channels.store.schemas.ts new file mode 100644 index 0000000000..69a2418a54 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/store/channels.store.schemas.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { ChannelCreateReqSchema, ChannelResSchema, ChannelSchema, ChannelUpdateReqSchema } from '../../../modules/devices'; +import { DEVICES_ZIGBEE_HERDSMAN_TYPE } from '../devices-zigbee-herdsman.constants'; + +export const ZigbeeHerdsmanChannelSchema = ChannelSchema; + +// BACKEND API +// =========== + +export const ZigbeeHerdsmanChannelCreateReqSchema = ChannelCreateReqSchema.and( + z.object({ + type: z.literal(DEVICES_ZIGBEE_HERDSMAN_TYPE), + }) +); + +export const ZigbeeHerdsmanChannelUpdateReqSchema = ChannelUpdateReqSchema.and( + z.object({ + type: z.literal(DEVICES_ZIGBEE_HERDSMAN_TYPE), + }) +); + +export const ZigbeeHerdsmanChannelResSchema = ChannelResSchema.and( + z.object({ + type: z.literal(DEVICES_ZIGBEE_HERDSMAN_TYPE), + }) +); diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/store/config.store.schemas.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/store/config.store.schemas.ts new file mode 100644 index 0000000000..848c656032 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/store/config.store.schemas.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; + +import { ConfigPluginResSchema, ConfigPluginSchema, ConfigPluginUpdateReqSchema } from '../../../modules/config/store/config-plugins.store.schemas'; +import { DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME } from '../devices-zigbee-herdsman.constants'; + +export const ZigbeeHerdsmanConfigSchema = ConfigPluginSchema.extend({ + serial: z.object({ + path: z.string(), + baudRate: z.number(), + adapterType: z.string(), + }), + network: z.object({ + channel: z.number(), + }), + discovery: z.object({ + permitJoinTimeout: z.number(), + mainsDeviceTimeout: z.number(), + batteryDeviceTimeout: z.number(), + commandRetries: z.number(), + syncOnStartup: z.boolean(), + }), + databasePath: z.string(), +}); + +// BACKEND API +// =========== + +export const ZigbeeHerdsmanConfigUpdateReqSchema = ConfigPluginUpdateReqSchema.and( + z.object({ + type: z.literal(DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME), + serial: z + .object({ + path: z.string().optional(), + baud_rate: z.number().optional(), + adapter_type: z.string().optional(), + }) + .optional(), + network: z + .object({ + channel: z.number().optional(), + }) + .optional(), + discovery: z + .object({ + permit_join_timeout: z.number().optional(), + mains_device_timeout: z.number().optional(), + battery_device_timeout: z.number().optional(), + command_retries: z.number().optional(), + sync_on_startup: z.boolean().optional(), + }) + .optional(), + database_path: z.string().optional(), + }) +); + +export const ZigbeeHerdsmanConfigResSchema = ConfigPluginResSchema.and( + z.object({ + type: z.literal(DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME), + serial: z.object({ + path: z.string(), + baud_rate: z.number(), + adapter_type: z.string(), + }), + network: z.object({ + channel: z.number(), + }), + discovery: z.object({ + permit_join_timeout: z.number(), + mains_device_timeout: z.number(), + battery_device_timeout: z.number(), + command_retries: z.number(), + sync_on_startup: z.boolean(), + }), + database_path: z.string(), + }) +); diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/store/devices.store.schemas.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/store/devices.store.schemas.ts new file mode 100644 index 0000000000..9d7ff0e5d7 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/store/devices.store.schemas.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import { DeviceCreateReqSchema, DeviceResSchema, DeviceSchema, DeviceUpdateReqSchema } from '../../../modules/devices'; +import { DevicesModuleDeviceCategory } from '../../../openapi.constants'; +import { DEVICES_ZIGBEE_HERDSMAN_TYPE } from '../devices-zigbee-herdsman.constants'; + +export const ZigbeeHerdsmanDeviceSchema = DeviceSchema; + +// BACKEND API +// =========== + +export const ZigbeeHerdsmanDeviceCreateReqSchema = DeviceCreateReqSchema.and( + z.object({ + type: z.literal(DEVICES_ZIGBEE_HERDSMAN_TYPE), + }) +); + +export const ZigbeeHerdsmanDeviceUpdateReqSchema = DeviceUpdateReqSchema.and( + z.object({ + type: z.literal(DEVICES_ZIGBEE_HERDSMAN_TYPE), + category: z.nativeEnum(DevicesModuleDeviceCategory).optional(), + }) +); + +export const ZigbeeHerdsmanDeviceResSchema = DeviceResSchema.and( + z.object({ + type: z.literal(DEVICES_ZIGBEE_HERDSMAN_TYPE), + }) +); diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/store/devices.store.types.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/store/devices.store.types.ts new file mode 100644 index 0000000000..18401b361a --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/store/devices.store.types.ts @@ -0,0 +1,5 @@ +import type { z } from 'zod'; + +import type { ZigbeeHerdsmanDeviceSchema } from './devices.store.schemas'; + +export type IZigbeeHerdsmanDevice = z.infer; diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/store/keys.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/store/keys.ts new file mode 100644 index 0000000000..3e829116c9 --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/store/keys.ts @@ -0,0 +1 @@ +// Store keys are registered in the plugin's install function diff --git a/apps/admin/src/plugins/devices-zigbee-herdsman/store/stores.ts b/apps/admin/src/plugins/devices-zigbee-herdsman/store/stores.ts new file mode 100644 index 0000000000..21357378cb --- /dev/null +++ b/apps/admin/src/plugins/devices-zigbee-herdsman/store/stores.ts @@ -0,0 +1,6 @@ +export * from './channels.properties.store.schemas'; +export * from './channels.store.schemas'; +export * from './config.store.schemas'; +export * from './devices.store.schemas'; +export * from './devices.store.types'; + diff --git a/apps/backend/package.json b/apps/backend/package.json index ec4ef9abc1..04b306e25b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -1,183 +1,183 @@ { - "name": "@fastybird/smart-panel-backend", - "version": "0.7.0-alpha.1", - "description": "Powerful backend for administrating and integrating the FastyBird IoT Smart Panel", - "keywords": [ - "fastybird", - "iot", - "smart-panel", - "backend", - "administration", - "iot-platform", - "device-management", - "home-automation", - "api", - "websockets", - "iot-backend", - "automation", - "smart-home", - "fastybird-platform", - "smart-devices", - "connected-devices", - "integration" - ], - "homepage": "https://smart-panel.fastybird.com", - "bugs": "https://github.com/FastyBird/smart-panel/issues", - "license": "Apache-2.0", - "author": { - "name": "Studio81 Labs s.r.o.", - "email": "dev@studio81.cz", - "url": "https://www.studio81.cz" - }, - "repository": { - "type": "git", - "url": "https://github.com/FastyBird/smart-panel.git" - }, - "main": "dist/main.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.js" - } - }, - "files": [ - "dist/", - "static/", - "package.json", - "README.md", - "LICENSE.md" - ], - "scripts": { - "build": "nest build", - "start": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --disable-warning=DEP0040\" nest start", - "start:dev": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --disable-warning=DEP0040\" nest start --watch", - "start:debug": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --disable-warning=DEP0040\" nest start --debug --watch", - "start:prod": "node --disable-warning=DEP0040 dist/main", - "lint:js": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", - "lint:js:fix": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --fix", - "lint:api": "bash ../../bin/lint-api-conventions.sh", - "lint:openapi:spec": "spectral lint ../../spec/api/v1/openapi.json --fail-severity warn", - "lint:openapi": "pnpm run generate:openapi && pnpm run lint:openapi:spec", - "lint:deps": "npx madge --circular src --extensions ts,tsx --ts-config tsconfig.json", - "pretty": "pnpm pretty:write && pnpm pretty:check", - "pretty:check": "prettier \"src/**/*.ts\" \"test/**/*.ts\" --check", - "pretty:write": "prettier \"src/**/*.ts\" \"test/**/*.ts\" --write", - "test:unit": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/jest/bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json", - "type-check": "tsc --noEmit", - "typeorm:migration:generate": "ts-node-esm --project tsconfig.json --transpile-only ./node_modules/typeorm/cli.js migration:generate -d ./src/dataSource.ts", - "typeorm:migration:run": "ts-node-esm --project tsconfig.json --transpile-only ./node_modules/typeorm/cli.js migration:run -d ./src/dataSource.ts", - "typeorm:migration:revert": "ts-node-esm --project tsconfig.json --transpile-only ./node_modules/typeorm/cli.js migration:revert -d ./src/dataSource.ts", - "typeorm:migration:run:prod": "node ./node_modules/typeorm/cli.js migration:run -d ./dist/dataSource.js", - "generate:openapi": "ts-node --project tsconfig.json src/cli.ts openapi:generate", - "generate:spec": "ts-node ./tools/build_devices_spec.ts && ts-node ./tools/build_channel_spec.ts", - "cli": "ts-node --project tsconfig.json src/cli.ts", - "swagger": "ts-node --project tsconfig.json src/cli.ts swagger:serve", - "cli:prod": "node dist/cli" - }, - "dependencies": { - "@anthropic-ai/sdk": "^0.78.0", - "@fastify/helmet": "^13.0.2", - "@fastify/multipart": "^9.4.0", - "@fastify/static": "^9.0.0", - "@fastybird/smart-panel-extension-sdk": "workspace:*", - "@influxdata/influxdb-client": "^1.35.0", - "@nestjs/cache-manager": "^3.1.0", - "@nestjs/common": "^11.1.16", - "@nestjs/config": "^4.0.3", - "@nestjs/core": "^11.1.16", - "@nestjs/event-emitter": "^3.0.1", - "@nestjs/jwt": "^11.0.2", - "@nestjs/mapped-types": "^2.1.0", - "@nestjs/platform-express": "^11.1.16", - "@nestjs/platform-fastify": "^11.1.17", - "@nestjs/platform-socket.io": "^11.1.16", - "@nestjs/schedule": "^6.1.1", - "@nestjs/serve-static": "^5.0.4", - "@nestjs/swagger": "^11.2.6", - "@nestjs/throttler": "^6.5.0", - "@nestjs/typeorm": "^11.0.0", - "@nestjs/websockets": "^11.1.16", - "@whiskeysockets/baileys": "7.0.0-rc.9", - "ajv": "^8.18.0", - "bcrypt": "^6.0.0", - "bonjour-service": "^1.3.0", - "cache-manager": "^7.2.8", - "class-transformer": "^0.5.1", - "class-validator": "^0.15.1", - "cron": "^4.4.0", - "discord.js": "^14.16.3", - "dotenv": "^17.3.1", - "fastify": "~5.8.2", - "influx": "^5.12.0", - "inquirer": "^13.3.0", - "lodash.isundefined": "^3.0.1", - "lodash.omitby": "^4.6.0", - "mime-types": "^3.0.2", - "mqtt": "^5.15.0", - "nest-commander": "^3.20.1", - "nestjs-cls": "^6.2.0", - "openai": "^6.28.0", - "openweathermap-ts": "^1.2.10", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.2", - "shellies": "^1.7.0", - "shellies-ds9": "github:fastybird/node-shellies-ds9#main", - "socket.io": "^4.8.3", - "sqlite3": "^5.1.7", - "systeminformation": "^5.31.4", - "telegraf": "^4.16.3", - "typeorm": "^0.3.28", - "ulid": "^3.0.2", - "uuid": "^13.0.0", - "ws": "^8.19.0", - "yaml": "^2.8.2" - }, - "devDependencies": { - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "^10.0.1", - "@nestjs/cli": "^11.0.16", - "@nestjs/schematics": "^11.0.9", - "@nestjs/testing": "^11.1.16", - "@stoplight/spectral-cli": "^6.15.0", - "@swc/cli": "^0.8.0", - "@swc/core": "^1.15.18", - "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@types/bcrypt": "^6.0.0", - "@types/express": "^5.0.6", - "@types/jest": "^30", - "@types/lodash.isundefined": "^3.0.9", - "@types/lodash.omitby": "^4.6.9", - "@types/md5": "^2.3.6", - "@types/mime-types": "^3.0.1", - "@types/node": "^24.12.0", - "@types/supertest": "^7.2.0", - "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "latest", - "@typescript-eslint/parser": "latest", - "eslint": "^10.0.3", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.5", - "globals": "^17.4.0", - "jest": "^30.3.0", - "openapi-typescript": "^7.13.0", - "prettier": "^3.8.1", - "source-map-support": "^0.5.21", - "supertest": "^7.2.2", - "ts-jest": "^29.4.0", - "ts-loader": "^9.5.4", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typescript": "~5.9.3", - "typescript-eslint": "^8.57.0" - }, - "engines": { - "node": ">=20", - "npm": ">=10" - } -} \ No newline at end of file + "name": "@fastybird/smart-panel-backend", + "version": "1.0.0-dev.1", + "description": "Powerful backend for administrating and integrating the FastyBird IoT Smart Panel", + "keywords": [ + "fastybird", + "iot", + "smart-panel", + "backend", + "administration", + "iot-platform", + "device-management", + "home-automation", + "api", + "websockets", + "iot-backend", + "automation", + "smart-home", + "fastybird-platform", + "smart-devices", + "connected-devices", + "integration" + ], + "homepage": "https://smart-panel.fastybird.com", + "bugs": "https://github.com/FastyBird/smart-panel/issues", + "license": "Apache-2.0", + "author": { + "name": "Studio81 Labs s.r.o.", + "email": "dev@studio81.cz", + "url": "https://www.studio81.cz" + }, + "repository": { + "type": "git", + "url": "https://github.com/FastyBird/smart-panel.git" + }, + "main": "dist/main.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js" + } + }, + "files": [ + "dist/", + "static/", + "package.json", + "README.md", + "LICENSE.md" + ], + "scripts": { + "build": "nest build", + "start": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --disable-warning=DEP0040\" nest start", + "start:dev": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --disable-warning=DEP0040\" nest start --watch", + "start:debug": "NODE_OPTIONS=\"${NODE_OPTIONS:-} --disable-warning=DEP0040\" nest start --debug --watch", + "start:prod": "node --disable-warning=DEP0040 dist/main", + "lint:js": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", + "lint:js:fix": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --fix", + "lint:api": "bash ../../bin/lint-api-conventions.sh", + "lint:openapi:spec": "spectral lint ../../spec/api/v1/openapi.json --fail-severity warn", + "lint:openapi": "pnpm run generate:openapi && pnpm run lint:openapi:spec", + "lint:deps": "npx madge --circular src --extensions ts,tsx --ts-config tsconfig.json", + "pretty": "pnpm pretty:write && pnpm pretty:check", + "pretty:check": "prettier \"src/**/*.ts\" \"test/**/*.ts\" --check", + "pretty:write": "prettier \"src/**/*.ts\" \"test/**/*.ts\" --write", + "test:unit": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/jest/bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "type-check": "tsc --noEmit", + "typeorm:migration:generate": "ts-node-esm --project tsconfig.json --transpile-only ./node_modules/typeorm/cli.js migration:generate -d ./src/dataSource.ts", + "typeorm:migration:run": "ts-node-esm --project tsconfig.json --transpile-only ./node_modules/typeorm/cli.js migration:run -d ./src/dataSource.ts", + "typeorm:migration:revert": "ts-node-esm --project tsconfig.json --transpile-only ./node_modules/typeorm/cli.js migration:revert -d ./src/dataSource.ts", + "typeorm:migration:run:prod": "node ./node_modules/typeorm/cli.js migration:run -d ./dist/dataSource.js", + "generate:openapi": "ts-node --project tsconfig.json src/cli.ts openapi:generate", + "generate:spec": "ts-node ./tools/build_devices_spec.ts && ts-node ./tools/build_channel_spec.ts", + "cli": "ts-node --project tsconfig.json src/cli.ts", + "cli:prod": "node dist/cli" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", + "@fastify/multipart": "^9.4.0", + "@fastify/static": "^9.0.0", + "@fastybird/smart-panel-extension-sdk": "workspace:*", + "@influxdata/influxdb-client": "^1.35.0", + "@nestjs/cache-manager": "^3.1.0", + "@nestjs/common": "^11.1.16", + "@nestjs/config": "^4.0.3", + "@nestjs/core": "^11.1.16", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/mapped-types": "^2.1.0", + "@nestjs/platform-express": "^11.1.16", + "@nestjs/platform-fastify": "^11.1.17", + "@nestjs/platform-socket.io": "^11.1.16", + "@nestjs/schedule": "^6.1.1", + "@nestjs/serve-static": "^5.0.4", + "@nestjs/swagger": "^11.2.6", + "@nestjs/typeorm": "^11.0.0", + "@nestjs/websockets": "^11.1.16", + "@whiskeysockets/baileys": "7.0.0-rc.9", + "ajv": "^8.18.0", + "bcrypt": "^6.0.0", + "bonjour-service": "^1.3.0", + "cache-manager": "^7.2.8", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "cron": "^4.4.0", + "discord.js": "^14.16.3", + "dotenv": "^17.3.1", + "fastify": "~5.8.2", + "influx": "^5.12.0", + "inquirer": "^13.3.0", + "lodash.isundefined": "^3.0.1", + "lodash.omitby": "^4.6.0", + "mime-types": "^3.0.2", + "mqtt": "^5.15.0", + "nest-commander": "^3.20.1", + "nestjs-cls": "^6.2.0", + "openai": "^6.28.0", + "openweathermap-ts": "^1.2.10", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "serialport": "^12.0.0", + "shellies": "^1.7.0", + "shellies-ds9": "github:fastybird/node-shellies-ds9#main", + "socket.io": "^4.8.3", + "sqlite3": "^5.1.7", + "systeminformation": "^5.31.4", + "telegraf": "^4.16.3", + "typeorm": "^0.3.28", + "ulid": "^3.0.2", + "uuid": "^13.0.0", + "ws": "^8.19.0", + "yaml": "^2.8.2", + "zigbee-herdsman": "^10.0.4", + "zigbee-herdsman-converters": "^26.26.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "^10.0.1", + "@nestjs/cli": "^11.0.16", + "@nestjs/schematics": "^11.0.9", + "@nestjs/testing": "^11.1.16", + "@stoplight/spectral-cli": "^6.15.0", + "@swc/cli": "^0.8.0", + "@swc/core": "^1.15.18", + "@trivago/prettier-plugin-sort-imports": "^6.0.2", + "@types/bcrypt": "^6.0.0", + "@types/express": "^5.0.6", + "@types/jest": "^30", + "@types/lodash.isundefined": "^3.0.9", + "@types/lodash.omitby": "^4.6.9", + "@types/md5": "^2.3.6", + "@types/mime-types": "^3.0.1", + "@types/node": "^24.12.0", + "@types/supertest": "^7.2.0", + "@types/ws": "^8.18.1", + "@typescript-eslint/eslint-plugin": "latest", + "@typescript-eslint/parser": "latest", + "eslint": "^10.0.3", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "globals": "^17.4.0", + "jest": "^30.3.0", + "openapi-typescript": "^7.13.0", + "prettier": "^3.8.1", + "source-map-support": "^0.5.21", + "supertest": "^7.2.2", + "ts-jest": "^29.4.0", + "ts-loader": "^9.5.4", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.0" + }, + "engines": { + "node": ">=20", + "npm": ">=10" + } +} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index f1029228b2..10c175b965 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -92,6 +92,8 @@ import { DEVICES_WLED_PLUGIN_PREFIX } from './plugins/devices-wled/devices-wled. import { DevicesWledPlugin } from './plugins/devices-wled/devices-wled.plugin'; import { DEVICES_ZIGBEE2MQTT_PLUGIN_PREFIX } from './plugins/devices-zigbee2mqtt/devices-zigbee2mqtt.constants'; import { DevicesZigbee2mqttPlugin } from './plugins/devices-zigbee2mqtt/devices-zigbee2mqtt.plugin'; +import { DEVICES_ZIGBEE_HERDSMAN_PLUGIN_PREFIX } from './plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.constants'; +import { DevicesZigbeeHerdsmanPlugin } from './plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.plugin'; import { InfluxV1Plugin } from './plugins/influx-v1/influx-v1.plugin'; import { InfluxV2Plugin } from './plugins/influx-v2/influx-v2.plugin'; import { LoggerRotatingFilePlugin } from './plugins/logger-rotating-file/logger-rotating-file.plugin'; @@ -305,6 +307,10 @@ export class AppModule { path: DEVICES_WLED_PLUGIN_PREFIX, module: DevicesWledPlugin, }, + { + path: DEVICES_ZIGBEE_HERDSMAN_PLUGIN_PREFIX, + module: DevicesZigbeeHerdsmanPlugin, + }, { path: DEVICES_ZIGBEE2MQTT_PLUGIN_PREFIX, module: DevicesZigbee2mqttPlugin, @@ -409,6 +415,7 @@ export class AppModule { DevicesShellyNgPlugin, DevicesShellyV1Plugin, DevicesWledPlugin, + DevicesZigbeeHerdsmanPlugin, DevicesZigbee2mqttPlugin, SimulatorPlugin, PagesCardsPlugin, diff --git a/apps/backend/src/migrations/1743400000000-AddZigbeeHerdsmanColumns.ts b/apps/backend/src/migrations/1743400000000-AddZigbeeHerdsmanColumns.ts new file mode 100644 index 0000000000..429a60dbe6 --- /dev/null +++ b/apps/backend/src/migrations/1743400000000-AddZigbeeHerdsmanColumns.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddZigbeeHerdsmanColumns1743400000000 implements MigrationInterface { + name = 'AddZigbeeHerdsmanColumns1743400000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Device entity columns for zigbee-herdsman plugin + await queryRunner.query(`ALTER TABLE "devices_module_devices" ADD COLUMN "ieee_address" varchar`); + await queryRunner.query(`ALTER TABLE "devices_module_devices" ADD COLUMN "network_address" integer`); + await queryRunner.query(`ALTER TABLE "devices_module_devices" ADD COLUMN "manufacturer_name" varchar`); + await queryRunner.query(`ALTER TABLE "devices_module_devices" ADD COLUMN "model_id" varchar`); + await queryRunner.query(`ALTER TABLE "devices_module_devices" ADD COLUMN "date_code" varchar`); + await queryRunner.query(`ALTER TABLE "devices_module_devices" ADD COLUMN "software_build_id" varchar`); + await queryRunner.query( + `ALTER TABLE "devices_module_devices" ADD COLUMN "interview_completed" boolean DEFAULT (0)`, + ); + + // Channel property entity columns for zigbee-herdsman plugin + await queryRunner.query(`ALTER TABLE "devices_module_channels_properties" ADD COLUMN "zigbee_cluster" varchar`); + await queryRunner.query( + `ALTER TABLE "devices_module_channels_properties" ADD COLUMN "zigbee_attribute" varchar`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // SQLite 3.35.0+ supports DROP COLUMN for columns that have no constraints, + // are not part of an index, and are not the PRIMARY KEY. All our columns qualify. + // Each column is dropped individually so a failure on one doesn't prevent + // the others from being attempted, and partial state is visible in logs. + const columns = [ + { table: 'devices_module_devices', column: 'ieee_address' }, + { table: 'devices_module_devices', column: 'network_address' }, + { table: 'devices_module_devices', column: 'manufacturer_name' }, + { table: 'devices_module_devices', column: 'model_id' }, + { table: 'devices_module_devices', column: 'date_code' }, + { table: 'devices_module_devices', column: 'software_build_id' }, + { table: 'devices_module_devices', column: 'interview_completed' }, + { table: 'devices_module_channels_properties', column: 'zigbee_cluster' }, + { table: 'devices_module_channels_properties', column: 'zigbee_attribute' }, + ]; + + for (const { table, column } of columns) { + try { + await queryRunner.query(`ALTER TABLE "${table}" DROP COLUMN "${column}"`); + } catch { + // DROP COLUMN not supported on this SQLite version — column remains harmlessly + } + } + } +} diff --git a/apps/backend/src/plugins/devices-shelly-ng/delegates/shelly-device.delegate.ts b/apps/backend/src/plugins/devices-shelly-ng/delegates/shelly-device.delegate.ts index c3a5377191..28540b0550 100644 --- a/apps/backend/src/plugins/devices-shelly-ng/delegates/shelly-device.delegate.ts +++ b/apps/backend/src/plugins/devices-shelly-ng/delegates/shelly-device.delegate.ts @@ -46,7 +46,6 @@ export class ShellyDeviceDelegate extends EventEmitter2 { ); public connected: boolean = false; - public disconnectedAt: number | null = null; public components: Map = new Map(); @@ -211,24 +210,6 @@ export class ShellyDeviceDelegate extends EventEmitter2 { * is actually responsive. Resolves to true if the device responds, false otherwise. */ async ping(timeoutMs: number = 5_000): Promise { - return this.rpcWithTimeout(() => this.shelly.rpcHandler.request('Shelly.GetDeviceInfo'), timeoutMs); - } - - /** - * Polls the device by calling the library's loadStatus(), which internally - * fetches Shelly.GetStatus and applies the values to each component. - * Components then emit proper 'change' events through the existing pipeline, - * correctly handling nested values like aenergy ({ total, by_minute, ... }). - */ - async pollStatus(timeoutMs: number = 10_000): Promise { - return this.rpcWithTimeout(() => this.shelly.loadStatus(), timeoutMs); - } - - /** - * Runs a lazily-created promise with a timeout guard. The factory is only - * called after the connected check, avoiding orphaned in-flight promises. - */ - private async rpcWithTimeout(factory: () => PromiseLike, timeoutMs: number): Promise { if (!this.shelly.rpcHandler.connected) { return false; } @@ -237,9 +218,9 @@ export class ShellyDeviceDelegate extends EventEmitter2 { try { await Promise.race([ - factory(), + this.shelly.rpcHandler.request('Shelly.GetDeviceInfo'), new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error('RPC timeout')), timeoutMs); + timer = setTimeout(() => reject(new Error('Ping timeout')), timeoutMs); }), ]); @@ -254,16 +235,26 @@ export class ShellyDeviceDelegate extends EventEmitter2 { } /** - * Triggers an immediate reconnection attempt via the library's public API. - * Resets backoff and terminates the current socket. + * Forcibly terminates the underlying WebSocket to trigger a reconnection cycle. + * Unlike a graceful close (code 1000), terminate() causes a non-1000 close code + * which makes the shellies-ds9 library schedule an automatic reconnection. */ forceReconnect(): void { this.logger.warn(`Forcing reconnection for device=${this.shelly.id}`, { resource: this.shelly.id }); - const handler = this.shelly.rpcHandler as unknown as { reconnect?: () => void }; + // Access the protected socket to force-terminate it. + // TypeScript access modifiers are not enforced at runtime. + const rpcHandler = this.shelly.rpcHandler as unknown as { + socket?: { terminate?: () => void }; + resetReconnectInterval?: () => void; + }; + + // Reset reconnect backoff so the library uses the shortest interval + // (first entry in reconnectInterval) instead of an escalated delay. + rpcHandler.resetReconnectInterval?.(); - if (handler.reconnect) { - handler.reconnect(); + if (rpcHandler.socket?.terminate) { + rpcHandler.socket.terminate(); } } @@ -271,7 +262,6 @@ export class ShellyDeviceDelegate extends EventEmitter2 { this.logger.log(`Device=${this.shelly.id} connected`, { resource: this.shelly.id }); this.connected = true; - this.disconnectedAt = null; this.emit('connected', true); }; @@ -300,7 +290,6 @@ export class ShellyDeviceDelegate extends EventEmitter2 { } this.connected = false; - this.disconnectedAt = Date.now(); this.emit('connected', false); }; diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/controllers/zigbee-herdsman-discovered-devices.controller.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/controllers/zigbee-herdsman-discovered-devices.controller.ts new file mode 100644 index 0000000000..22aa9fe041 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/controllers/zigbee-herdsman-discovered-devices.controller.ts @@ -0,0 +1,346 @@ +import { Body, Controller, Delete, Get, Param, Post, UnprocessableEntityException } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; + +import { ExtensionLoggerService, createExtensionLogger } from '../../../common/logger'; +import { toInstance } from '../../../common/utils/transform.utils'; +import { DeviceCategory } from '../../../modules/devices/devices.constants'; +import { DeviceResponseModel } from '../../../modules/devices/models/devices-response.model'; +import { DevicesService } from '../../../modules/devices/services/devices.service'; +import { + ApiBadRequestResponse, + ApiCreatedSuccessResponse, + ApiInternalServerErrorResponse, + ApiNotFoundResponse, + ApiSuccessResponse, + ApiUnprocessableEntityResponse, +} from '../../../modules/swagger/decorators/api-documentation.decorator'; +import { + DEVICES_ZIGBEE_HERDSMAN_API_TAG_NAME, + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + DEVICES_ZIGBEE_HERDSMAN_TYPE, + extractExposeInfo, + mapZhCategoryToDeviceCategory, +} from '../devices-zigbee-herdsman.constants'; +import { ZigbeeHerdsmanCoordinatorOfflineException } from '../devices-zigbee-herdsman.exceptions'; +import { + ReqZhAdoptDeviceDto, + ReqZhMappingPreviewDto, + ReqZhPermitJoinDto, + ZhAdoptDeviceRequestDto, + ZhMappingPreviewRequestDto, +} from '../dto/mapping-preview.dto'; +import { ZigbeeHerdsmanDeviceEntity } from '../entities/devices-zigbee-herdsman.entity'; +import { ZhDiscoveredDevice } from '../interfaces/zigbee-herdsman.interface'; +import { + ZhCoordinatorInfoModel, + ZhCoordinatorInfoResponseModel, + ZhExposeInfoModel, + ZhMappingPreviewModel, + ZhMappingPreviewResponseModel, + ZhPermitJoinModel, + ZhPermitJoinResponseModel, + ZigbeeHerdsmanDiscoveredDeviceModel, + ZigbeeHerdsmanDiscoveredDeviceResponseModel, + ZigbeeHerdsmanDiscoveredDevicesResponseModel, +} from '../models/zigbee-herdsman-response.model'; +import { ZhDeviceAdoptionService } from '../services/device-adoption.service'; +import { ZhMappingPreviewService } from '../services/mapping-preview.service'; +import { ZigbeeHerdsmanService } from '../services/zigbee-herdsman.service'; + +@ApiTags(DEVICES_ZIGBEE_HERDSMAN_API_TAG_NAME) +@Controller('discovered-devices') +export class ZigbeeHerdsmanDiscoveredDevicesController { + private readonly logger: ExtensionLoggerService = createExtensionLogger( + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + 'DiscoveredDevicesController', + ); + + constructor( + private readonly zigbeeHerdsmanService: ZigbeeHerdsmanService, + private readonly mappingPreviewService: ZhMappingPreviewService, + private readonly deviceAdoptionService: ZhDeviceAdoptionService, + private readonly devicesService: DevicesService, + ) {} + + // ========================================================================= + // Static routes — must be declared before parameterized (:ieeeAddress) routes + // to prevent NestJS from treating 'coordinator-info' as an :ieeeAddress param. + // ========================================================================= + + @ApiOperation({ + tags: [DEVICES_ZIGBEE_HERDSMAN_API_TAG_NAME], + summary: 'Retrieve all discovered Zigbee devices', + description: 'Fetches a list of all Zigbee devices discovered via the zigbee-herdsman coordinator.', + operationId: 'get-devices-zigbee-herdsman-plugin-devices', + }) + @ApiSuccessResponse( + ZigbeeHerdsmanDiscoveredDevicesResponseModel, + 'A list of discovered Zigbee devices successfully retrieved', + ) + @ApiBadRequestResponse('Invalid request parameters') + @ApiUnprocessableEntityResponse('Zigbee coordinator is not properly configured') + @ApiInternalServerErrorResponse('Internal server error') + @Get() + async findAll(): Promise { + this.logger.debug('Fetching all discovered Zigbee devices'); + + try { + if (!this.zigbeeHerdsmanService.isCoordinatorOnline()) { + throw new ZigbeeHerdsmanCoordinatorOfflineException(); + } + + const discoveredDevices = this.zigbeeHerdsmanService.getDiscoveredDevices(); + const adoptedDevices = + await this.devicesService.findAll(DEVICES_ZIGBEE_HERDSMAN_TYPE); + const adoptedAddresses = new Set(adoptedDevices.map((d) => d.ieeeAddress)); + + const devices = discoveredDevices.map((d) => + this.transformToDiscoveredDevice(d, adoptedAddresses, adoptedDevices), + ); + + const response = new ZigbeeHerdsmanDiscoveredDevicesResponseModel(); + response.data = toInstance(ZigbeeHerdsmanDiscoveredDeviceModel, devices); + return response; + } catch (error) { + if (error instanceof ZigbeeHerdsmanCoordinatorOfflineException) { + throw new UnprocessableEntityException('Zigbee coordinator is offline'); + } + throw error; + } + } + + @ApiOperation({ + tags: [DEVICES_ZIGBEE_HERDSMAN_API_TAG_NAME], + summary: 'Get coordinator information', + description: 'Returns information about the Zigbee coordinator including firmware version and network details.', + operationId: 'get-devices-zigbee-herdsman-plugin-coordinator-info', + }) + @ApiSuccessResponse(ZhCoordinatorInfoResponseModel, 'Coordinator info retrieved') + @ApiUnprocessableEntityResponse('Coordinator offline') + @ApiInternalServerErrorResponse('Internal server error') + @Get('coordinator-info') + async getCoordinatorInfo(): Promise { + if (!this.zigbeeHerdsmanService.isCoordinatorOnline()) { + throw new UnprocessableEntityException('Zigbee coordinator is offline'); + } + + const info = await this.zigbeeHerdsmanService.getCoordinatorInfo(); + if (!info) { + throw new UnprocessableEntityException('Could not retrieve coordinator information'); + } + + const result: ZhCoordinatorInfoModel = { + type: info.type, + ieeeAddress: info.ieeeAddress, + firmwareVersion: info.firmwareVersion, + channel: info.channel, + panId: info.panId, + pairedDeviceCount: info.pairedDeviceCount, + permitJoin: this.zigbeeHerdsmanService.isPermitJoinEnabled(), + }; + + const response = new ZhCoordinatorInfoResponseModel(); + response.data = toInstance(ZhCoordinatorInfoModel, result); + return response; + } + + @ApiOperation({ + tags: [DEVICES_ZIGBEE_HERDSMAN_API_TAG_NAME], + summary: 'Adopt a Zigbee device', + description: 'Creates a Smart Panel device from a Zigbee device based on the provided mapping.', + operationId: 'adopt-devices-zigbee-herdsman-plugin-device', + }) + @ApiBody({ type: ReqZhAdoptDeviceDto, description: 'Device adoption configuration' }) + @ApiCreatedSuccessResponse(DeviceResponseModel, 'Device adopted successfully') + @ApiBadRequestResponse('Invalid request parameters') + @ApiNotFoundResponse('Zigbee device not found') + @ApiUnprocessableEntityResponse('Device adoption failed') + @ApiInternalServerErrorResponse('Internal server error') + @Post('adopt') + async adoptDevice(@Body() body: ReqZhAdoptDeviceDto): Promise { + this.logger.debug(`Adopting device ieeeAddress=${body.data.ieeeAddress}`); + + // Service throws HttpException subclasses (404, 422, 500) with correct + // status codes — let NestJS handle them directly without re-wrapping. + const request = toInstance(ZhAdoptDeviceRequestDto, body.data); + const device = await this.deviceAdoptionService.adoptDevice(request); + + const response = new DeviceResponseModel(); + response.data = device; + return response; + } + + @ApiOperation({ + tags: [DEVICES_ZIGBEE_HERDSMAN_API_TAG_NAME], + summary: 'Enable or disable permit join', + description: 'Controls whether new devices can join the Zigbee network.', + operationId: 'permit-join-devices-zigbee-herdsman-plugin', + }) + @ApiBody({ type: ReqZhPermitJoinDto, description: 'Permit join configuration' }) + @ApiSuccessResponse(ZhPermitJoinResponseModel, 'Permit join state updated') + @ApiUnprocessableEntityResponse('Coordinator offline') + @ApiInternalServerErrorResponse('Internal server error') + @Post('permit-join') + async permitJoin(@Body() body: ReqZhPermitJoinDto): Promise { + this.logger.debug(`Setting permit join: enabled=${body.data.enabled}`); + + try { + if (!this.zigbeeHerdsmanService.isCoordinatorOnline()) { + throw new ZigbeeHerdsmanCoordinatorOfflineException(); + } + + await this.zigbeeHerdsmanService.permitJoin(body.data.enabled, body.data.timeout); + + const result: ZhPermitJoinModel = { + enabled: body.data.enabled, + timeout: body.data.enabled ? (body.data.timeout ?? 254) : 0, + }; + + const response = new ZhPermitJoinResponseModel(); + response.data = toInstance(ZhPermitJoinModel, result); + return response; + } catch (error) { + if (error instanceof ZigbeeHerdsmanCoordinatorOfflineException) { + throw new UnprocessableEntityException('Zigbee coordinator is offline'); + } + throw error; + } + } + + // ========================================================================= + // Parameterized routes — after static routes to avoid shadowing. + // ========================================================================= + + @ApiOperation({ + tags: [DEVICES_ZIGBEE_HERDSMAN_API_TAG_NAME], + summary: 'Preview device mapping', + description: 'Generates a preview of how a Zigbee device would be mapped to Smart Panel entities.', + operationId: 'preview-devices-zigbee-herdsman-plugin-device-mapping', + }) + @ApiParam({ name: 'ieeeAddress', type: 'string', description: 'Device IEEE address' }) + @ApiBody({ type: ReqZhMappingPreviewDto, required: false, description: 'Optional mapping overrides' }) + @ApiSuccessResponse(ZhMappingPreviewResponseModel, 'Mapping preview generated successfully') + @ApiNotFoundResponse('Zigbee device not found') + @ApiUnprocessableEntityResponse('Zigbee coordinator is not properly configured') + @ApiInternalServerErrorResponse('Internal server error') + @Post(':ieeeAddress/preview-mapping') + async previewMapping( + @Param('ieeeAddress') ieeeAddress: string, + @Body() body?: ReqZhMappingPreviewDto, + ): Promise { + this.logger.debug(`Previewing mapping for device ieeeAddress=${ieeeAddress}`); + + // Service throws HttpException subclasses (404, 422) — let NestJS handle them directly. + const request = body?.data ? toInstance(ZhMappingPreviewRequestDto, body.data) : undefined; + const preview = await this.mappingPreviewService.generatePreview(ieeeAddress, request); + + const response = new ZhMappingPreviewResponseModel(); + response.data = toInstance(ZhMappingPreviewModel, preview); + return response; + } + + @ApiOperation({ + tags: [DEVICES_ZIGBEE_HERDSMAN_API_TAG_NAME], + summary: 'Remove a device from the Zigbee network', + description: 'Removes a device from the Zigbee network and optionally deletes the Smart Panel entity.', + operationId: 'remove-devices-zigbee-herdsman-plugin-device', + }) + @ApiParam({ name: 'ieeeAddress', type: 'string', description: 'Device IEEE address' }) + @ApiSuccessResponse(ZigbeeHerdsmanDiscoveredDeviceResponseModel, 'Device removed successfully') + @ApiNotFoundResponse('Device not found') + @ApiUnprocessableEntityResponse('Coordinator offline') + @ApiInternalServerErrorResponse('Internal server error') + @Delete(':ieeeAddress') + async removeDevice(@Param('ieeeAddress') ieeeAddress: string): Promise { + this.logger.debug(`Removing device ieeeAddress=${ieeeAddress}`); + + if (!this.zigbeeHerdsmanService.isCoordinatorOnline()) { + throw new UnprocessableEntityException('Zigbee coordinator is offline'); + } + + try { + await this.zigbeeHerdsmanService.removeDevice(ieeeAddress); + } catch (error) { + const err = error as Error; + + this.logger.error(`Failed to remove device ${ieeeAddress}`, { + message: err.message, + stack: err.stack, + }); + + throw new UnprocessableEntityException(`Failed to remove device: ${err.message}`); + } + } + + // ========================================================================= + // Private helpers + // ========================================================================= + + private transformToDiscoveredDevice( + discovered: ZhDiscoveredDevice, + adoptedAddresses: Set, + adoptedDevices: ZigbeeHerdsmanDeviceEntity[], + ): ZigbeeHerdsmanDiscoveredDeviceModel { + const isAdopted = adoptedAddresses.has(discovered.ieeeAddress); + const adoptedDevice = adoptedDevices.find((d) => d.ieeeAddress === discovered.ieeeAddress); + + let suggestedCategory: DeviceCategory | undefined = undefined; + if (discovered.definition) { + const { exposeTypes, propertyNames } = extractExposeInfo(discovered.definition.exposes); + suggestedCategory = mapZhCategoryToDeviceCategory(exposeTypes, propertyNames); + } + + const exposes: ZhExposeInfoModel[] = []; + if (discovered.definition?.exposes) { + for (const expose of discovered.definition.exposes) { + exposes.push({ + type: expose.type, + name: expose.name, + property: expose.property, + label: expose.label, + access: expose.access, + unit: 'unit' in expose ? (expose as { unit?: string }).unit : undefined, + }); + + // For composite types (light, switch, climate, etc.), also include + // nested features so the API consumer can see actual capabilities. + if ('features' in expose && Array.isArray(expose.features)) { + for (const feature of expose.features as Array<{ + type: string; + name?: string; + property?: string; + label?: string; + access?: number; + unit?: string; + }>) { + exposes.push({ + type: feature.type, + name: feature.name ?? feature.property, + property: feature.property, + label: feature.label, + access: feature.access, + unit: feature.unit, + }); + } + } + } + } + + return { + ieeeAddress: discovered.ieeeAddress, + friendlyName: discovered.friendlyName, + type: discovered.type, + modelId: discovered.modelId ?? undefined, + manufacturer: discovered.definition?.vendor, + model: discovered.definition?.model, + description: discovered.definition?.description, + powerSource: discovered.powerSource ?? undefined, + interviewStatus: discovered.interviewStatus, + available: discovered.available, + adopted: isAdopted, + adoptedDeviceId: adoptedDevice?.id, + exposes, + suggestedCategory, + }; + } +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.constants.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.constants.ts new file mode 100644 index 0000000000..a2c3878723 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.constants.ts @@ -0,0 +1,552 @@ +import { + ChannelCategory, + DataTypeType, + DeviceCategory, + PermissionType, + PropertyCategory, +} from '../../modules/devices/devices.constants'; + +// Plugin identification +export const DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME = 'devices-zigbee-herdsman-plugin'; +export const DEVICES_ZIGBEE_HERDSMAN_PLUGIN_PREFIX = 'devices-zigbee-herdsman'; +export const DEVICES_ZIGBEE_HERDSMAN_TYPE = 'devices-zigbee-herdsman'; + +// API Tag configuration +export const DEVICES_ZIGBEE_HERDSMAN_API_TAG_NAME = 'Devices module - Zigbee Herdsman plugin'; +export const DEVICES_ZIGBEE_HERDSMAN_API_TAG_DESCRIPTION = + 'Direct Zigbee device integration via zigbee-herdsman for self-contained Zigbee network management.'; + +// Default configuration values +export const DEFAULT_SERIAL_PORT = '/dev/ttyUSB0'; +export const DEFAULT_BAUD_RATE = 115200; +export const DEFAULT_ADAPTER_TYPE = 'auto'; +export const DEFAULT_CHANNEL = 11; +export const DEFAULT_PERMIT_JOIN_TIMEOUT = 254; +export const DEFAULT_DATABASE_PATH = 'data/zigbee-herdsman.db'; +export const DEFAULT_DATABASE_BACKUP_PATH = 'data/zigbee-herdsman-backup.db'; +export const DEFAULT_MAINS_DEVICE_TIMEOUT = 3600; +export const DEFAULT_BATTERY_DEVICE_TIMEOUT = 7200; +export const DEFAULT_COMMAND_RETRIES = 3; +export const DEFAULT_ADAPTER_CONCURRENT = 16; + +// Adapter types +export const ADAPTER_TYPES = ['auto', 'zstack', 'ember', 'deconz', 'zigate'] as const; +export type AdapterType = (typeof ADAPTER_TYPES)[number]; + +// Recommended Zigbee channels +export const RECOMMENDED_CHANNELS = [11, 15, 20, 25] as const; +export const ALLOWED_CHANNELS_MIN = 11; +export const ALLOWED_CHANNELS_MAX = 26; + +// Device interview states +export const INTERVIEW_STATUS = { + NOT_STARTED: 'not_started', + IN_PROGRESS: 'in_progress', + COMPLETED: 'completed', + FAILED: 'failed', +} as const; +export type InterviewStatus = (typeof INTERVIEW_STATUS)[keyof typeof INTERVIEW_STATUS]; + +// Zigbee device types +export const ZIGBEE_DEVICE_TYPES = ['Coordinator', 'Router', 'EndDevice'] as const; +export type ZigbeeDeviceType = (typeof ZIGBEE_DEVICE_TYPES)[number]; + +// Channel identifiers +export const ZH_CHANNEL_IDENTIFIERS = { + DEVICE_INFORMATION: 'device_information', + LIGHT: 'light', + SWITCHER: 'switcher', + THERMOSTAT: 'thermostat', + WINDOW_COVERING: 'window_covering', + LOCK: 'lock', + FAN: 'fan', + TEMPERATURE: 'temperature', + HUMIDITY: 'humidity', + ILLUMINANCE: 'illuminance', + PRESSURE: 'pressure', + OCCUPANCY: 'occupancy', + CONTACT: 'contact', + LEAK: 'leak', + SMOKE: 'smoke', + BATTERY: 'battery', + ELECTRICAL_POWER: 'electrical_power', + ELECTRICAL_ENERGY: 'electrical_energy', +} as const; + +// Expose access bits (from zigbee-herdsman-converters) +export const ZH_ACCESS = { + STATE: 1, + SET: 2, + GET: 4, +} as const; + +// Specific expose types +export const ZH_SPECIFIC_TYPES = ['light', 'switch', 'fan', 'cover', 'lock', 'climate'] as const; +export const ZH_GENERIC_TYPES = ['binary', 'numeric', 'enum', 'text', 'composite', 'list'] as const; + +// Property binding interface — maps zigbee-herdsman-converters exposes to Smart Panel properties. +// The entity identifier is set to the zigbee expose property name (e.g. 'state', 'brightness') +// during adoption so the platform handler can match it against toZigbee converter keys. +export interface ZhPropertyBinding { + channelCategory: ChannelCategory; + category: PropertyCategory; + dataType: DataTypeType; + permissions: PermissionType[]; + name: string; + unit?: string; + format?: string | string[] | number[]; + min?: number; + max?: number; + step?: number; +} + +// Common property mappings for standard zigbee-herdsman-converters exposes. +// Values reflect raw ZCL ranges (brightness 0-254, color_temp in mireds, linkquality 0-255) +// since this plugin communicates directly with the Zigbee coordinator. +type ZhPropertyMappingEntry = Pick & + Partial>; +export const COMMON_PROPERTY_MAPPINGS: Record = { + state: { + channelCategory: ChannelCategory.SWITCHER, + category: PropertyCategory.ON, + dataType: DataTypeType.BOOL, + name: 'State', + }, + occupancy: { + channelCategory: ChannelCategory.OCCUPANCY, + category: PropertyCategory.DETECTED, + dataType: DataTypeType.BOOL, + name: 'Occupancy', + }, + contact: { + channelCategory: ChannelCategory.CONTACT, + category: PropertyCategory.DETECTED, + dataType: DataTypeType.BOOL, + name: 'Contact', + }, + water_leak: { + channelCategory: ChannelCategory.LEAK, + category: PropertyCategory.DETECTED, + dataType: DataTypeType.BOOL, + name: 'Water Leak', + }, + smoke: { + channelCategory: ChannelCategory.SMOKE, + category: PropertyCategory.DETECTED, + dataType: DataTypeType.BOOL, + name: 'Smoke', + }, + vibration: { + channelCategory: ChannelCategory.MOTION, + category: PropertyCategory.DETECTED, + dataType: DataTypeType.BOOL, + name: 'Vibration', + }, + tamper: { + channelCategory: ChannelCategory.OCCUPANCY, + category: PropertyCategory.TAMPERED, + dataType: DataTypeType.BOOL, + name: 'Tamper', + }, + battery_low: { + channelCategory: ChannelCategory.BATTERY, + category: PropertyCategory.FAULT, + dataType: DataTypeType.BOOL, + name: 'Battery Low', + }, + brightness: { + channelCategory: ChannelCategory.LIGHT, + category: PropertyCategory.BRIGHTNESS, + dataType: DataTypeType.UCHAR, + name: 'Brightness', + min: 0, + max: 254, + }, + color_temp: { + channelCategory: ChannelCategory.LIGHT, + category: PropertyCategory.COLOR_TEMPERATURE, + dataType: DataTypeType.USHORT, + name: 'Color Temperature', + unit: 'mired', + min: 150, + max: 500, + }, + temperature: { + channelCategory: ChannelCategory.TEMPERATURE, + category: PropertyCategory.TEMPERATURE, + dataType: DataTypeType.FLOAT, + name: 'Temperature', + unit: '°C', + }, + humidity: { + channelCategory: ChannelCategory.HUMIDITY, + category: PropertyCategory.HUMIDITY, + dataType: DataTypeType.FLOAT, + name: 'Humidity', + unit: '%', + min: 0, + max: 100, + }, + pressure: { + channelCategory: ChannelCategory.PRESSURE, + category: PropertyCategory.PRESSURE, + dataType: DataTypeType.FLOAT, + name: 'Pressure', + unit: 'hPa', + }, + illuminance: { + channelCategory: ChannelCategory.ILLUMINANCE, + category: PropertyCategory.ILLUMINANCE, + dataType: DataTypeType.FLOAT, + name: 'Illuminance', + unit: 'lx', + }, + illuminance_lux: { + channelCategory: ChannelCategory.ILLUMINANCE, + category: PropertyCategory.ILLUMINANCE, + dataType: DataTypeType.FLOAT, + name: 'Illuminance', + unit: 'lx', + }, + battery: { + channelCategory: ChannelCategory.BATTERY, + category: PropertyCategory.PERCENTAGE, + dataType: DataTypeType.UCHAR, + name: 'Battery', + unit: '%', + min: 0, + max: 100, + }, + voltage: { + channelCategory: ChannelCategory.ELECTRICAL_POWER, + category: PropertyCategory.VOLTAGE, + dataType: DataTypeType.FLOAT, + name: 'Voltage', + unit: 'V', + }, + current: { + channelCategory: ChannelCategory.ELECTRICAL_POWER, + category: PropertyCategory.CURRENT, + dataType: DataTypeType.FLOAT, + name: 'Current', + unit: 'A', + }, + power: { + channelCategory: ChannelCategory.ELECTRICAL_POWER, + category: PropertyCategory.POWER, + dataType: DataTypeType.FLOAT, + name: 'Power', + unit: 'W', + }, + energy: { + channelCategory: ChannelCategory.ELECTRICAL_ENERGY, + category: PropertyCategory.CONSUMPTION, + dataType: DataTypeType.FLOAT, + name: 'Energy', + unit: 'kWh', + }, + linkquality: { + channelCategory: ChannelCategory.DEVICE_INFORMATION, + category: PropertyCategory.LINK_QUALITY, + dataType: DataTypeType.UCHAR, + name: 'Link Quality', + min: 0, + max: 255, + }, + hue: { + channelCategory: ChannelCategory.LIGHT, + category: PropertyCategory.HUE, + dataType: DataTypeType.USHORT, + name: 'Hue', + unit: 'deg', + min: 0, + max: 360, + }, + saturation: { + channelCategory: ChannelCategory.LIGHT, + category: PropertyCategory.SATURATION, + dataType: DataTypeType.UCHAR, + name: 'Saturation', + unit: '%', + min: 0, + max: 100, + }, + local_temperature: { + channelCategory: ChannelCategory.THERMOSTAT, + category: PropertyCategory.TEMPERATURE, + dataType: DataTypeType.FLOAT, + name: 'Local Temperature', + unit: '°C', + }, + current_heating_setpoint: { + channelCategory: ChannelCategory.THERMOSTAT, + category: PropertyCategory.TEMPERATURE, + dataType: DataTypeType.FLOAT, + name: 'Heating Setpoint', + unit: '°C', + permissions: [PermissionType.READ_WRITE], + }, + occupied_heating_setpoint: { + channelCategory: ChannelCategory.THERMOSTAT, + category: PropertyCategory.TEMPERATURE, + dataType: DataTypeType.FLOAT, + name: 'Occupied Heating Setpoint', + unit: '°C', + permissions: [PermissionType.READ_WRITE], + }, + system_mode: { + channelCategory: ChannelCategory.THERMOSTAT, + category: PropertyCategory.MODE, + dataType: DataTypeType.STRING, + name: 'System Mode', + }, + running_state: { + channelCategory: ChannelCategory.THERMOSTAT, + category: PropertyCategory.STATUS, + dataType: DataTypeType.STRING, + name: 'Running State', + }, + position: { + channelCategory: ChannelCategory.WINDOW_COVERING, + category: PropertyCategory.POSITION, + dataType: DataTypeType.UCHAR, + name: 'Position', + unit: '%', + min: 0, + max: 100, + }, + tilt: { + channelCategory: ChannelCategory.WINDOW_COVERING, + category: PropertyCategory.TILT, + dataType: DataTypeType.UCHAR, + name: 'Tilt', + unit: '%', + min: 0, + max: 100, + }, + lock_state: { + channelCategory: ChannelCategory.LOCK, + category: PropertyCategory.LOCKED, + dataType: DataTypeType.BOOL, + name: 'Lock State', + }, + action: { + channelCategory: ChannelCategory.DOORBELL, + category: PropertyCategory.EVENT, + dataType: DataTypeType.STRING, + name: 'Action', + }, + presence: { + channelCategory: ChannelCategory.OCCUPANCY, + category: PropertyCategory.DETECTED, + dataType: DataTypeType.BOOL, + name: 'Presence', + }, + target_distance: { + channelCategory: ChannelCategory.OCCUPANCY, + category: PropertyCategory.DISTANCE, + dataType: DataTypeType.FLOAT, + name: 'Target Distance', + unit: 'm', + }, + fan_state: { + channelCategory: ChannelCategory.FAN, + category: PropertyCategory.ON, + dataType: DataTypeType.BOOL, + name: 'Fan State', + }, + fan_mode: { + channelCategory: ChannelCategory.FAN, + category: PropertyCategory.MODE, + dataType: DataTypeType.STRING, + name: 'Fan Mode', + }, + pm25: { + channelCategory: ChannelCategory.AIR_PARTICULATE, + category: PropertyCategory.CONCENTRATION, + dataType: DataTypeType.UINT, + name: 'PM2.5', + unit: 'µg/m³', + }, +}; + +// Device information property identifiers +export const ZH_DEVICE_INFO_PROPERTY_IDENTIFIERS = { + MANUFACTURER: 'manufacturer', + MODEL: 'model', + SERIAL_NUMBER: 'serial_number', + LINK_QUALITY: 'linkquality', +} as const; + +// Extract expose types and property names (including nested features) from a definition's exposes. +export const extractExposeInfo = ( + exposes: { type: string; property?: string; features?: { property?: string }[] }[], +): { exposeTypes: string[]; propertyNames: string[] } => { + const exposeTypes = exposes.map((e) => e.type); + const propertyNames: string[] = []; + + for (const expose of exposes) { + if (expose.property) { + propertyNames.push(expose.property); + } + if (expose.features) { + for (const feature of expose.features) { + if (feature.property) { + propertyNames.push(feature.property); + } + } + } + } + + return { exposeTypes, propertyNames }; +}; + +// Mapping functions for zigbee-herdsman-converters exposes. +export const mapZhCategoryToDeviceCategory = (exposeTypes: string[], propertyNames: string[] = []): DeviceCategory => { + if (exposeTypes.includes('light')) { + return DeviceCategory.LIGHTING; + } + if (exposeTypes.includes('climate')) { + return DeviceCategory.THERMOSTAT; + } + if (exposeTypes.includes('lock')) { + return DeviceCategory.LOCK; + } + if (exposeTypes.includes('cover')) { + return DeviceCategory.WINDOW_COVERING; + } + if (exposeTypes.includes('fan')) { + return DeviceCategory.FAN; + } + + const hasSwitch = exposeTypes.includes('switch') || propertyNames.includes('state'); + const hasPowerMonitoring = + propertyNames.includes('power') || propertyNames.includes('energy') || propertyNames.includes('voltage'); + + if (hasSwitch && hasPowerMonitoring) { + return DeviceCategory.OUTLET; + } + if (hasSwitch) { + return DeviceCategory.SWITCHER; + } + + const binarySensorProperties = ['occupancy', 'contact', 'smoke', 'water_leak', 'vibration', 'tamper', 'gas', 'co']; + if (binarySensorProperties.some((prop) => propertyNames.includes(prop))) { + return DeviceCategory.SENSOR; + } + + const environmentalSensorProperties = [ + 'temperature', + 'humidity', + 'pressure', + 'illuminance', + 'illuminance_lux', + 'soil_moisture', + 'co2', + 'voc', + 'formaldehyde', + 'pm25', + 'pm10', + ]; + if (environmentalSensorProperties.some((prop) => propertyNames.includes(prop))) { + return DeviceCategory.SENSOR; + } + + const sensorOnlyProperties = ['battery', 'linkquality']; + const hasSensorOnlyProperties = propertyNames.some((prop) => sensorOnlyProperties.includes(prop)); + const hasOtherProperties = propertyNames.some((prop) => !sensorOnlyProperties.includes(prop) && prop !== 'action'); + if (hasSensorOnlyProperties && !hasOtherProperties) { + return DeviceCategory.SENSOR; + } + + if (propertyNames.includes('action')) { + return DeviceCategory.SENSOR; + } + + return DeviceCategory.GENERIC; +}; + +// Map expose type to channel category +export const mapZhExposeToChannelCategory = (exposeType: string): ChannelCategory => { + switch (exposeType) { + case 'light': + return ChannelCategory.LIGHT; + case 'switch': + return ChannelCategory.SWITCHER; + case 'climate': + return ChannelCategory.THERMOSTAT; + case 'cover': + return ChannelCategory.WINDOW_COVERING; + case 'lock': + return ChannelCategory.LOCK; + case 'fan': + return ChannelCategory.FAN; + default: + return ChannelCategory.GENERIC; + } +}; + +// Determine data type from expose +export const mapZhTypeToDataType = ( + type: string, + valueMin?: number, + valueMax?: number, + _values?: string[], + valueStep?: number, +): DataTypeType => { + switch (type) { + case 'binary': + return DataTypeType.BOOL; + case 'numeric': + // Use FLOAT for any property with fractional step or negative range + if (valueStep !== undefined && valueStep % 1 !== 0) { + return DataTypeType.FLOAT; + } + if (valueMin !== undefined && valueMin < 0) { + return DataTypeType.FLOAT; + } + // Size unsigned types when we know the max (min defaults to 0 if unset) + if (valueMax !== undefined) { + if (valueMax <= 255) { + return DataTypeType.UCHAR; + } + if (valueMax <= 65535) { + return DataTypeType.USHORT; + } + if (valueMax <= 4294967295) { + return DataTypeType.UINT; + } + } + return DataTypeType.FLOAT; + case 'enum': + return DataTypeType.STRING; + case 'text': + return DataTypeType.STRING; + default: + return DataTypeType.STRING; + } +}; + +// Determine permissions from access bits. +// access=0 means the property has no published access — return empty array +// so callers can decide whether to skip or default. +export const mapZhAccessToPermissions = (access: number): PermissionType[] => { + const canRead = (access & ZH_ACCESS.STATE) !== 0 || (access & ZH_ACCESS.GET) !== 0; + const canWrite = (access & ZH_ACCESS.SET) !== 0; + + if (canRead && canWrite) { + return [PermissionType.READ_WRITE]; + } else if (canWrite) { + return [PermissionType.WRITE_ONLY]; + } else if (canRead) { + return [PermissionType.READ_ONLY]; + } + + // access=0: no access bits set, property is not published + return []; +}; + +// Format a snake_case identifier as Title Case +export const formatSnakeCaseToTitle = (identifier: string): string => { + return identifier.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +}; diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.exceptions.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.exceptions.ts new file mode 100644 index 0000000000..f70224beea --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.exceptions.ts @@ -0,0 +1,113 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +/** + * Exception thrown when coordinator connection fails + */ +export class ZigbeeHerdsmanConnectionException extends HttpException { + constructor(serialPort: string, reason: string) { + super( + { + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + message: `Failed to connect to Zigbee coordinator at ${serialPort}: ${reason}`, + error: 'Zigbee Herdsman Connection Error', + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } +} + +/** + * Exception thrown when a command fails + */ +export class ZigbeeHerdsmanCommandException extends HttpException { + constructor(deviceAddress: string, command: string, reason: string) { + super( + { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: `Failed to execute ${command} on device ${deviceAddress}: ${reason}`, + error: 'Zigbee Herdsman Command Error', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } +} + +/** + * Exception thrown when device is not found + */ +export class ZigbeeHerdsmanDeviceNotFoundException extends HttpException { + constructor(identifier: string) { + super( + { + statusCode: HttpStatus.NOT_FOUND, + message: `Zigbee device not found: ${identifier}`, + error: 'Device Not Found', + }, + HttpStatus.NOT_FOUND, + ); + } +} + +/** + * Exception thrown when coordinator is offline + */ +export class ZigbeeHerdsmanCoordinatorOfflineException extends HttpException { + constructor() { + super( + { + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + message: 'Zigbee coordinator is offline', + error: 'Coordinator Offline', + }, + HttpStatus.SERVICE_UNAVAILABLE, + ); + } +} + +/** + * Base exception for Zigbee Herdsman plugin errors + */ +export class DevicesZigbeeHerdsmanException extends HttpException { + constructor(message: string) { + super( + { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message, + error: 'Zigbee Herdsman Plugin Error', + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } +} + +/** + * Exception thrown when a device is not found + */ +export class DevicesZigbeeHerdsmanNotFoundException extends HttpException { + constructor(message: string) { + super( + { + statusCode: HttpStatus.NOT_FOUND, + message, + error: 'Not Found', + }, + HttpStatus.NOT_FOUND, + ); + } +} + +/** + * Exception thrown when validation fails + */ +export class DevicesZigbeeHerdsmanValidationException extends HttpException { + constructor(message: string) { + super( + { + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + message, + error: 'Validation Error', + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.openapi.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.openapi.ts new file mode 100644 index 0000000000..2e4d0ecc55 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.openapi.ts @@ -0,0 +1,105 @@ +/** + * OpenAPI extra models for Zigbee Herdsman plugin + */ +import { CreateZigbeeHerdsmanChannelPropertyDto } from './dto/create-channel-property.dto'; +import { CreateZigbeeHerdsmanChannelDto } from './dto/create-channel.dto'; +import { CreateZigbeeHerdsmanDeviceDto } from './dto/create-device.dto'; +import { + ReqZhAdoptDeviceDto, + ReqZhMappingPreviewDto, + ReqZhPermitJoinDto, + ZhAdoptChannelDefinitionDto, + ZhAdoptDeviceRequestDto, + ZhAdoptPropertyDefinitionDto, + ZhMappingExposeOverrideDto, + ZhMappingPreviewRequestDto, + ZhPermitJoinRequestDto, +} from './dto/mapping-preview.dto'; +import { UpdateZigbeeHerdsmanChannelPropertyDto } from './dto/update-channel-property.dto'; +import { UpdateZigbeeHerdsmanChannelDto } from './dto/update-channel.dto'; +import { + ZhUpdateDiscoveryDto, + ZhUpdateNetworkDto, + ZhUpdateSerialDto, + ZigbeeHerdsmanUpdatePluginConfigDto, +} from './dto/update-config.dto'; +import { UpdateZigbeeHerdsmanDeviceDto } from './dto/update-device.dto'; +import { + ZigbeeHerdsmanChannelEntity, + ZigbeeHerdsmanChannelPropertyEntity, + ZigbeeHerdsmanDeviceEntity, +} from './entities/devices-zigbee-herdsman.entity'; +import { + ZhDiscoveryConfigModel, + ZhNetworkConfigModel, + ZhSerialConfigModel, + ZigbeeHerdsmanConfigModel, +} from './models/config.model'; +import { + ZhCoordinatorInfoModel, + ZhCoordinatorInfoResponseModel, + ZhDeviceInfoModel, + ZhExposeInfoModel, + ZhExposeMappingPreviewModel, + ZhMappingPreviewModel, + ZhMappingPreviewResponseModel, + ZhMappingWarningModel, + ZhPermitJoinModel, + ZhPermitJoinResponseModel, + ZhPropertyMappingPreviewModel, + ZhSuggestedChannelModel, + ZhSuggestedDeviceModel, + ZigbeeHerdsmanDiscoveredDeviceModel, + ZigbeeHerdsmanDiscoveredDeviceResponseModel, + ZigbeeHerdsmanDiscoveredDevicesResponseModel, +} from './models/zigbee-herdsman-response.model'; + +export const DEVICES_ZIGBEE_HERDSMAN_PLUGIN_SWAGGER_EXTRA_MODELS = [ + // DTOs + CreateZigbeeHerdsmanDeviceDto, + UpdateZigbeeHerdsmanDeviceDto, + CreateZigbeeHerdsmanChannelDto, + UpdateZigbeeHerdsmanChannelDto, + CreateZigbeeHerdsmanChannelPropertyDto, + UpdateZigbeeHerdsmanChannelPropertyDto, + ZigbeeHerdsmanUpdatePluginConfigDto, + ZhUpdateSerialDto, + ZhUpdateNetworkDto, + ZhUpdateDiscoveryDto, + // Mapping preview DTOs + ZhMappingPreviewRequestDto, + ZhMappingExposeOverrideDto, + ZhAdoptDeviceRequestDto, + ZhAdoptChannelDefinitionDto, + ZhAdoptPropertyDefinitionDto, + ZhPermitJoinRequestDto, + ReqZhMappingPreviewDto, + ReqZhAdoptDeviceDto, + ReqZhPermitJoinDto, + // Config models + ZigbeeHerdsmanConfigModel, + ZhSerialConfigModel, + ZhNetworkConfigModel, + ZhDiscoveryConfigModel, + // Response models + ZigbeeHerdsmanDiscoveredDeviceModel, + ZigbeeHerdsmanDiscoveredDeviceResponseModel, + ZigbeeHerdsmanDiscoveredDevicesResponseModel, + ZhExposeInfoModel, + ZhMappingPreviewModel, + ZhMappingPreviewResponseModel, + ZhDeviceInfoModel, + ZhSuggestedDeviceModel, + ZhExposeMappingPreviewModel, + ZhSuggestedChannelModel, + ZhPropertyMappingPreviewModel, + ZhMappingWarningModel, + ZhCoordinatorInfoModel, + ZhCoordinatorInfoResponseModel, + ZhPermitJoinModel, + ZhPermitJoinResponseModel, + // Entities + ZigbeeHerdsmanDeviceEntity, + ZigbeeHerdsmanChannelEntity, + ZigbeeHerdsmanChannelPropertyEntity, +]; diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.plugin.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.plugin.ts new file mode 100644 index 0000000000..045b6134a1 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/devices-zigbee-herdsman.plugin.ts @@ -0,0 +1,256 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ConfigModule } from '../../modules/config/config.module'; +import { PluginsTypeMapperService } from '../../modules/config/services/plugins-type-mapper.service'; +import { DevicesModule } from '../../modules/devices/devices.module'; +import { CreateChannelPropertyDto } from '../../modules/devices/dto/create-channel-property.dto'; +import { CreateChannelDto } from '../../modules/devices/dto/create-channel.dto'; +import { CreateDeviceDto } from '../../modules/devices/dto/create-device.dto'; +import { UpdateChannelPropertyDto } from '../../modules/devices/dto/update-channel-property.dto'; +import { UpdateChannelDto } from '../../modules/devices/dto/update-channel.dto'; +import { UpdateDeviceDto } from '../../modules/devices/dto/update-device.dto'; +import { ChannelEntity, ChannelPropertyEntity, DeviceEntity } from '../../modules/devices/entities/devices.entity'; +import { ChannelsTypeMapperService } from '../../modules/devices/services/channels-type-mapper.service'; +import { ChannelsPropertiesTypeMapperService } from '../../modules/devices/services/channels.properties-type-mapper.service'; +import { DevicesTypeMapperService } from '../../modules/devices/services/devices-type-mapper.service'; +import { PlatformRegistryService } from '../../modules/devices/services/platform.registry.service'; +import { ExtensionsModule } from '../../modules/extensions/extensions.module'; +import { ExtensionsService } from '../../modules/extensions/services/extensions.service'; +import { PluginServiceManagerService } from '../../modules/extensions/services/plugin-service-manager.service'; +import { ApiTag } from '../../modules/swagger/decorators/api-tag.decorator'; +import { ExtendedDiscriminatorService } from '../../modules/swagger/services/extended-discriminator.service'; +import { SwaggerModelsRegistryService } from '../../modules/swagger/services/swagger-models-registry.service'; +import { SwaggerModule } from '../../modules/swagger/swagger.module'; + +import { ZigbeeHerdsmanDiscoveredDevicesController } from './controllers/zigbee-herdsman-discovered-devices.controller'; +import { + DEVICES_ZIGBEE_HERDSMAN_API_TAG_DESCRIPTION, + DEVICES_ZIGBEE_HERDSMAN_API_TAG_NAME, + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + DEVICES_ZIGBEE_HERDSMAN_TYPE, +} from './devices-zigbee-herdsman.constants'; +import { DEVICES_ZIGBEE_HERDSMAN_PLUGIN_SWAGGER_EXTRA_MODELS } from './devices-zigbee-herdsman.openapi'; +import { CreateZigbeeHerdsmanChannelPropertyDto } from './dto/create-channel-property.dto'; +import { CreateZigbeeHerdsmanChannelDto } from './dto/create-channel.dto'; +import { CreateZigbeeHerdsmanDeviceDto } from './dto/create-device.dto'; +import { UpdateZigbeeHerdsmanChannelPropertyDto } from './dto/update-channel-property.dto'; +import { UpdateZigbeeHerdsmanChannelDto } from './dto/update-channel.dto'; +import { ZigbeeHerdsmanUpdatePluginConfigDto } from './dto/update-config.dto'; +import { UpdateZigbeeHerdsmanDeviceDto } from './dto/update-device.dto'; +import { + ZigbeeHerdsmanChannelEntity, + ZigbeeHerdsmanChannelPropertyEntity, + ZigbeeHerdsmanDeviceEntity, +} from './entities/devices-zigbee-herdsman.entity'; +import { ZigbeeHerdsmanConfigModel } from './models/config.model'; +import { ZigbeeHerdsmanDevicePlatform } from './platforms/zigbee-herdsman.device.platform'; +import { ZhDeviceAdoptionService } from './services/device-adoption.service'; +import { ZhDeviceConnectivityService } from './services/device-connectivity.service'; +import { ZhMappingPreviewService } from './services/mapping-preview.service'; +import { ZigbeeHerdsmanAdapterService } from './services/zigbee-herdsman-adapter.service'; +import { ZigbeeHerdsmanConfigValidatorService } from './services/zigbee-herdsman-config-validator.service'; +import { ZigbeeHerdsmanService } from './services/zigbee-herdsman.service'; + +@ApiTag({ + tagName: DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + displayName: DEVICES_ZIGBEE_HERDSMAN_API_TAG_NAME, + description: DEVICES_ZIGBEE_HERDSMAN_API_TAG_DESCRIPTION, +}) +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ZigbeeHerdsmanDeviceEntity, + ZigbeeHerdsmanChannelEntity, + ZigbeeHerdsmanChannelPropertyEntity, + ]), + DevicesModule, + ConfigModule, + SwaggerModule, + ExtensionsModule, + ], + providers: [ + // Core services + ZigbeeHerdsmanConfigValidatorService, + ZigbeeHerdsmanAdapterService, + ZhDeviceConnectivityService, + ZhMappingPreviewService, + ZhDeviceAdoptionService, + ZigbeeHerdsmanDevicePlatform, + ZigbeeHerdsmanService, + ], + controllers: [ZigbeeHerdsmanDiscoveredDevicesController], +}) +export class DevicesZigbeeHerdsmanPlugin { + constructor( + private readonly configMapper: PluginsTypeMapperService, + private readonly zigbeeHerdsmanService: ZigbeeHerdsmanService, + private readonly devicesMapper: DevicesTypeMapperService, + private readonly channelsMapper: ChannelsTypeMapperService, + private readonly channelsPropertiesMapper: ChannelsPropertiesTypeMapperService, + private readonly zigbeeHerdsmanDevicePlatform: ZigbeeHerdsmanDevicePlatform, + private readonly platformRegistryService: PlatformRegistryService, + private readonly swaggerRegistry: SwaggerModelsRegistryService, + private readonly discriminatorRegistry: ExtendedDiscriminatorService, + private readonly extensionsService: ExtensionsService, + private readonly pluginServiceManager: PluginServiceManagerService, + ) {} + + onModuleInit() { + // Register plugin config mapper + this.configMapper.registerMapping({ + type: DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + class: ZigbeeHerdsmanConfigModel, + configDto: ZigbeeHerdsmanUpdatePluginConfigDto, + }); + + // Register device type mapper + this.devicesMapper.registerMapping< + ZigbeeHerdsmanDeviceEntity, + CreateZigbeeHerdsmanDeviceDto, + UpdateZigbeeHerdsmanDeviceDto + >({ + type: DEVICES_ZIGBEE_HERDSMAN_TYPE, + createDto: CreateZigbeeHerdsmanDeviceDto, + updateDto: UpdateZigbeeHerdsmanDeviceDto, + class: ZigbeeHerdsmanDeviceEntity, + }); + + // Register channel type mapper + this.channelsMapper.registerMapping< + ZigbeeHerdsmanChannelEntity, + CreateZigbeeHerdsmanChannelDto, + UpdateZigbeeHerdsmanChannelDto + >({ + type: DEVICES_ZIGBEE_HERDSMAN_TYPE, + createDto: CreateZigbeeHerdsmanChannelDto, + updateDto: UpdateZigbeeHerdsmanChannelDto, + class: ZigbeeHerdsmanChannelEntity, + }); + + // Register channel property type mapper + this.channelsPropertiesMapper.registerMapping< + ZigbeeHerdsmanChannelPropertyEntity, + CreateZigbeeHerdsmanChannelPropertyDto, + UpdateZigbeeHerdsmanChannelPropertyDto + >({ + type: DEVICES_ZIGBEE_HERDSMAN_TYPE, + createDto: CreateZigbeeHerdsmanChannelPropertyDto, + updateDto: UpdateZigbeeHerdsmanChannelPropertyDto, + class: ZigbeeHerdsmanChannelPropertyEntity, + }); + + // Register device platform + this.platformRegistryService.register(this.zigbeeHerdsmanDevicePlatform); + + // Register Swagger models + for (const model of DEVICES_ZIGBEE_HERDSMAN_PLUGIN_SWAGGER_EXTRA_MODELS) { + this.swaggerRegistry.register(model); + } + + // Register discriminators for polymorphic serialization + this.discriminatorRegistry.register({ + parentClass: DeviceEntity, + discriminatorProperty: 'type', + discriminatorValue: DEVICES_ZIGBEE_HERDSMAN_TYPE, + modelClass: ZigbeeHerdsmanDeviceEntity, + }); + this.discriminatorRegistry.register({ + parentClass: CreateDeviceDto, + discriminatorProperty: 'type', + discriminatorValue: DEVICES_ZIGBEE_HERDSMAN_TYPE, + modelClass: CreateZigbeeHerdsmanDeviceDto, + }); + this.discriminatorRegistry.register({ + parentClass: UpdateDeviceDto, + discriminatorProperty: 'type', + discriminatorValue: DEVICES_ZIGBEE_HERDSMAN_TYPE, + modelClass: UpdateZigbeeHerdsmanDeviceDto, + }); + this.discriminatorRegistry.register({ + parentClass: ChannelEntity, + discriminatorProperty: 'type', + discriminatorValue: DEVICES_ZIGBEE_HERDSMAN_TYPE, + modelClass: ZigbeeHerdsmanChannelEntity, + }); + this.discriminatorRegistry.register({ + parentClass: CreateChannelDto, + discriminatorProperty: 'type', + discriminatorValue: DEVICES_ZIGBEE_HERDSMAN_TYPE, + modelClass: CreateZigbeeHerdsmanChannelDto, + }); + this.discriminatorRegistry.register({ + parentClass: UpdateChannelDto, + discriminatorProperty: 'type', + discriminatorValue: DEVICES_ZIGBEE_HERDSMAN_TYPE, + modelClass: UpdateZigbeeHerdsmanChannelDto, + }); + this.discriminatorRegistry.register({ + parentClass: ChannelPropertyEntity, + discriminatorProperty: 'type', + discriminatorValue: DEVICES_ZIGBEE_HERDSMAN_TYPE, + modelClass: ZigbeeHerdsmanChannelPropertyEntity, + }); + this.discriminatorRegistry.register({ + parentClass: CreateChannelPropertyDto, + discriminatorProperty: 'type', + discriminatorValue: DEVICES_ZIGBEE_HERDSMAN_TYPE, + modelClass: CreateZigbeeHerdsmanChannelPropertyDto, + }); + this.discriminatorRegistry.register({ + parentClass: UpdateChannelPropertyDto, + discriminatorProperty: 'type', + discriminatorValue: DEVICES_ZIGBEE_HERDSMAN_TYPE, + modelClass: UpdateZigbeeHerdsmanChannelPropertyDto, + }); + + // Register plugin metadata + this.extensionsService.registerPluginMetadata({ + type: DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + name: 'Zigbee Herdsman Devices', + description: 'Direct Zigbee device integration via zigbee-herdsman', + author: 'FastyBird', + readme: `# Zigbee Herdsman Plugin + +Self-contained Zigbee device integration using the zigbee-herdsman library. Communicates directly with Zigbee coordinators — USB dongles and network-attached adapters (SLZB-06/07). + +## Features + +- **Direct Communication** - Talks to the Zigbee coordinator via USB serial or TCP +- **Self-Contained** - No external dependencies or middleware required +- **3000+ Device Support** - Uses zigbee-herdsman-converters for device definitions +- **Network Management** - Configure channel, PAN ID, network key +- **Device Pairing** - Permit join with timeout for secure pairing +- **Real-time Updates** - Receives ZCL attribute reports directly +- **Bidirectional Control** - Send commands via toZigbee converters + +## Supported Coordinators + +- Texas Instruments CC2652 (ZBDongle-P, Slaesh's, etc.) +- Silicon Labs EFR32/Ember (ZBDongle-E, SkyConnect, etc.) +- ConBee II / ConBee III (deConz adapter) +- ZiGate +- Network adapters: SLZB-06, SLZB-07, ser2net (via tcp://host:port) + +## Requirements + +- USB Zigbee coordinator or network-attached adapter +- Serial port or TCP access (the coordinator is exclusively locked) + +## Configuration + +- **Coordinator Path** - USB path (e.g., /dev/ttyUSB0) or TCP address (e.g., tcp://192.168.1.100:6638) +- **Baud Rate** - Serial baud rate (default: 115200) +- **Adapter Type** - auto, zstack, ember, deconz, or zigate +- **Channel** - Zigbee channel (11-26, recommended: 11, 15, 20, 25) +- **Network Settings** - PAN ID and network key (auto-generated on first start)`, + links: { + documentation: 'https://smart-panel.fastybird.com/docs', + repository: 'https://github.com/FastyBird/smart-panel', + }, + }); + + // Register service with plugin manager + this.pluginServiceManager.register(this.zigbeeHerdsmanService); + } +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-channel-property.dto.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-channel-property.dto.ts new file mode 100644 index 0000000000..d3f5381e3e --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-channel-property.dto.ts @@ -0,0 +1,20 @@ +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; + +import { ApiProperty, ApiSchema } from '@nestjs/swagger'; + +import { CreateChannelPropertyDto } from '../../../modules/devices/dto/create-channel-property.dto'; +import { DEVICES_ZIGBEE_HERDSMAN_TYPE } from '../devices-zigbee-herdsman.constants'; + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginCreateChannelProperty' }) +export class CreateZigbeeHerdsmanChannelPropertyDto extends CreateChannelPropertyDto { + @ApiProperty({ + description: 'Channel property type', + type: 'string', + default: DEVICES_ZIGBEE_HERDSMAN_TYPE, + example: DEVICES_ZIGBEE_HERDSMAN_TYPE, + }) + @Expose() + @IsString({ message: '[{"field":"type","reason":"Type must be a valid property type string."}]' }) + readonly type: typeof DEVICES_ZIGBEE_HERDSMAN_TYPE; +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-channel.dto.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-channel.dto.ts new file mode 100644 index 0000000000..4c9bd3324e --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-channel.dto.ts @@ -0,0 +1,20 @@ +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; + +import { ApiProperty, ApiSchema } from '@nestjs/swagger'; + +import { CreateChannelDto } from '../../../modules/devices/dto/create-channel.dto'; +import { DEVICES_ZIGBEE_HERDSMAN_TYPE } from '../devices-zigbee-herdsman.constants'; + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginCreateChannel' }) +export class CreateZigbeeHerdsmanChannelDto extends CreateChannelDto { + @ApiProperty({ + description: 'Channel type', + type: 'string', + default: DEVICES_ZIGBEE_HERDSMAN_TYPE, + example: DEVICES_ZIGBEE_HERDSMAN_TYPE, + }) + @Expose() + @IsString({ message: '[{"field":"type","reason":"Type must be a valid channel type string."}]' }) + readonly type: typeof DEVICES_ZIGBEE_HERDSMAN_TYPE; +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-device.dto.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-device.dto.ts new file mode 100644 index 0000000000..62271027f6 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/create-device.dto.ts @@ -0,0 +1,20 @@ +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; + +import { ApiProperty, ApiSchema } from '@nestjs/swagger'; + +import { CreateDeviceDto } from '../../../modules/devices/dto/create-device.dto'; +import { DEVICES_ZIGBEE_HERDSMAN_TYPE } from '../devices-zigbee-herdsman.constants'; + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginCreateDevice' }) +export class CreateZigbeeHerdsmanDeviceDto extends CreateDeviceDto { + @ApiProperty({ + description: 'Device type', + type: 'string', + default: DEVICES_ZIGBEE_HERDSMAN_TYPE, + example: DEVICES_ZIGBEE_HERDSMAN_TYPE, + }) + @Expose() + @IsString({ message: '[{"field":"type","reason":"Type must be a valid device type string."}]' }) + readonly type: typeof DEVICES_ZIGBEE_HERDSMAN_TYPE; +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/dto/mapping-preview.dto.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/mapping-preview.dto.ts new file mode 100644 index 0000000000..4a91ba7213 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/mapping-preview.dto.ts @@ -0,0 +1,254 @@ +import { Expose, Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsDefined, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from 'class-validator'; + +import { ApiProperty, ApiPropertyOptional, ApiSchema } from '@nestjs/swagger'; + +import { + ChannelCategory, + DataTypeType, + DeviceCategory, + PermissionType, + PropertyCategory, +} from '../../../modules/devices/devices.constants'; + +// ============================================================================= +// Expose Override DTO +// ============================================================================= + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginMappingExposeOverride' }) +export class ZhMappingExposeOverrideDto { + @ApiProperty({ description: 'Expose property name to override', example: 'temperature', name: 'expose_name' }) + @Expose({ name: 'expose_name' }) + @IsString() + exposeName: string; + + @ApiPropertyOptional({ + description: 'Override channel category', + enum: ChannelCategory, + enumName: 'DevicesModuleChannelCategory', + name: 'channel_category', + }) + @Expose({ name: 'channel_category' }) + @IsOptional() + @IsEnum(ChannelCategory) + channelCategory?: ChannelCategory; + + @ApiPropertyOptional({ description: 'Skip this expose during adoption', default: false }) + @Expose() + @IsOptional() + @IsBoolean() + skip?: boolean; +} + +// ============================================================================= +// Mapping Preview Request DTO +// ============================================================================= + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginMappingPreviewRequest' }) +export class ZhMappingPreviewRequestDto { + @ApiPropertyOptional({ + description: 'Override device category suggestion', + enum: DeviceCategory, + enumName: 'DevicesModuleDeviceCategory', + name: 'device_category', + }) + @Expose({ name: 'device_category' }) + @IsOptional() + @IsEnum(DeviceCategory) + deviceCategory?: DeviceCategory; + + @ApiPropertyOptional({ + description: 'Override expose mappings', + type: () => [ZhMappingExposeOverrideDto], + name: 'expose_overrides', + }) + @Expose({ name: 'expose_overrides' }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ZhMappingExposeOverrideDto) + exposeOverrides?: ZhMappingExposeOverrideDto[]; +} + +// ============================================================================= +// Adopt Device Request DTO +// ============================================================================= + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginAdoptPropertyDefinition' }) +export class ZhAdoptPropertyDefinitionDto { + @ApiProperty({ description: 'Property category', enum: PropertyCategory, enumName: 'DevicesModulePropertyCategory' }) + @Expose() + @IsEnum(PropertyCategory) + category: PropertyCategory; + + @ApiProperty({ + description: 'Property data type', + enum: DataTypeType, + enumName: 'DevicesModuleDataType', + name: 'data_type', + }) + @Expose({ name: 'data_type' }) + @IsEnum(DataTypeType) + dataType: DataTypeType; + + @ApiProperty({ + description: 'Property permissions', + enum: PermissionType, + enumName: 'DevicesModulePermissionType', + isArray: true, + }) + @Expose() + @IsArray() + @IsEnum(PermissionType, { each: true }) + permissions: PermissionType[]; + + @ApiPropertyOptional({ description: 'Property format' }) + @Expose() + @IsOptional() + format?: string[] | number[] | null; + + @ApiProperty({ + description: 'Zigbee expose property name', + example: 'temperature', + name: 'zigbee_property', + }) + @Expose({ name: 'zigbee_property' }) + @IsString() + zigbeeProperty: string; +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginAdoptChannelDefinition' }) +export class ZhAdoptChannelDefinitionDto { + @ApiProperty({ + description: 'Channel category', + enum: ChannelCategory, + enumName: 'DevicesModuleChannelCategory', + }) + @Expose() + @IsEnum(ChannelCategory) + category: ChannelCategory; + + @ApiProperty({ description: 'Channel name', example: 'Temperature Sensor' }) + @Expose() + @IsString() + name: string; + + @ApiPropertyOptional({ description: 'Channel identifier' }) + @Expose() + @IsOptional() + @IsString() + identifier?: string; + + @ApiProperty({ description: 'Channel properties', type: () => [ZhAdoptPropertyDefinitionDto] }) + @Expose() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ZhAdoptPropertyDefinitionDto) + properties: ZhAdoptPropertyDefinitionDto[]; +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginAdoptDeviceRequest' }) +export class ZhAdoptDeviceRequestDto { + @ApiProperty({ + description: 'IEEE address of the device to adopt', + example: '0x00124b002216a490', + name: 'ieee_address', + }) + @Expose({ name: 'ieee_address' }) + @IsString() + ieeeAddress: string; + + @ApiProperty({ description: 'Device name', example: 'Living Room Temperature Sensor' }) + @Expose() + @IsString() + name: string; + + @ApiProperty({ description: 'Device category', enum: DeviceCategory, enumName: 'DevicesModuleDeviceCategory' }) + @Expose() + @IsEnum(DeviceCategory) + category: DeviceCategory; + + @ApiPropertyOptional({ description: 'Device description' }) + @Expose() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Enable device', default: true }) + @Expose() + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @ApiProperty({ description: 'Channel definitions', type: () => [ZhAdoptChannelDefinitionDto] }) + @Expose() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ZhAdoptChannelDefinitionDto) + channels: ZhAdoptChannelDefinitionDto[]; +} + +// ============================================================================= +// Permit Join Request DTO +// ============================================================================= + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginPermitJoinRequest' }) +export class ZhPermitJoinRequestDto { + @ApiProperty({ description: 'Enable or disable permit join', type: 'boolean' }) + @Expose() + @IsBoolean() + enabled: boolean; + + @ApiPropertyOptional({ description: 'Timeout in seconds (1-254)', type: 'number', minimum: 1, maximum: 254 }) + @Expose() + @IsOptional() + @IsInt({ message: '[{"field":"timeout","reason":"Timeout must be a whole number."}]' }) + @Min(1, { message: '[{"field":"timeout","reason":"Timeout minimum value must be at least 1 second."}]' }) + @Max(254, { message: '[{"field":"timeout","reason":"Timeout maximum value must be at most 254 seconds."}]' }) + timeout?: number; +} + +// ============================================================================= +// Request Wrapper DTOs +// ============================================================================= + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginReqMappingPreview' }) +export class ReqZhMappingPreviewDto { + @ApiPropertyOptional({ description: 'Mapping preview request data', type: () => ZhMappingPreviewRequestDto }) + @Expose() + @IsOptional() + @ValidateNested() + @Type(() => ZhMappingPreviewRequestDto) + data?: ZhMappingPreviewRequestDto; +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginReqAdoptDevice' }) +export class ReqZhAdoptDeviceDto { + @ApiProperty({ description: 'Device adoption request data', type: () => ZhAdoptDeviceRequestDto }) + @Expose() + @IsDefined({ message: '[{"field":"data","reason":"Request body must contain a data property."}]' }) + @ValidateNested() + @Type(() => ZhAdoptDeviceRequestDto) + data: ZhAdoptDeviceRequestDto; +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginReqPermitJoin' }) +export class ReqZhPermitJoinDto { + @ApiProperty({ description: 'Permit join request data', type: () => ZhPermitJoinRequestDto }) + @Expose() + @IsDefined({ message: '[{"field":"data","reason":"Request body must contain a data property."}]' }) + @ValidateNested() + @Type(() => ZhPermitJoinRequestDto) + data: ZhPermitJoinRequestDto; +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-channel-property.dto.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-channel-property.dto.ts new file mode 100644 index 0000000000..4362f71a2c --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-channel-property.dto.ts @@ -0,0 +1,20 @@ +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; + +import { ApiProperty, ApiSchema } from '@nestjs/swagger'; + +import { UpdateChannelPropertyDto } from '../../../modules/devices/dto/update-channel-property.dto'; +import { DEVICES_ZIGBEE_HERDSMAN_TYPE } from '../devices-zigbee-herdsman.constants'; + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginUpdateChannelProperty' }) +export class UpdateZigbeeHerdsmanChannelPropertyDto extends UpdateChannelPropertyDto { + @ApiProperty({ + description: 'Channel property type', + type: 'string', + default: DEVICES_ZIGBEE_HERDSMAN_TYPE, + example: DEVICES_ZIGBEE_HERDSMAN_TYPE, + }) + @Expose() + @IsString({ message: '[{"field":"type","reason":"Type must be a valid property type string."}]' }) + readonly type: typeof DEVICES_ZIGBEE_HERDSMAN_TYPE; +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-channel.dto.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-channel.dto.ts new file mode 100644 index 0000000000..1935f778dc --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-channel.dto.ts @@ -0,0 +1,20 @@ +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; + +import { ApiProperty, ApiSchema } from '@nestjs/swagger'; + +import { UpdateChannelDto } from '../../../modules/devices/dto/update-channel.dto'; +import { DEVICES_ZIGBEE_HERDSMAN_TYPE } from '../devices-zigbee-herdsman.constants'; + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginUpdateChannel' }) +export class UpdateZigbeeHerdsmanChannelDto extends UpdateChannelDto { + @ApiProperty({ + description: 'Channel type', + type: 'string', + default: DEVICES_ZIGBEE_HERDSMAN_TYPE, + example: DEVICES_ZIGBEE_HERDSMAN_TYPE, + }) + @Expose() + @IsString({ message: '[{"field":"type","reason":"Type must be a valid channel type string."}]' }) + readonly type: typeof DEVICES_ZIGBEE_HERDSMAN_TYPE; +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-config.dto.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-config.dto.ts new file mode 100644 index 0000000000..e020786314 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-config.dto.ts @@ -0,0 +1,183 @@ +import { Expose, Transform, Type } from 'class-transformer'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, Max, Min, ValidateNested } from 'class-validator'; + +import { ApiProperty, ApiPropertyOptional, ApiSchema } from '@nestjs/swagger'; + +import { UpdatePluginConfigDto } from '../../../modules/config/dto/config.dto'; +import { + ADAPTER_TYPES, + ALLOWED_CHANNELS_MAX, + ALLOWED_CHANNELS_MIN, + AdapterType, + DEFAULT_BATTERY_DEVICE_TIMEOUT, + DEFAULT_BAUD_RATE, + DEFAULT_CHANNEL, + DEFAULT_COMMAND_RETRIES, + DEFAULT_MAINS_DEVICE_TIMEOUT, + DEFAULT_PERMIT_JOIN_TIMEOUT, + DEFAULT_SERIAL_PORT, + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, +} from '../devices-zigbee-herdsman.constants'; + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginUpdateConfigSerial' }) +export class ZhUpdateSerialDto { + @ApiPropertyOptional({ description: 'Serial port path', example: DEFAULT_SERIAL_PORT }) + @Expose() + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @IsString({ message: '[{"field":"path","reason":"Path must be a valid string."}]' }) + path?: string; + + @ApiPropertyOptional({ description: 'Baud rate', example: DEFAULT_BAUD_RATE, name: 'baud_rate' }) + @Expose({ name: 'baud_rate' }) + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @IsInt({ message: '[{"field":"baud_rate","reason":"Baud rate must be a whole number."}]' }) + @Min(9600, { message: '[{"field":"baud_rate","reason":"Baud rate minimum value must be at least 9600."}]' }) + baudRate?: number; + + @ApiPropertyOptional({ description: 'Adapter type', enum: ADAPTER_TYPES, name: 'adapter_type' }) + @Expose({ name: 'adapter_type' }) + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @IsEnum(ADAPTER_TYPES, { message: '[{"field":"adapter_type","reason":"Adapter type must be a valid adapter."}]' }) + adapterType?: AdapterType; +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginUpdateConfigNetwork' }) +export class ZhUpdateNetworkDto { + @ApiPropertyOptional({ + description: 'Zigbee channel', + example: DEFAULT_CHANNEL, + minimum: ALLOWED_CHANNELS_MIN, + maximum: ALLOWED_CHANNELS_MAX, + }) + @Expose() + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @IsInt({ message: '[{"field":"channel","reason":"Channel must be a whole number."}]' }) + @Min(ALLOWED_CHANNELS_MIN, { + message: `[{"field":"channel","reason":"Channel minimum value must be at least ${ALLOWED_CHANNELS_MIN}."}]`, + }) + @Max(ALLOWED_CHANNELS_MAX, { + message: `[{"field":"channel","reason":"Channel maximum value must be at most ${ALLOWED_CHANNELS_MAX}."}]`, + }) + channel?: number; +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginUpdateConfigDiscovery' }) +export class ZhUpdateDiscoveryDto { + @ApiPropertyOptional({ + description: 'Default permit join timeout in seconds', + example: DEFAULT_PERMIT_JOIN_TIMEOUT, + name: 'permit_join_timeout', + }) + @Expose({ name: 'permit_join_timeout' }) + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @IsInt({ + message: '[{"field":"permit_join_timeout","reason":"Permit join timeout must be a whole number."}]', + }) + @Min(0, { + message: '[{"field":"permit_join_timeout","reason":"Permit join timeout minimum value must be at least 0."}]', + }) + permitJoinTimeout?: number; + + @ApiPropertyOptional({ + description: 'Offline timeout for mains-powered devices in seconds', + example: DEFAULT_MAINS_DEVICE_TIMEOUT, + name: 'mains_device_timeout', + }) + @Expose({ name: 'mains_device_timeout' }) + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @IsInt({ + message: '[{"field":"mains_device_timeout","reason":"Mains device timeout must be a whole number."}]', + }) + @Min(60, { + message: '[{"field":"mains_device_timeout","reason":"Mains device timeout minimum value must be at least 60."}]', + }) + mainsDeviceTimeout?: number; + + @ApiPropertyOptional({ + description: 'Offline timeout for battery devices in seconds', + example: DEFAULT_BATTERY_DEVICE_TIMEOUT, + name: 'battery_device_timeout', + }) + @Expose({ name: 'battery_device_timeout' }) + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @IsInt({ + message: '[{"field":"battery_device_timeout","reason":"Battery device timeout must be a whole number."}]', + }) + @Min(60, { + message: + '[{"field":"battery_device_timeout","reason":"Battery device timeout minimum value must be at least 60."}]', + }) + batteryDeviceTimeout?: number; + + @ApiPropertyOptional({ + description: 'Command retry attempts', + example: DEFAULT_COMMAND_RETRIES, + name: 'command_retries', + }) + @Expose({ name: 'command_retries' }) + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @IsInt({ message: '[{"field":"command_retries","reason":"Command retries must be a whole number."}]' }) + @Min(1, { message: '[{"field":"command_retries","reason":"Command retries minimum value must be at least 1."}]' }) + commandRetries?: number; + + @ApiPropertyOptional({ + description: 'Synchronize all devices on startup', + example: true, + name: 'sync_on_startup', + }) + @Expose({ name: 'sync_on_startup' }) + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @IsBoolean({ message: '[{"field":"sync_on_startup","reason":"Sync on startup must be a boolean value."}]' }) + syncOnStartup?: boolean; +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginUpdateConfig' }) +export class ZigbeeHerdsmanUpdatePluginConfigDto extends UpdatePluginConfigDto { + @ApiProperty({ description: 'Plugin type identifier', example: DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME }) + @Expose() + @IsString({ message: '[{"field":"type","reason":"Type must be a valid string."}]' }) + type: typeof DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME; + + @ApiPropertyOptional({ description: 'Serial port configuration', type: () => ZhUpdateSerialDto }) + @Expose() + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @ValidateNested() + @Type(() => ZhUpdateSerialDto) + serial?: ZhUpdateSerialDto; + + @ApiPropertyOptional({ description: 'Network configuration', type: () => ZhUpdateNetworkDto }) + @Expose() + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @ValidateNested() + @Type(() => ZhUpdateNetworkDto) + network?: ZhUpdateNetworkDto; + + @ApiPropertyOptional({ description: 'Discovery configuration', type: () => ZhUpdateDiscoveryDto }) + @Expose() + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @ValidateNested() + @Type(() => ZhUpdateDiscoveryDto) + discovery?: ZhUpdateDiscoveryDto; + + @ApiPropertyOptional({ + description: 'Path to zigbee-herdsman database file', + name: 'database_path', + }) + @Expose({ name: 'database_path' }) + @Transform(({ value }: { value: unknown }) => (value === null ? undefined : value)) + @IsOptional() + @IsString({ message: '[{"field":"database_path","reason":"Database path must be a valid string."}]' }) + databasePath?: string; +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-device.dto.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-device.dto.ts new file mode 100644 index 0000000000..edae4dcab3 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/dto/update-device.dto.ts @@ -0,0 +1,20 @@ +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; + +import { ApiProperty, ApiSchema } from '@nestjs/swagger'; + +import { UpdateDeviceDto } from '../../../modules/devices/dto/update-device.dto'; +import { DEVICES_ZIGBEE_HERDSMAN_TYPE } from '../devices-zigbee-herdsman.constants'; + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginUpdateDevice' }) +export class UpdateZigbeeHerdsmanDeviceDto extends UpdateDeviceDto { + @ApiProperty({ + description: 'Device type', + type: 'string', + default: DEVICES_ZIGBEE_HERDSMAN_TYPE, + example: DEVICES_ZIGBEE_HERDSMAN_TYPE, + }) + @Expose() + @IsString({ message: '[{"field":"type","reason":"Type must be a valid device type string."}]' }) + readonly type: typeof DEVICES_ZIGBEE_HERDSMAN_TYPE; +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/entities/devices-zigbee-herdsman.entity.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/entities/devices-zigbee-herdsman.entity.ts new file mode 100644 index 0000000000..51bb4ed5d7 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/entities/devices-zigbee-herdsman.entity.ts @@ -0,0 +1,171 @@ +import { Expose } from 'class-transformer'; +import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator'; +import { ChildEntity, Column } from 'typeorm'; + +import { ApiProperty, ApiPropertyOptional, ApiSchema } from '@nestjs/swagger'; + +import { ChannelEntity, ChannelPropertyEntity, DeviceEntity } from '../../../modules/devices/entities/devices.entity'; +import { DEVICES_ZIGBEE_HERDSMAN_TYPE } from '../devices-zigbee-herdsman.constants'; + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataDevice' }) +@ChildEntity() +export class ZigbeeHerdsmanDeviceEntity extends DeviceEntity { + @ApiProperty({ + description: 'Device type', + type: 'string', + default: DEVICES_ZIGBEE_HERDSMAN_TYPE, + example: DEVICES_ZIGBEE_HERDSMAN_TYPE, + }) + @Expose() + get type(): string { + return DEVICES_ZIGBEE_HERDSMAN_TYPE; + } + + @ApiProperty({ + description: 'Zigbee IEEE address', + type: 'string', + name: 'ieee_address', + example: '0x00124b002216a490', + }) + @Expose({ name: 'ieee_address' }) + @IsString() + @Column({ name: 'ieee_address', nullable: true }) + ieeeAddress: string; + + @ApiPropertyOptional({ + description: 'Zigbee network address', + type: 'number', + name: 'network_address', + nullable: true, + }) + @Expose({ name: 'network_address' }) + @IsOptional() + @IsNumber() + @Column({ name: 'network_address', type: 'int', nullable: true }) + networkAddress: number | null = null; + + @ApiPropertyOptional({ + description: 'Device manufacturer name', + type: 'string', + name: 'manufacturer_name', + nullable: true, + }) + @Expose({ name: 'manufacturer_name' }) + @IsOptional() + @IsString() + @Column({ name: 'manufacturer_name', nullable: true }) + manufacturerName: string | null = null; + + @ApiPropertyOptional({ + description: 'Device model ID', + type: 'string', + name: 'model_id', + nullable: true, + }) + @Expose({ name: 'model_id' }) + @IsOptional() + @IsString() + @Column({ name: 'model_id', nullable: true }) + modelId: string | null = null; + + @ApiPropertyOptional({ + description: 'Device date code', + type: 'string', + name: 'date_code', + nullable: true, + }) + @Expose({ name: 'date_code' }) + @IsOptional() + @IsString() + @Column({ name: 'date_code', nullable: true }) + dateCode: string | null = null; + + @ApiPropertyOptional({ + description: 'Software build ID', + type: 'string', + name: 'software_build_id', + nullable: true, + }) + @Expose({ name: 'software_build_id' }) + @IsOptional() + @IsString() + @Column({ name: 'software_build_id', nullable: true }) + softwareBuildId: string | null = null; + + @ApiProperty({ + description: 'Whether the device interview has been completed', + type: 'boolean', + name: 'interview_completed', + default: false, + }) + @Expose({ name: 'interview_completed' }) + @IsBoolean() + @Column({ name: 'interview_completed', type: 'boolean', default: false }) + interviewCompleted: boolean = false; + + toString(): string { + return `Zigbee Herdsman Device [${this.ieeeAddress}] -> Device [${this.id}]`; + } +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataChannel' }) +@ChildEntity() +export class ZigbeeHerdsmanChannelEntity extends ChannelEntity { + @ApiProperty({ + description: 'Channel type', + type: 'string', + default: DEVICES_ZIGBEE_HERDSMAN_TYPE, + example: DEVICES_ZIGBEE_HERDSMAN_TYPE, + }) + @Expose() + get type(): string { + return DEVICES_ZIGBEE_HERDSMAN_TYPE; + } + + toString(): string { + return `Zigbee Herdsman Channel [${this.identifier}] -> Channel [${this.id}]`; + } +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataChannelProperty' }) +@ChildEntity() +export class ZigbeeHerdsmanChannelPropertyEntity extends ChannelPropertyEntity { + @ApiProperty({ + description: 'Channel property type', + type: 'string', + default: DEVICES_ZIGBEE_HERDSMAN_TYPE, + example: DEVICES_ZIGBEE_HERDSMAN_TYPE, + }) + @Expose() + get type(): string { + return DEVICES_ZIGBEE_HERDSMAN_TYPE; + } + + @ApiPropertyOptional({ + description: 'Zigbee ZCL cluster name', + type: 'string', + name: 'zigbee_cluster', + nullable: true, + }) + @Expose({ name: 'zigbee_cluster' }) + @IsOptional() + @IsString() + @Column({ name: 'zigbee_cluster', nullable: true }) + zigbeeCluster: string | null = null; + + @ApiPropertyOptional({ + description: 'Zigbee ZCL attribute name', + type: 'string', + name: 'zigbee_attribute', + nullable: true, + }) + @Expose({ name: 'zigbee_attribute' }) + @IsOptional() + @IsString() + @Column({ name: 'zigbee_attribute', nullable: true }) + zigbeeAttribute: string | null = null; + + toString(): string { + return `Zigbee Herdsman Channel Property [${this.identifier}] -> Property [${this.id}]`; + } +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/index.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/index.ts new file mode 100644 index 0000000000..35522ff252 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/index.ts @@ -0,0 +1,17 @@ +/** + * Zigbee Herdsman Plugin Exports + */ + +export * from './devices-zigbee-herdsman.constants'; +export * from './devices-zigbee-herdsman.exceptions'; +export * from './devices-zigbee-herdsman.plugin'; +export * from './dto/create-channel-property.dto'; +export * from './dto/create-channel.dto'; +export * from './dto/create-device.dto'; +export * from './dto/update-channel-property.dto'; +export * from './dto/update-channel.dto'; +export * from './dto/update-config.dto'; +export * from './dto/update-device.dto'; +export * from './entities/devices-zigbee-herdsman.entity'; +export * from './interfaces/zigbee-herdsman.interface'; +export * from './models/config.model'; diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/interfaces/zigbee-herdsman.interface.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/interfaces/zigbee-herdsman.interface.ts new file mode 100644 index 0000000000..4270f5d7ea --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/interfaces/zigbee-herdsman.interface.ts @@ -0,0 +1,195 @@ +/** + * Zigbee Herdsman Interfaces + * + * Types for direct zigbee-herdsman integration. + */ +import { InterviewStatus, ZigbeeDeviceType } from '../devices-zigbee-herdsman.constants'; + +// ============================================================================= +// Adapter Event Callbacks +// ============================================================================= + +export interface ZhAdapterCallbacks { + onDeviceJoined?: (device: ZhDeviceJoinedEvent) => void | Promise; + onDeviceInterview?: (event: ZhDeviceInterviewEvent) => void | Promise; + onDeviceLeave?: (event: ZhDeviceLeaveEvent) => void | Promise; + onDeviceAnnounce?: (event: ZhDeviceAnnounceEvent) => void | Promise; + onMessage?: (event: ZhMessageEvent) => void | Promise; + onAdapterDisconnected?: () => void | Promise; +} + +// ============================================================================= +// Adapter Events +// ============================================================================= + +export interface ZhDeviceJoinedEvent { + ieeeAddress: string; + networkAddress: number; +} + +export interface ZhDeviceInterviewEvent { + ieeeAddress: string; + status: 'started' | 'successful' | 'failed'; +} + +export interface ZhDeviceLeaveEvent { + ieeeAddress: string; + networkAddress: number; +} + +export interface ZhDeviceAnnounceEvent { + ieeeAddress: string; + networkAddress: number; +} + +export interface ZhMessageEvent { + ieeeAddress: string; + type: string; + cluster: string; + data: Record; + endpoint: number; + linkquality?: number; + groupId?: number; +} + +// ============================================================================= +// Expose Types (from zigbee-herdsman-converters) +// ============================================================================= + +export interface ZhExposeBase { + type: string; + name?: string; + label?: string; + property?: string; + access?: number; + description?: string; + category?: 'config' | 'diagnostic'; + endpoint?: string; +} + +export interface ZhExposeBinary extends ZhExposeBase { + type: 'binary'; + value_on: string | boolean; + value_off: string | boolean; + value_toggle?: string; +} + +export interface ZhExposeNumeric extends ZhExposeBase { + type: 'numeric'; + value_min?: number; + value_max?: number; + value_step?: number; + unit?: string; + presets?: ZhPreset[]; +} + +export interface ZhExposeEnum extends ZhExposeBase { + type: 'enum'; + values: string[]; +} + +export interface ZhExposeText extends ZhExposeBase { + type: 'text'; +} + +export interface ZhExposeComposite extends ZhExposeBase { + type: 'composite'; + features: ZhExpose[]; +} + +export interface ZhExposeList extends ZhExposeBase { + type: 'list'; + item_type: string; + length_min?: number; + length_max?: number; +} + +export interface ZhExposeSpecific extends ZhExposeBase { + type: 'light' | 'switch' | 'fan' | 'cover' | 'lock' | 'climate'; + features: ZhExpose[]; +} + +export type ZhExpose = + | ZhExposeBinary + | ZhExposeNumeric + | ZhExposeEnum + | ZhExposeText + | ZhExposeComposite + | ZhExposeList + | ZhExposeSpecific; + +export interface ZhPreset { + name: string; + value: number; + description?: string; +} + +// ============================================================================= +// Device Definition (from zigbee-herdsman-converters) +// ============================================================================= + +export interface ZhDeviceDefinition { + model: string; + vendor: string; + description: string; + exposes: ZhExpose[]; + fromZigbee: unknown[]; + toZigbee: unknown[]; + options?: unknown[]; +} + +// ============================================================================= +// Discovered Device Registry +// ============================================================================= + +export interface ZhDiscoveredDevice { + ieeeAddress: string; + networkAddress: number; + friendlyName: string; + type: ZigbeeDeviceType; + manufacturerName: string | null; + modelId: string | null; + dateCode: string | null; + softwareBuildId: string | null; + interviewStatus: InterviewStatus; + definition: ZhDeviceDefinition | null; + powerSource: string | null; + lastSeen: Date | null; + available: boolean; +} + +// ============================================================================= +// Group Types +// ============================================================================= + +export interface ZhGroup { + id: number; + name: string; + members: ZhGroupMember[]; +} + +export interface ZhGroupMember { + ieeeAddress: string; + endpoint: number; +} + +// ============================================================================= +// Network Configuration +// ============================================================================= + +export interface ZhNetworkConfig { + channel: number; + panId: number; + extendedPanId: number[]; + networkKey: number[]; +} + +// ============================================================================= +// Permit Join State +// ============================================================================= + +export interface ZhPermitJoinState { + enabled: boolean; + timeout: number; + remainingTime: number; +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/models/config.model.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/models/config.model.ts new file mode 100644 index 0000000000..432690d845 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/models/config.model.ts @@ -0,0 +1,205 @@ +import { Expose, Type } from 'class-transformer'; +import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, Max, Min, ValidateNested } from 'class-validator'; + +import { ApiProperty, ApiPropertyOptional, ApiSchema } from '@nestjs/swagger'; + +import { PluginConfigModel } from '../../../modules/config/models/config.model'; +import { + ADAPTER_TYPES, + ALLOWED_CHANNELS_MAX, + ALLOWED_CHANNELS_MIN, + AdapterType, + DEFAULT_ADAPTER_TYPE, + DEFAULT_BATTERY_DEVICE_TIMEOUT, + DEFAULT_BAUD_RATE, + DEFAULT_CHANNEL, + DEFAULT_COMMAND_RETRIES, + DEFAULT_DATABASE_PATH, + DEFAULT_MAINS_DEVICE_TIMEOUT, + DEFAULT_PERMIT_JOIN_TIMEOUT, + DEFAULT_SERIAL_PORT, + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, +} from '../devices-zigbee-herdsman.constants'; + +/** + * Serial port configuration + */ +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataSerialConfig' }) +export class ZhSerialConfigModel { + @Expose() + @IsString() + @ApiProperty({ + description: 'Serial port path to Zigbee coordinator', + example: DEFAULT_SERIAL_PORT, + }) + path: string = DEFAULT_SERIAL_PORT; + + @Expose({ name: 'baud_rate' }) + @IsInt() + @Min(9600) + @ApiProperty({ + name: 'baud_rate', + description: 'Serial port baud rate', + example: DEFAULT_BAUD_RATE, + }) + baudRate: number = DEFAULT_BAUD_RATE; + + @Expose({ name: 'adapter_type' }) + @IsEnum(ADAPTER_TYPES) + @ApiProperty({ + name: 'adapter_type', + description: 'Zigbee adapter type', + enum: ADAPTER_TYPES, + example: DEFAULT_ADAPTER_TYPE, + }) + adapterType: AdapterType = DEFAULT_ADAPTER_TYPE; +} + +/** + * Zigbee network configuration + */ +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataNetworkConfig' }) +export class ZhNetworkConfigModel { + @Expose() + @IsInt() + @Min(ALLOWED_CHANNELS_MIN) + @Max(ALLOWED_CHANNELS_MAX) + @ApiProperty({ + description: 'Zigbee channel (11-26, recommended: 11, 15, 20, 25)', + example: DEFAULT_CHANNEL, + minimum: ALLOWED_CHANNELS_MIN, + maximum: ALLOWED_CHANNELS_MAX, + }) + channel: number = DEFAULT_CHANNEL; + + @Expose({ name: 'pan_id' }) + @IsOptional() + @IsInt() + @ApiPropertyOptional({ + name: 'pan_id', + description: 'PAN ID (auto-generated if not set)', + nullable: true, + }) + panId: number | null = null; + + @Expose({ name: 'extended_pan_id' }) + @IsOptional() + @IsArray() + @ApiPropertyOptional({ + name: 'extended_pan_id', + description: 'Extended PAN ID (auto-generated if not set)', + type: [Number], + nullable: true, + }) + extendedPanId: number[] | null = null; + + @Expose({ name: 'network_key' }) + @IsOptional() + @IsArray() + networkKey: number[] | null = null; +} + +/** + * Discovery/join configuration + */ +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataDiscoveryConfig' }) +export class ZhDiscoveryConfigModel { + @Expose({ name: 'permit_join_timeout' }) + @IsInt() + @Min(0) + @ApiProperty({ + name: 'permit_join_timeout', + description: 'Default permit join timeout in seconds (0 = disabled, max 254)', + example: DEFAULT_PERMIT_JOIN_TIMEOUT, + }) + permitJoinTimeout: number = DEFAULT_PERMIT_JOIN_TIMEOUT; + + @Expose({ name: 'mains_device_timeout' }) + @IsInt() + @Min(60) + @ApiProperty({ + name: 'mains_device_timeout', + description: 'Offline timeout for mains-powered devices in seconds', + example: DEFAULT_MAINS_DEVICE_TIMEOUT, + }) + mainsDeviceTimeout: number = DEFAULT_MAINS_DEVICE_TIMEOUT; + + @Expose({ name: 'battery_device_timeout' }) + @IsInt() + @Min(60) + @ApiProperty({ + name: 'battery_device_timeout', + description: 'Offline timeout for battery-powered devices in seconds', + example: DEFAULT_BATTERY_DEVICE_TIMEOUT, + }) + batteryDeviceTimeout: number = DEFAULT_BATTERY_DEVICE_TIMEOUT; + + @Expose({ name: 'command_retries' }) + @IsInt() + @Min(1) + @ApiProperty({ + name: 'command_retries', + description: 'Number of command retry attempts for unreliable devices', + example: DEFAULT_COMMAND_RETRIES, + }) + commandRetries: number = DEFAULT_COMMAND_RETRIES; + + @Expose({ name: 'sync_on_startup' }) + @IsBoolean() + @ApiProperty({ + name: 'sync_on_startup', + description: 'Synchronize all devices on startup', + example: true, + }) + syncOnStartup: boolean = true; +} + +/** + * Main plugin configuration model + */ +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataConfig' }) +export class ZigbeeHerdsmanConfigModel extends PluginConfigModel { + @Expose() + @IsString() + @ApiProperty({ + description: 'Plugin type identifier', + example: DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + }) + type: string = DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME; + + @Expose() + @ValidateNested() + @Type(() => ZhSerialConfigModel) + @ApiProperty({ + description: 'Serial port configuration', + type: () => ZhSerialConfigModel, + }) + serial: ZhSerialConfigModel = new ZhSerialConfigModel(); + + @Expose() + @ValidateNested() + @Type(() => ZhNetworkConfigModel) + @ApiProperty({ + description: 'Zigbee network configuration', + type: () => ZhNetworkConfigModel, + }) + network: ZhNetworkConfigModel = new ZhNetworkConfigModel(); + + @Expose() + @ValidateNested() + @Type(() => ZhDiscoveryConfigModel) + @ApiProperty({ + description: 'Discovery and join configuration', + type: () => ZhDiscoveryConfigModel, + }) + discovery: ZhDiscoveryConfigModel = new ZhDiscoveryConfigModel(); + + @Expose({ name: 'database_path' }) + @IsString() + @ApiProperty({ + name: 'database_path', + description: 'Path to zigbee-herdsman database file', + example: DEFAULT_DATABASE_PATH, + }) + databasePath: string = DEFAULT_DATABASE_PATH; +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/models/zigbee-herdsman-response.model.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/models/zigbee-herdsman-response.model.ts new file mode 100644 index 0000000000..e3dd5a47ba --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/models/zigbee-herdsman-response.model.ts @@ -0,0 +1,590 @@ +import { Expose, Type } from 'class-transformer'; +import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested } from 'class-validator'; + +import { ApiProperty, ApiPropertyOptional, ApiSchema } from '@nestjs/swagger'; + +import { BaseSuccessResponseModel } from '../../../modules/api/models/api-response.model'; +import { + ChannelCategory, + DataTypeType, + DeviceCategory, + PermissionType, + PropertyCategory, +} from '../../../modules/devices/devices.constants'; +import { InterviewStatus } from '../devices-zigbee-herdsman.constants'; + +// ============================================================================ +// Expose Info Model +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataExposeInfo' }) +export class ZhExposeInfoModel { + @ApiProperty({ description: 'Expose type', type: 'string', example: 'numeric' }) + @Expose() + @IsString() + type: string; + + @ApiPropertyOptional({ description: 'Expose name', type: 'string', example: 'temperature' }) + @Expose() + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ description: 'Expose property key', type: 'string', example: 'temperature' }) + @Expose() + @IsOptional() + @IsString() + property?: string; + + @ApiPropertyOptional({ description: 'Expose label', type: 'string', example: 'Temperature' }) + @Expose() + @IsOptional() + @IsString() + label?: string; + + @ApiPropertyOptional({ description: 'Access bits (1=STATE, 2=SET, 4=GET)', type: 'number' }) + @Expose() + @IsOptional() + access?: number; + + @ApiPropertyOptional({ description: 'Unit of measurement', type: 'string', example: '°C' }) + @Expose() + @IsOptional() + @IsString() + unit?: string; +} + +// ============================================================================ +// Discovered Device Model +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataDiscoveredDevice' }) +export class ZigbeeHerdsmanDiscoveredDeviceModel { + @ApiProperty({ + description: 'IEEE address of the device', + type: 'string', + name: 'ieee_address', + example: '0x00124b002216a490', + }) + @Expose({ name: 'ieee_address' }) + @IsString() + ieeeAddress: string; + + @ApiProperty({ + description: 'Friendly name of the device', + type: 'string', + name: 'friendly_name', + example: 'Aqara Temp Sensor', + }) + @Expose({ name: 'friendly_name' }) + @IsString() + friendlyName: string; + + @ApiProperty({ description: 'Zigbee device type', type: 'string', example: 'EndDevice' }) + @Expose() + @IsString() + type: 'Coordinator' | 'Router' | 'EndDevice'; + + @ApiPropertyOptional({ description: 'Model ID', type: 'string', name: 'model_id', example: 'WSDCGQ11LM' }) + @Expose({ name: 'model_id' }) + @IsOptional() + @IsString() + modelId?: string; + + @ApiPropertyOptional({ description: 'Manufacturer', type: 'string', example: 'Aqara' }) + @Expose() + @IsOptional() + @IsString() + manufacturer?: string; + + @ApiPropertyOptional({ description: 'Model name', type: 'string', example: 'WSDCGQ11LM' }) + @Expose() + @IsOptional() + @IsString() + model?: string; + + @ApiPropertyOptional({ description: 'Device description', type: 'string' }) + @Expose() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ + description: 'Power source', + type: 'string', + name: 'power_source', + example: 'Battery', + }) + @Expose({ name: 'power_source' }) + @IsOptional() + @IsString() + powerSource?: string; + + @ApiProperty({ + description: 'Device interview status', + type: 'string', + name: 'interview_status', + example: 'completed', + }) + @Expose({ name: 'interview_status' }) + @IsString() + interviewStatus: InterviewStatus; + + @ApiProperty({ description: 'Whether the device is available (online)', type: 'boolean' }) + @Expose() + @IsBoolean() + available: boolean; + + @ApiProperty({ description: 'Whether the device is already adopted', type: 'boolean' }) + @Expose() + @IsBoolean() + adopted: boolean; + + @ApiPropertyOptional({ + description: 'ID of the adopted device if already adopted', + type: 'string', + name: 'adopted_device_id', + }) + @Expose({ name: 'adopted_device_id' }) + @IsOptional() + @IsString() + adoptedDeviceId?: string; + + @ApiProperty({ description: 'Device exposes/capabilities', type: () => [ZhExposeInfoModel] }) + @Expose() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ZhExposeInfoModel) + exposes: ZhExposeInfoModel[]; + + @ApiPropertyOptional({ + description: 'Suggested device category based on exposes', + enum: DeviceCategory, + enumName: 'DevicesModuleDeviceCategory', + name: 'suggested_category', + }) + @Expose({ name: 'suggested_category' }) + @IsOptional() + @IsEnum(DeviceCategory) + suggestedCategory?: DeviceCategory; + + @ApiPropertyOptional({ + description: 'Link quality indicator (0-255)', + type: 'number', + nullable: true, + }) + @Expose() + @IsOptional() + @IsInt() + lqi?: number; +} + +// ============================================================================ +// Discovered Device Response Models +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginResDiscoveredDevice' }) +export class ZigbeeHerdsmanDiscoveredDeviceResponseModel extends BaseSuccessResponseModel { + @ApiProperty({ + description: 'The actual data payload returned by the API', + type: () => ZigbeeHerdsmanDiscoveredDeviceModel, + }) + @Expose() + declare data: ZigbeeHerdsmanDiscoveredDeviceModel; +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginResDiscoveredDevices' }) +export class ZigbeeHerdsmanDiscoveredDevicesResponseModel extends BaseSuccessResponseModel< + ZigbeeHerdsmanDiscoveredDeviceModel[] +> { + @ApiProperty({ + description: 'The actual data payload returned by the API', + type: () => [ZigbeeHerdsmanDiscoveredDeviceModel], + }) + @Expose() + declare data: ZigbeeHerdsmanDiscoveredDeviceModel[]; +} + +// ============================================================================ +// Property Mapping Preview +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataPropertyMappingPreview' }) +export class ZhPropertyMappingPreviewModel { + @ApiProperty({ description: 'Property category', enum: PropertyCategory }) + @Expose() + @IsEnum(PropertyCategory) + category: PropertyCategory; + + @ApiProperty({ description: 'Property name', type: 'string' }) + @Expose() + @IsString() + name: string; + + @ApiProperty({ description: 'Zigbee expose property name', type: 'string', name: 'zigbee_property' }) + @Expose({ name: 'zigbee_property' }) + @IsString() + zigbeeProperty: string; + + @ApiProperty({ description: 'Data type', enum: DataTypeType, name: 'data_type' }) + @Expose({ name: 'data_type' }) + @IsEnum(DataTypeType) + dataType: DataTypeType; + + @ApiProperty({ description: 'Permissions', type: [String], enum: PermissionType, isArray: true }) + @Expose() + @IsArray() + @IsEnum(PermissionType, { each: true }) + permissions: PermissionType[]; + + @ApiPropertyOptional({ + description: 'Format (enum values or numeric range)', + type: 'array', + items: { oneOf: [{ type: 'string' }, { type: 'number' }] }, + nullable: true, + }) + @Expose() + @IsOptional() + format: (string | number)[] | null; + + @ApiProperty({ description: 'Whether this property is required', type: 'boolean' }) + @Expose() + @IsBoolean() + required: boolean; + + @ApiPropertyOptional({ + description: 'Current value from device state', + oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], + nullable: true, + name: 'current_value', + }) + @Expose({ name: 'current_value' }) + currentValue: string | number | boolean | null; +} + +// ============================================================================ +// Channel Mapping Preview +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataSuggestedChannel' }) +export class ZhSuggestedChannelModel { + @ApiProperty({ description: 'Channel category', enum: ChannelCategory }) + @Expose() + @IsEnum(ChannelCategory) + category: ChannelCategory; + + @ApiProperty({ description: 'Suggested channel name', type: 'string' }) + @Expose() + @IsString() + name: string; + + @ApiProperty({ description: 'Mapping confidence level', enum: ['high', 'medium', 'low'] }) + @Expose() + @IsString() + confidence: 'high' | 'medium' | 'low'; +} + +// ============================================================================ +// Expose Mapping Preview +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataExposeMappingPreview' }) +export class ZhExposeMappingPreviewModel { + @ApiProperty({ description: 'Expose name/property', type: 'string', name: 'expose_name' }) + @Expose({ name: 'expose_name' }) + @IsString() + exposeName: string; + + @ApiProperty({ description: 'Expose type', type: 'string', name: 'expose_type' }) + @Expose({ name: 'expose_type' }) + @IsString() + exposeType: string; + + @ApiProperty({ description: 'Mapping status', enum: ['mapped', 'partial', 'unmapped', 'skipped'] }) + @Expose() + @IsString() + status: 'mapped' | 'partial' | 'unmapped' | 'skipped'; + + @ApiPropertyOptional({ + description: 'Suggested channel mapping', + type: () => ZhSuggestedChannelModel, + nullable: true, + name: 'suggested_channel', + }) + @Expose({ name: 'suggested_channel' }) + @IsOptional() + @ValidateNested() + @Type(() => ZhSuggestedChannelModel) + suggestedChannel: ZhSuggestedChannelModel | null; + + @ApiProperty({ + description: 'Suggested property mappings', + type: [ZhPropertyMappingPreviewModel], + name: 'suggested_properties', + }) + @Expose({ name: 'suggested_properties' }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ZhPropertyMappingPreviewModel) + suggestedProperties: ZhPropertyMappingPreviewModel[]; + + @ApiProperty({ + description: 'Required properties that are missing', + type: [String], + enum: PropertyCategory, + isArray: true, + name: 'missing_required_properties', + }) + @Expose({ name: 'missing_required_properties' }) + @IsArray() + @IsEnum(PropertyCategory, { each: true }) + missingRequiredProperties: PropertyCategory[]; +} + +// ============================================================================ +// Mapping Warning +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataMappingWarning' }) +export class ZhMappingWarningModel { + @ApiProperty({ + description: 'Warning type', + enum: [ + 'missing_required_channel', + 'missing_required_property', + 'unsupported_expose', + 'unknown_expose_type', + 'device_not_available', + 'device_already_adopted', + 'interview_incomplete', + ], + }) + @Expose() + @IsString() + type: + | 'missing_required_channel' + | 'missing_required_property' + | 'unsupported_expose' + | 'unknown_expose_type' + | 'device_not_available' + | 'device_already_adopted' + | 'interview_incomplete'; + + @ApiPropertyOptional({ description: 'Related expose name', type: 'string', nullable: true, name: 'expose_name' }) + @Expose({ name: 'expose_name' }) + @IsOptional() + @IsString() + exposeName?: string; + + @ApiProperty({ description: 'Warning message', type: 'string' }) + @Expose() + @IsString() + message: string; + + @ApiPropertyOptional({ description: 'Suggested resolution', type: 'string', nullable: true }) + @Expose() + @IsOptional() + @IsString() + suggestion?: string; +} + +// ============================================================================ +// Suggested Device +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataSuggestedDevice' }) +export class ZhSuggestedDeviceModel { + @ApiProperty({ description: 'Device category', enum: DeviceCategory }) + @Expose() + @IsEnum(DeviceCategory) + category: DeviceCategory; + + @ApiProperty({ description: 'Suggested device name', type: 'string' }) + @Expose() + @IsString() + name: string; + + @ApiProperty({ description: 'Mapping confidence level', enum: ['high', 'medium', 'low'] }) + @Expose() + @IsString() + confidence: 'high' | 'medium' | 'low'; +} + +// ============================================================================ +// Device Info +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataDeviceInfo' }) +export class ZhDeviceInfoModel { + @ApiProperty({ description: 'IEEE address', type: 'string', name: 'ieee_address' }) + @Expose({ name: 'ieee_address' }) + @IsString() + ieeeAddress: string; + + @ApiProperty({ description: 'Friendly name', type: 'string', name: 'friendly_name' }) + @Expose({ name: 'friendly_name' }) + @IsString() + friendlyName: string; + + @ApiPropertyOptional({ description: 'Manufacturer', type: 'string', nullable: true }) + @Expose() + @IsOptional() + @IsString() + manufacturer: string | null; + + @ApiPropertyOptional({ description: 'Model', type: 'string', nullable: true }) + @Expose() + @IsOptional() + @IsString() + model: string | null; + + @ApiPropertyOptional({ description: 'Description', type: 'string', nullable: true }) + @Expose() + @IsOptional() + @IsString() + description: string | null; +} + +// ============================================================================ +// Mapping Preview +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataMappingPreview' }) +export class ZhMappingPreviewModel { + @ApiProperty({ + description: 'Device information', + type: () => ZhDeviceInfoModel, + name: 'zh_device', + }) + @Expose({ name: 'zh_device' }) + @ValidateNested() + @Type(() => ZhDeviceInfoModel) + zhDevice: ZhDeviceInfoModel; + + @ApiProperty({ + description: 'Suggested Smart Panel device', + type: () => ZhSuggestedDeviceModel, + name: 'suggested_device', + }) + @Expose({ name: 'suggested_device' }) + @ValidateNested() + @Type(() => ZhSuggestedDeviceModel) + suggestedDevice: ZhSuggestedDeviceModel; + + @ApiProperty({ description: 'Expose mapping previews', type: [ZhExposeMappingPreviewModel] }) + @Expose() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ZhExposeMappingPreviewModel) + exposes: ZhExposeMappingPreviewModel[]; + + @ApiProperty({ description: 'Mapping warnings', type: [ZhMappingWarningModel] }) + @Expose() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ZhMappingWarningModel) + warnings: ZhMappingWarningModel[]; + + @ApiProperty({ + description: 'Whether the mapping is ready for adoption', + type: 'boolean', + name: 'ready_to_adopt', + }) + @Expose({ name: 'ready_to_adopt' }) + @IsBoolean() + readyToAdopt: boolean; +} + +// ============================================================================ +// Mapping Preview Response +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginResMappingPreview' }) +export class ZhMappingPreviewResponseModel extends BaseSuccessResponseModel { + @ApiProperty({ + description: 'The actual data payload returned by the API', + type: () => ZhMappingPreviewModel, + }) + @Expose() + declare data: ZhMappingPreviewModel; +} + +// ============================================================================ +// Coordinator Info Response +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataCoordinatorInfo' }) +export class ZhCoordinatorInfoModel { + @ApiProperty({ description: 'Adapter type', type: 'string' }) + @Expose() + @IsString() + type: string; + + @ApiProperty({ description: 'Coordinator IEEE address', type: 'string', name: 'ieee_address' }) + @Expose({ name: 'ieee_address' }) + @IsString() + ieeeAddress: string; + + @ApiPropertyOptional({ description: 'Firmware version', type: 'string', nullable: true, name: 'firmware_version' }) + @Expose({ name: 'firmware_version' }) + @IsOptional() + @IsString() + firmwareVersion: string | null; + + @ApiProperty({ description: 'Zigbee channel', type: 'number' }) + @Expose() + @IsInt() + channel: number; + + @ApiProperty({ description: 'PAN ID', type: 'number', name: 'pan_id' }) + @Expose({ name: 'pan_id' }) + @IsInt() + panId: number; + + @ApiProperty({ description: 'Number of paired devices', type: 'number', name: 'paired_device_count' }) + @Expose({ name: 'paired_device_count' }) + @IsInt() + pairedDeviceCount: number; + + @ApiProperty({ description: 'Permit join state', type: 'boolean', name: 'permit_join' }) + @Expose({ name: 'permit_join' }) + @IsBoolean() + permitJoin: boolean; +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginResCoordinatorInfo' }) +export class ZhCoordinatorInfoResponseModel extends BaseSuccessResponseModel { + @ApiProperty({ + description: 'The actual data payload returned by the API', + type: () => ZhCoordinatorInfoModel, + }) + @Expose() + declare data: ZhCoordinatorInfoModel; +} + +// ============================================================================ +// Permit Join Response +// ============================================================================ + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginDataPermitJoin' }) +export class ZhPermitJoinModel { + @ApiProperty({ description: 'Whether permit join is enabled', type: 'boolean' }) + @Expose() + @IsBoolean() + enabled: boolean; + + @ApiProperty({ description: 'Timeout in seconds', type: 'number' }) + @Expose() + @IsInt() + timeout: number; +} + +@ApiSchema({ name: 'DevicesZigbeeHerdsmanPluginResPermitJoin' }) +export class ZhPermitJoinResponseModel extends BaseSuccessResponseModel { + @ApiProperty({ + description: 'The actual data payload returned by the API', + type: () => ZhPermitJoinModel, + }) + @Expose() + declare data: ZhPermitJoinModel; +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/platforms/zigbee-herdsman.device.platform.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/platforms/zigbee-herdsman.device.platform.ts new file mode 100644 index 0000000000..0266a29ff7 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/platforms/zigbee-herdsman.device.platform.ts @@ -0,0 +1,217 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +import { Injectable } from '@nestjs/common'; + +import { ExtensionLoggerService, createExtensionLogger } from '../../../common/logger'; +import { ConfigService } from '../../../modules/config/services/config.service'; +import { PermissionType } from '../../../modules/devices/devices.constants'; +import { IDevicePlatform, IDevicePropertyData } from '../../../modules/devices/platforms/device.platform'; +import { + DEFAULT_COMMAND_RETRIES, + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + DEVICES_ZIGBEE_HERDSMAN_TYPE, +} from '../devices-zigbee-herdsman.constants'; +import { ZigbeeHerdsmanDeviceEntity } from '../entities/devices-zigbee-herdsman.entity'; +import { ZigbeeHerdsmanConfigModel } from '../models/config.model'; +import { ZigbeeHerdsmanAdapterService } from '../services/zigbee-herdsman-adapter.service'; + +@Injectable() +export class ZigbeeHerdsmanDevicePlatform implements IDevicePlatform { + private readonly logger: ExtensionLoggerService = createExtensionLogger( + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + 'DevicePlatform', + ); + + constructor( + private readonly adapterService: ZigbeeHerdsmanAdapterService, + private readonly configService: ConfigService, + ) {} + + getType(): string { + return DEVICES_ZIGBEE_HERDSMAN_TYPE; + } + + async process({ device, channel, property, value }: IDevicePropertyData): Promise { + return this.processBatch([{ device, channel, property, value }]); + } + + async processBatch(updates: Array): Promise { + if (!this.adapterService.isStarted()) { + this.logger.warn('Adapter not started, cannot process commands'); + return false; + } + + const config = this.getConfig(); + const retries = config?.discovery?.commandRetries ?? DEFAULT_COMMAND_RETRIES; + + // Group updates by device IEEE address + const deviceUpdates = new Map }>(); + + for (const update of updates) { + const zhDevice = update.device as ZigbeeHerdsmanDeviceEntity; + const ieeeAddress = zhDevice.ieeeAddress; + + if (!ieeeAddress) { + this.logger.warn(`Device ${update.device.id} has no IEEE address`); + continue; + } + + // Skip read-only properties — they have no toZigbee converter + const isWritable = update.property.permissions?.some( + (p) => p === PermissionType.READ_WRITE || p === PermissionType.WRITE_ONLY, + ); + if (!isWritable) { + this.logger.debug(`Skipping read-only property ${update.property.identifier} on device ${ieeeAddress}`); + continue; + } + + // Use the property identifier as the payload key. During adoption, + // identifier is set to the zigbee expose property name (e.g. 'brightness', + // 'state'), which is what toZigbee converters expect in their key arrays. + const payloadKey = update.property.identifier; + if (!payloadKey) { + this.logger.warn(`Property ${update.property.id} has no identifier`); + continue; + } + + const existing = deviceUpdates.get(ieeeAddress) ?? { ieeeAddress, payload: {} }; + // Convert Smart Panel property values to zigbee-herdsman-converters format. + // The toZigbee converters expect specific value formats that may differ from + // how the Smart Panel stores them (e.g. state: "ON"/"OFF" not true/false). + existing.payload[payloadKey] = this.convertValueForZigbee(payloadKey, update.value); + deviceUpdates.set(ieeeAddress, existing); + } + + let success = true; + + for (const [ieeeAddress, { payload }] of deviceUpdates) { + // Re-check adapter state per device — it may have disconnected asynchronously + if (!this.adapterService.isStarted()) { + this.logger.warn('Adapter disconnected during batch processing'); + return false; + } + + const discovered = this.adapterService.getDiscoveredDevice(ieeeAddress); + if (!discovered?.definition) { + this.logger.warn(`No device definition for ${ieeeAddress}, skipping command`); + success = false; + continue; + } + + // Get the actual zigbee-herdsman Device and its first endpoint for ZCL commands + const herdsmanDevice = this.adapterService.getHerdsmanDevice(ieeeAddress); + if (!herdsmanDevice) { + this.logger.warn(`Herdsman device not found for ${ieeeAddress}`); + success = false; + continue; + } + + // Use getEndpoint(1) for the primary application endpoint (ID 1, standard in most Zigbee devices). + // Fallback: search the endpoints array by ID property, since array index !== endpoint ID. + const endpoint = + herdsmanDevice.getEndpoint?.(1) ?? + herdsmanDevice.endpoints?.find((ep: { ID: number }) => ep.ID === 1) ?? + herdsmanDevice.endpoints?.[0] ?? + null; + + if (!endpoint) { + this.logger.warn(`No endpoint found for device ${ieeeAddress}`); + success = false; + continue; + } + + // Use toZigbee converters to send commands. + // Track consumed keys so each key is only handled by the first matching converter. + const consumedKeys = new Set(); + + for (const converter of discovered.definition.toZigbee as any[]) { + if (typeof converter?.convertSet !== 'function') { + continue; + } + + const matchingKeys = Object.keys(payload).filter( + (key) => !consumedKeys.has(key) && converter.key?.includes(key), + ); + if (matchingKeys.length === 0) { + continue; + } + + // Send each key individually so a failure on one doesn't re-send others on retry. + for (const key of matchingKeys) { + let keySent = false; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const meta = { + device: herdsmanDevice, + state: payload, + logger: this.logger, + options: {}, + }; + + await converter.convertSet(endpoint, key, payload[key], meta); + keySent = true; + break; // Success for this key + } catch (error) { + const err = error as Error; + if (attempt === retries) { + this.logger.error(`Failed to send command to ${ieeeAddress} after ${retries} attempts: ${err.message}`); + success = false; + } else { + // Exponential backoff: 250ms, 500ms, 1000ms, ... + const delayMs = 250 * Math.pow(2, attempt - 1); + this.logger.debug( + `Command attempt ${attempt}/${retries} failed for ${ieeeAddress}, retrying in ${delayMs}ms...`, + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + } + + if (keySent) { + consumedKeys.add(key); + } + } + } + // Warn about payload keys that no converter handled + const unconsumed = Object.keys(payload).filter((key) => !consumedKeys.has(key)); + if (unconsumed.length > 0) { + this.logger.warn( + `No toZigbee converter found for properties [${unconsumed.join(', ')}] on device ${ieeeAddress}`, + ); + success = false; + } + } + + return success; + } + + private getConfig(): ZigbeeHerdsmanConfigModel | null { + try { + return this.configService.getPluginConfig(DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME); + } catch { + return null; + } + } + + /** + * Convert Smart Panel property values to the format expected by toZigbee converters. + * zigbee-herdsman-converters expect specific value types that may differ from how + * the Smart Panel stores/transmits them. + */ + private convertValueForZigbee(key: string, value: string | number | boolean): string | number | boolean { + // Boolean-typed properties: toZigbee converters typically expect string values + // (e.g. "ON"/"OFF" for state, "LOCK"/"UNLOCK" for lock_state) + if (typeof value === 'boolean') { + return value ? 'ON' : 'OFF'; + } + + // String boolean aliases + if (typeof value === 'string') { + const upper = value.toUpperCase(); + if (upper === 'TRUE') return 'ON'; + if (upper === 'FALSE') return 'OFF'; + } + + return value; + } +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/services/device-adoption.service.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/services/device-adoption.service.ts new file mode 100644 index 0000000000..2b23792f8a --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/services/device-adoption.service.ts @@ -0,0 +1,258 @@ +import { DataSource } from 'typeorm'; + +import { Injectable } from '@nestjs/common'; + +import { ExtensionLoggerService, createExtensionLogger } from '../../../common/logger'; +import { toInstance } from '../../../common/utils/transform.utils'; +import { + ChannelCategory, + ConnectionState, + DataTypeType, + PermissionType, + PropertyCategory, +} from '../../../modules/devices/devices.constants'; +import { ChannelsPropertiesService } from '../../../modules/devices/services/channels.properties.service'; +import { ChannelsService } from '../../../modules/devices/services/channels.service'; +import { DeviceConnectivityService } from '../../../modules/devices/services/device-connectivity.service'; +import { DevicesService } from '../../../modules/devices/services/devices.service'; +import { + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + DEVICES_ZIGBEE_HERDSMAN_TYPE, + ZH_DEVICE_INFO_PROPERTY_IDENTIFIERS, + formatSnakeCaseToTitle, +} from '../devices-zigbee-herdsman.constants'; +import { + DevicesZigbeeHerdsmanException, + DevicesZigbeeHerdsmanNotFoundException, + DevicesZigbeeHerdsmanValidationException, +} from '../devices-zigbee-herdsman.exceptions'; +import { CreateZigbeeHerdsmanChannelPropertyDto } from '../dto/create-channel-property.dto'; +import { CreateZigbeeHerdsmanChannelDto } from '../dto/create-channel.dto'; +import { CreateZigbeeHerdsmanDeviceDto } from '../dto/create-device.dto'; +import { ZhAdoptDeviceRequestDto } from '../dto/mapping-preview.dto'; +import { + ZigbeeHerdsmanChannelEntity, + ZigbeeHerdsmanChannelPropertyEntity, + ZigbeeHerdsmanDeviceEntity, +} from '../entities/devices-zigbee-herdsman.entity'; +import { ZhDiscoveredDevice } from '../interfaces/zigbee-herdsman.interface'; + +import { ZigbeeHerdsmanService } from './zigbee-herdsman.service'; + +@Injectable() +export class ZhDeviceAdoptionService { + private readonly logger: ExtensionLoggerService = createExtensionLogger( + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + 'DeviceAdoptionService', + ); + + constructor( + private readonly zigbeeHerdsmanService: ZigbeeHerdsmanService, + private readonly devicesService: DevicesService, + private readonly channelsService: ChannelsService, + private readonly channelsPropertiesService: ChannelsPropertiesService, + private readonly deviceConnectivityService: DeviceConnectivityService, + private readonly dataSource: DataSource, + ) {} + + async adoptDevice(request: ZhAdoptDeviceRequestDto): Promise { + this.logger.debug(`Adopting device ieeeAddress=${request.ieeeAddress}`); + + // Verify coordinator is online — don't adopt from stale cached data + if (!this.zigbeeHerdsmanService.isCoordinatorOnline()) { + throw new DevicesZigbeeHerdsmanValidationException('Cannot adopt device: Zigbee coordinator is offline'); + } + + // Verify device exists on network + const discovered = this.zigbeeHerdsmanService.getDiscoveredDevice(request.ieeeAddress); + if (!discovered) { + throw new DevicesZigbeeHerdsmanNotFoundException(`Device ${request.ieeeAddress} not found on Zigbee network`); + } + + // Check if already adopted — remove existing if re-adopting + const existingDevices = await this.devicesService.findAll(DEVICES_ZIGBEE_HERDSMAN_TYPE); + const existing = existingDevices.find((d) => d.ieeeAddress === request.ieeeAddress); + if (existing) { + this.logger.debug(`Removing existing device ${existing.id} for re-adoption`); + await this.devicesService.remove(existing.id); + } + + // Create device via DTO + const createDeviceDto = toInstance(CreateZigbeeHerdsmanDeviceDto, { + type: DEVICES_ZIGBEE_HERDSMAN_TYPE, + identifier: request.ieeeAddress, + name: request.name, + category: request.category, + description: request.description || null, + enabled: request.enabled ?? true, + }); + + const device = await this.devicesService.create( + createDeviceDto, + ); + + try { + // Persist Zigbee-specific columns via TypeORM (not part of the base create DTO) + const repo = this.dataSource.getRepository(ZigbeeHerdsmanDeviceEntity); + await repo.update(device.id, { + ieeeAddress: request.ieeeAddress, + networkAddress: discovered.networkAddress, + manufacturerName: discovered.manufacturerName, + modelId: discovered.modelId, + dateCode: discovered.dateCode, + softwareBuildId: discovered.softwareBuildId, + interviewCompleted: discovered.interviewStatus === 'completed', + }); + + // Set initial connection state + await this.deviceConnectivityService.setConnectionState(device.id, { + state: discovered.available ? ConnectionState.CONNECTED : ConnectionState.DISCONNECTED, + }); + + // Create device information channel + await this.createDeviceInfoChannel(device, discovered); + + // Create user-defined channels from request + for (const channelDef of request.channels) { + if (channelDef.category === ChannelCategory.DEVICE_INFORMATION) { + continue; + } + await this.createChannel(device, channelDef); + } + + // Return the fully loaded device + const fullDevice = await this.devicesService.findOne( + device.id, + DEVICES_ZIGBEE_HERDSMAN_TYPE, + ); + if (!fullDevice) { + throw new DevicesZigbeeHerdsmanException('Failed to load adopted device'); + } + + this.logger.debug(`Device adopted: ${fullDevice.id} (${fullDevice.name})`); + return fullDevice; + } catch (error) { + // Cleanup on failure + this.logger.error( + `Failed to create channels, cleaning up device: ${device.id}`, + error instanceof Error ? error : String(error), + ); + try { + await this.devicesService.remove(device.id); + } catch (cleanupError) { + this.logger.error( + `Failed to cleanup device: ${device.id}`, + cleanupError instanceof Error ? cleanupError : String(cleanupError), + ); + } + + if ( + error instanceof DevicesZigbeeHerdsmanException || + error instanceof DevicesZigbeeHerdsmanNotFoundException || + error instanceof DevicesZigbeeHerdsmanValidationException + ) { + throw error; + } + + const err = error as Error; + throw new DevicesZigbeeHerdsmanException(`Failed to adopt device: ${err.message}`); + } + } + + private async createDeviceInfoChannel( + device: ZigbeeHerdsmanDeviceEntity, + discovered: ZhDiscoveredDevice, + ): Promise { + const createChannelDto = toInstance(CreateZigbeeHerdsmanChannelDto, { + device: device.id, + type: DEVICES_ZIGBEE_HERDSMAN_TYPE, + identifier: 'device_information', + name: 'Device Information', + category: ChannelCategory.DEVICE_INFORMATION, + }); + + const channel = await this.channelsService.create( + createChannelDto, + ); + + const infoProperties = [ + { + identifier: ZH_DEVICE_INFO_PROPERTY_IDENTIFIERS.MANUFACTURER, + name: 'Manufacturer', + category: PropertyCategory.MANUFACTURER, + value: discovered.definition?.vendor ?? discovered.manufacturerName ?? 'Unknown', + }, + { + identifier: ZH_DEVICE_INFO_PROPERTY_IDENTIFIERS.MODEL, + name: 'Model', + category: PropertyCategory.MODEL, + value: discovered.definition?.model ?? discovered.modelId ?? 'Unknown', + }, + { + identifier: ZH_DEVICE_INFO_PROPERTY_IDENTIFIERS.SERIAL_NUMBER, + name: 'Serial Number', + category: PropertyCategory.SERIAL_NUMBER, + value: discovered.ieeeAddress, + }, + ]; + + for (const info of infoProperties) { + const createPropertyDto = toInstance(CreateZigbeeHerdsmanChannelPropertyDto, { + type: DEVICES_ZIGBEE_HERDSMAN_TYPE, + identifier: info.identifier, + name: info.name, + category: info.category, + data_type: DataTypeType.STRING, + permissions: [PermissionType.READ_ONLY], + unit: null, + format: null, + invalid: null, + step: null, + value: info.value, + }); + + await this.channelsPropertiesService.create< + ZigbeeHerdsmanChannelPropertyEntity, + CreateZigbeeHerdsmanChannelPropertyDto + >(channel.id, createPropertyDto); + } + } + + private async createChannel( + device: ZigbeeHerdsmanDeviceEntity, + channelDef: ZhAdoptDeviceRequestDto['channels'][0], + ): Promise { + const createChannelDto = toInstance(CreateZigbeeHerdsmanChannelDto, { + device: device.id, + type: DEVICES_ZIGBEE_HERDSMAN_TYPE, + identifier: channelDef.identifier ?? channelDef.category, + name: channelDef.name, + category: channelDef.category, + }); + + const channel = await this.channelsService.create( + createChannelDto, + ); + + for (const propDef of channelDef.properties) { + const createPropertyDto = toInstance(CreateZigbeeHerdsmanChannelPropertyDto, { + type: DEVICES_ZIGBEE_HERDSMAN_TYPE, + identifier: propDef.zigbeeProperty, + name: formatSnakeCaseToTitle(propDef.zigbeeProperty), + category: propDef.category, + data_type: propDef.dataType, + permissions: propDef.permissions, + unit: null, + format: propDef.format ?? null, + invalid: null, + step: null, + value: null, + }); + + await this.channelsPropertiesService.create< + ZigbeeHerdsmanChannelPropertyEntity, + CreateZigbeeHerdsmanChannelPropertyDto + >(channel.id, createPropertyDto); + } + } +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/services/device-connectivity.service.spec.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/services/device-connectivity.service.spec.ts new file mode 100644 index 0000000000..9497892850 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/services/device-connectivity.service.spec.ts @@ -0,0 +1,101 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ConfigService } from '../../../modules/config/services/config.service'; +import { DeviceConnectivityService as CoreDeviceConnectivityService } from '../../../modules/devices/services/device-connectivity.service'; +import { DevicesService } from '../../../modules/devices/services/devices.service'; + +import { ZhDeviceConnectivityService } from './device-connectivity.service'; +import { ZigbeeHerdsmanAdapterService } from './zigbee-herdsman-adapter.service'; + +describe('ZhDeviceConnectivityService', () => { + let service: ZhDeviceConnectivityService; + let adapterService: jest.Mocked; + + beforeEach(async () => { + const mockAdapterService = { + getDiscoveredDevices: jest.fn().mockReturnValue([]), + }; + + const mockDevicesService = { + findAll: jest.fn().mockResolvedValue([]), + updateConnectionState: jest.fn().mockResolvedValue(undefined), + }; + + const mockConfigService = { + getPluginConfig: jest.fn().mockResolvedValue({ + discovery: { + mainsDeviceTimeout: 3600, + batteryDeviceTimeout: 7200, + }, + }), + }; + + const mockCoreConnectivityService = { + setConnectionState: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ZhDeviceConnectivityService, + { provide: ZigbeeHerdsmanAdapterService, useValue: mockAdapterService }, + { provide: DevicesService, useValue: mockDevicesService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: CoreDeviceConnectivityService, useValue: mockCoreConnectivityService }, + ], + }).compile(); + + service = module.get(ZhDeviceConnectivityService); + adapterService = module.get(ZigbeeHerdsmanAdapterService); + }); + + afterEach(() => { + service.stopMonitoring(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('checkConnectivity', () => { + it('should handle empty device list', async () => { + adapterService.getDiscoveredDevices.mockReturnValue([]); + await expect(service.checkConnectivity()).resolves.toBeUndefined(); + }); + + it('should skip devices without lastSeen', async () => { + adapterService.getDiscoveredDevices.mockReturnValue([ + { + ieeeAddress: '0x001', + networkAddress: 1, + friendlyName: 'Test', + type: 'EndDevice', + manufacturerName: null, + modelId: null, + dateCode: null, + softwareBuildId: null, + interviewStatus: 'completed', + definition: null, + powerSource: null, + lastSeen: null, + available: true, + }, + ]); + + await service.checkConnectivity(); + // Should not throw, device skipped + }); + }); + + describe('monitoring lifecycle', () => { + it('should start and stop monitoring without error', () => { + service.startMonitoring(); + expect(() => service.stopMonitoring()).not.toThrow(); + }); + + it('should handle multiple start calls', () => { + service.startMonitoring(); + service.startMonitoring(); // Should not create duplicate intervals + service.stopMonitoring(); + }); + }); +}); diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/services/device-connectivity.service.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/services/device-connectivity.service.ts new file mode 100644 index 0000000000..2f7e601ac4 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/services/device-connectivity.service.ts @@ -0,0 +1,130 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; + +import { ExtensionLoggerService, createExtensionLogger } from '../../../common/logger'; +import { ConfigService } from '../../../modules/config/services/config.service'; +import { ConnectionState } from '../../../modules/devices/devices.constants'; +import { DeviceConnectivityService as CoreDeviceConnectivityService } from '../../../modules/devices/services/device-connectivity.service'; +import { DevicesService } from '../../../modules/devices/services/devices.service'; +import { + DEFAULT_BATTERY_DEVICE_TIMEOUT, + DEFAULT_MAINS_DEVICE_TIMEOUT, + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + DEVICES_ZIGBEE_HERDSMAN_TYPE, +} from '../devices-zigbee-herdsman.constants'; +import { ZigbeeHerdsmanDeviceEntity } from '../entities/devices-zigbee-herdsman.entity'; +import { ZigbeeHerdsmanConfigModel } from '../models/config.model'; + +import { ZigbeeHerdsmanAdapterService } from './zigbee-herdsman-adapter.service'; + +@Injectable() +export class ZhDeviceConnectivityService implements OnModuleDestroy { + private readonly logger: ExtensionLoggerService = createExtensionLogger( + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + 'ConnectivityService', + ); + + private checkInterval: ReturnType | null = null; + // Track the last state we wrote to the DB to avoid repeated writes. + private lastDbState: Map = new Map(); + + constructor( + private readonly adapterService: ZigbeeHerdsmanAdapterService, + private readonly devicesService: DevicesService, + private readonly configService: ConfigService, + private readonly coreConnectivityService: CoreDeviceConnectivityService, + ) {} + + startMonitoring(): void { + if (this.checkInterval) { + return; + } + + // Check every 60 seconds + this.checkInterval = setInterval(() => { + void this.checkConnectivity(); + }, 60_000); + + this.logger.debug('Device connectivity monitoring started'); + } + + stopMonitoring(): void { + if (this.checkInterval) { + clearInterval(this.checkInterval); + this.checkInterval = null; + } + this.lastDbState.clear(); + } + + onModuleDestroy(): void { + this.stopMonitoring(); + } + + async checkConnectivity(): Promise { + try { + const config = this.configService.getPluginConfig(DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME); + const mainsTimeout = config?.discovery?.mainsDeviceTimeout ?? DEFAULT_MAINS_DEVICE_TIMEOUT; + const batteryTimeout = config?.discovery?.batteryDeviceTimeout ?? DEFAULT_BATTERY_DEVICE_TIMEOUT; + const now = Date.now(); + + const discoveredDevices = this.adapterService.getDiscoveredDevices(); + + // Collect IEEE addresses that need a DB state update + const updates: { ieeeAddress: string; online: boolean }[] = []; + + for (const discovered of discoveredDevices) { + let isOnline: boolean; + + if (!discovered.lastSeen) { + // Device was registered with available=true but has never sent a message. + // Treat as offline — it should report in before we consider it reachable. + isOnline = false; + } else { + const isBattery = discovered.powerSource?.toLowerCase().includes('battery'); + const timeout = isBattery ? batteryTimeout : mainsTimeout; + const elapsed = (now - discovered.lastSeen.getTime()) / 1000; + isOnline = elapsed < timeout; + } + + const lastWritten = this.lastDbState.get(discovered.ieeeAddress); + if (lastWritten !== isOnline) { + if (!isOnline) { + this.logger.debug(`Device ${discovered.ieeeAddress} went offline`); + } else { + this.logger.debug(`Device ${discovered.ieeeAddress} came back online`); + } + // Don't update lastDbState here — only after successful DB write + updates.push({ ieeeAddress: discovered.ieeeAddress, online: isOnline }); + } + } + + if (updates.length === 0) { + return; + } + + // Single DB query for all adopted devices, then match by IEEE address + const adoptedDevices = + await this.devicesService.findAll(DEVICES_ZIGBEE_HERDSMAN_TYPE); + const deviceByIeee = new Map(adoptedDevices.map((d) => [d.ieeeAddress, d])); + + for (const { ieeeAddress, online } of updates) { + const device = deviceByIeee.get(ieeeAddress); + if (device) { + try { + await this.coreConnectivityService.setConnectionState(device.id, { + state: online ? ConnectionState.CONNECTED : ConnectionState.DISCONNECTED, + }); + // Only mark as persisted after successful write + this.lastDbState.set(ieeeAddress, online); + } catch (error) { + const err = error as Error; + this.logger.error(`Failed to update connection state for ${ieeeAddress}: ${err.message}`); + // Don't update lastDbState — will retry on next cycle + } + } + } + } catch (error) { + const err = error as Error; + this.logger.error(`Connectivity check failed: ${err.message}`); + } + } +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/services/mapping-preview.service.spec.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/services/mapping-preview.service.spec.ts new file mode 100644 index 0000000000..86065a40d2 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/services/mapping-preview.service.spec.ts @@ -0,0 +1,110 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { DevicesService } from '../../../modules/devices/services/devices.service'; +import { INTERVIEW_STATUS } from '../devices-zigbee-herdsman.constants'; + +import { ZhMappingPreviewService } from './mapping-preview.service'; +import { ZigbeeHerdsmanService } from './zigbee-herdsman.service'; + +describe('ZhMappingPreviewService', () => { + let service: ZhMappingPreviewService; + let zigbeeHerdsmanService: jest.Mocked>; + + const mockDiscoveredDevice = { + ieeeAddress: '0x00124b002216a490', + networkAddress: 12345, + friendlyName: 'WSDCGQ11LM_3039', + type: 'EndDevice' as const, + manufacturerName: 'Aqara', + modelId: 'WSDCGQ11LM', + dateCode: null, + softwareBuildId: null, + interviewStatus: INTERVIEW_STATUS.COMPLETED, + definition: { + model: 'WSDCGQ11LM', + vendor: 'Aqara', + description: 'Temperature and humidity sensor', + exposes: [ + { type: 'numeric', property: 'temperature', name: 'temperature', access: 5, unit: '°C' }, + { type: 'numeric', property: 'humidity', name: 'humidity', access: 5, unit: '%' }, + { type: 'numeric', property: 'pressure', name: 'pressure', access: 5, unit: 'hPa' }, + { type: 'numeric', property: 'battery', name: 'battery', access: 5, unit: '%' }, + { type: 'numeric', property: 'linkquality', name: 'linkquality', access: 1 }, + ], + fromZigbee: [], + toZigbee: [], + }, + powerSource: 'Battery', + lastSeen: new Date(), + available: true, + }; + + beforeEach(async () => { + zigbeeHerdsmanService = { + isCoordinatorOnline: jest.fn().mockReturnValue(true), + getDiscoveredDevice: jest.fn().mockReturnValue(mockDiscoveredDevice), + }; + + const mockDevicesService = { + findAll: jest.fn().mockResolvedValue([]), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ZhMappingPreviewService, + { provide: ZigbeeHerdsmanService, useValue: zigbeeHerdsmanService }, + { provide: DevicesService, useValue: mockDevicesService }, + ], + }).compile(); + + service = module.get(ZhMappingPreviewService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('generatePreview', () => { + it('should generate preview for temperature sensor', async () => { + const preview = await service.generatePreview('0x00124b002216a490'); + + expect(preview).toBeDefined(); + expect(preview.zhDevice.ieeeAddress).toBe('0x00124b002216a490'); + expect(preview.suggestedDevice.category).toBe('sensor'); + expect(preview.exposes.length).toBeGreaterThan(0); + expect(preview.readyToAdopt).toBe(true); + }); + + it('should map temperature expose correctly', async () => { + const preview = await service.generatePreview('0x00124b002216a490'); + + const tempExpose = preview.exposes.find((e) => e.exposeName === 'temperature'); + expect(tempExpose).toBeDefined(); + expect(tempExpose.status).toBe('mapped'); + expect(tempExpose.suggestedChannel).toBeDefined(); + expect(tempExpose.suggestedChannel.category).toBe('temperature'); + }); + + it('should throw when coordinator offline', async () => { + zigbeeHerdsmanService.isCoordinatorOnline.mockReturnValue(false); + + await expect(service.generatePreview('0x00124b002216a490')).rejects.toThrow(); + }); + + it('should throw when device not found', async () => { + zigbeeHerdsmanService.getDiscoveredDevice.mockReturnValue(undefined); + + await expect(service.generatePreview('0xNONEXISTENT')).rejects.toThrow(); + }); + + it('should handle expose override to skip', async () => { + const preview = await service.generatePreview('0x00124b002216a490', { + exposeOverrides: [{ exposeName: 'linkquality', skip: true }], + }); + + const lqExpose = preview.exposes.find((e) => e.exposeName === 'linkquality'); + expect(lqExpose).toBeDefined(); + expect(lqExpose.status).toBe('skipped'); + }); + }); +}); diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/services/mapping-preview.service.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/services/mapping-preview.service.ts new file mode 100644 index 0000000000..390d98697c --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/services/mapping-preview.service.ts @@ -0,0 +1,333 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return */ +import { Injectable } from '@nestjs/common'; + +import { ExtensionLoggerService, createExtensionLogger } from '../../../common/logger'; +import { ChannelCategory, PermissionType, PropertyCategory } from '../../../modules/devices/devices.constants'; +import { DevicesService } from '../../../modules/devices/services/devices.service'; +import { + COMMON_PROPERTY_MAPPINGS, + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + DEVICES_ZIGBEE_HERDSMAN_TYPE, + ZH_SPECIFIC_TYPES, + extractExposeInfo, + formatSnakeCaseToTitle, + mapZhAccessToPermissions, + mapZhCategoryToDeviceCategory, + mapZhExposeToChannelCategory, + mapZhTypeToDataType, +} from '../devices-zigbee-herdsman.constants'; +import { + DevicesZigbeeHerdsmanNotFoundException, + DevicesZigbeeHerdsmanValidationException, + ZigbeeHerdsmanCoordinatorOfflineException, +} from '../devices-zigbee-herdsman.exceptions'; +import { ZhMappingPreviewRequestDto } from '../dto/mapping-preview.dto'; +import { ZigbeeHerdsmanDeviceEntity } from '../entities/devices-zigbee-herdsman.entity'; +import { ZhExpose } from '../interfaces/zigbee-herdsman.interface'; +import { + ZhDeviceInfoModel, + ZhExposeMappingPreviewModel, + ZhMappingPreviewModel, + ZhMappingWarningModel, + ZhPropertyMappingPreviewModel, + ZhSuggestedChannelModel, + ZhSuggestedDeviceModel, +} from '../models/zigbee-herdsman-response.model'; + +import { ZigbeeHerdsmanService } from './zigbee-herdsman.service'; + +@Injectable() +export class ZhMappingPreviewService { + private readonly logger: ExtensionLoggerService = createExtensionLogger( + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + 'MappingPreviewService', + ); + + constructor( + private readonly zigbeeHerdsmanService: ZigbeeHerdsmanService, + private readonly devicesService: DevicesService, + ) {} + + async generatePreview(ieeeAddress: string, request?: ZhMappingPreviewRequestDto): Promise { + if (!this.zigbeeHerdsmanService.isCoordinatorOnline()) { + throw new ZigbeeHerdsmanCoordinatorOfflineException(); + } + + const discovered = this.zigbeeHerdsmanService.getDiscoveredDevice(ieeeAddress); + if (!discovered) { + throw new DevicesZigbeeHerdsmanNotFoundException(`Device with IEEE address ${ieeeAddress} not found`); + } + + if (!discovered.definition) { + throw new DevicesZigbeeHerdsmanValidationException( + `Device ${ieeeAddress} has no definition — interview may be incomplete`, + ); + } + + // Build device info + const zhDevice: ZhDeviceInfoModel = { + ieeeAddress: discovered.ieeeAddress, + friendlyName: discovered.friendlyName, + manufacturer: discovered.definition.vendor ?? null, + model: discovered.definition.model ?? null, + description: discovered.definition.description ?? null, + }; + + // Determine device category + const exposes = discovered.definition.exposes; + const { exposeTypes, propertyNames } = extractExposeInfo(exposes); + const deviceCategory = request?.deviceCategory ?? mapZhCategoryToDeviceCategory(exposeTypes, propertyNames); + + const suggestedDevice: ZhSuggestedDeviceModel = { + category: deviceCategory, + name: discovered.definition.description || discovered.friendlyName, + confidence: request?.deviceCategory ? 'high' : 'medium', + }; + + // Map exposes + const warnings: ZhMappingWarningModel[] = []; + const exposeMappings: ZhExposeMappingPreviewModel[] = []; + const skipSet = new Set(request?.exposeOverrides?.filter((o) => o.skip).map((o) => o.exposeName) ?? []); + const categoryOverrides = new Map( + request?.exposeOverrides + ?.filter((o): o is typeof o & { channelCategory: ChannelCategory } => !!o.channelCategory) + .map((o) => [o.exposeName, o.channelCategory] as [string, ChannelCategory]) ?? [], + ); + + // Check if already adopted + const adoptedDevices = await this.devicesService.findAll(DEVICES_ZIGBEE_HERDSMAN_TYPE); + const alreadyAdopted = adoptedDevices.find((d) => d.ieeeAddress === ieeeAddress); + if (alreadyAdopted) { + warnings.push({ + type: 'device_already_adopted', + message: `Device is already adopted as "${alreadyAdopted.name}"`, + suggestion: 'Re-adopting will replace the existing device', + }); + } + + // Check availability + if (!discovered.available) { + warnings.push({ + type: 'device_not_available', + message: 'Device is currently offline', + suggestion: 'The device may not respond to commands until it comes back online', + }); + } + + // Check interview status + if (discovered.interviewStatus !== 'completed') { + warnings.push({ + type: 'interview_incomplete', + message: `Device interview is ${discovered.interviewStatus}`, + suggestion: 'Wait for interview to complete for full capability mapping', + }); + } + + for (const expose of exposes) { + const exposeName = expose.property ?? expose.name ?? expose.type; + + if (skipSet.has(exposeName)) { + exposeMappings.push({ + exposeName, + exposeType: expose.type, + status: 'skipped', + suggestedChannel: null, + suggestedProperties: [], + missingRequiredProperties: [], + }); + continue; + } + + const mapping = this.mapExpose(expose, categoryOverrides.get(exposeName)); + + if (mapping) { + exposeMappings.push(mapping); + } else { + warnings.push({ + type: 'unsupported_expose', + exposeName, + message: `Expose "${exposeName}" (type: ${expose.type}) is not supported`, + }); + exposeMappings.push({ + exposeName, + exposeType: expose.type, + status: 'unmapped', + suggestedChannel: null, + suggestedProperties: [], + missingRequiredProperties: [], + }); + } + } + + const readyToAdopt = + exposeMappings.some((m) => m.status === 'mapped') && + !exposeMappings.some((m) => m.missingRequiredProperties.length > 0); + + return { + zhDevice, + suggestedDevice, + exposes: exposeMappings, + warnings, + readyToAdopt, + }; + } + + private mapExpose(expose: ZhExpose, channelCategoryOverride?: any): ZhExposeMappingPreviewModel | null { + const exposeName = expose.property ?? expose.name ?? expose.type; + const isSpecific = (ZH_SPECIFIC_TYPES as readonly string[]).includes(expose.type); + + if (isSpecific && 'features' in expose && expose.features) { + // Composite expose (light, switch, climate, etc.) + const channelCategory = channelCategoryOverride ?? mapZhExposeToChannelCategory(expose.type); + const properties: ZhPropertyMappingPreviewModel[] = []; + + for (const feature of expose.features) { + const prop = this.mapFeatureToProperty(feature); + if (prop) { + properties.push(prop); + } + } + + const suggestedChannel: ZhSuggestedChannelModel = { + category: channelCategory, + name: formatSnakeCaseToTitle(expose.type), + confidence: 'high', + }; + + return { + exposeName, + exposeType: expose.type, + status: properties.length > 0 ? 'mapped' : 'unmapped', + suggestedChannel, + suggestedProperties: properties, + missingRequiredProperties: [], + }; + } + + // Generic expose (binary, numeric, enum, text) + const exposeProperty = expose.property ?? ''; + const commonMapping = COMMON_PROPERTY_MAPPINGS[exposeProperty]; + if (commonMapping && exposeProperty) { + const channelCategory = channelCategoryOverride ?? commonMapping.channelCategory; + const property: ZhPropertyMappingPreviewModel = { + category: commonMapping.category, + name: commonMapping.name, + zigbeeProperty: exposeProperty, + dataType: commonMapping.dataType, + permissions: expose.access + ? mapZhAccessToPermissions(expose.access) + : (commonMapping.permissions ?? [PermissionType.READ_ONLY]), + format: this.extractFormat(expose), + required: true, + currentValue: null, + }; + + return { + exposeName, + exposeType: expose.type, + status: 'mapped', + suggestedChannel: { + category: channelCategory as ChannelCategory, + name: formatSnakeCaseToTitle(expose.property ?? expose.type), + confidence: 'high', + }, + suggestedProperties: [property], + missingRequiredProperties: [], + }; + } + + // Unknown generic expose (access may be 0 which is falsy but still a valid expose) + if (expose.property && expose.access !== undefined) { + const dataType = mapZhTypeToDataType( + expose.type, + 'value_min' in expose ? (expose as any).value_min : undefined, + 'value_max' in expose ? (expose as any).value_max : undefined, + undefined, + 'value_step' in expose ? (expose as any).value_step : undefined, + ); + + return { + exposeName, + exposeType: expose.type, + status: 'partial', + suggestedChannel: channelCategoryOverride + ? { + category: channelCategoryOverride, + name: formatSnakeCaseToTitle(expose.property), + confidence: 'medium', + } + : null, + suggestedProperties: [ + { + category: PropertyCategory.GENERIC, + name: formatSnakeCaseToTitle(expose.property), + zigbeeProperty: expose.property, + dataType, + permissions: mapZhAccessToPermissions(expose.access), + format: this.extractFormat(expose), + required: false, + currentValue: null, + }, + ], + missingRequiredProperties: [], + }; + } + + return null; + } + + private mapFeatureToProperty(feature: ZhExpose): ZhPropertyMappingPreviewModel | null { + if (!feature.property) { + return null; + } + + const commonMapping = COMMON_PROPERTY_MAPPINGS[feature.property]; + if (commonMapping) { + return { + category: commonMapping.category, + name: commonMapping.name, + zigbeeProperty: feature.property, + dataType: commonMapping.dataType, + permissions: feature.access + ? mapZhAccessToPermissions(feature.access) + : (commonMapping.permissions ?? [PermissionType.READ_ONLY]), + format: this.extractFormat(feature), + required: true, + currentValue: null, + }; + } + + // Fallback for unknown features + const dataType = mapZhTypeToDataType( + feature.type, + 'value_min' in feature ? (feature as any).value_min : undefined, + 'value_max' in feature ? (feature as any).value_max : undefined, + undefined, + 'value_step' in feature ? (feature as any).value_step : undefined, + ); + + return { + category: PropertyCategory.GENERIC, + name: formatSnakeCaseToTitle(feature.property), + zigbeeProperty: feature.property, + dataType, + permissions: feature.access ? mapZhAccessToPermissions(feature.access) : [], + format: this.extractFormat(feature), + required: false, + currentValue: null, + }; + } + + private extractFormat(expose: ZhExpose): (string | number)[] | null { + if ('values' in expose && expose.values) { + return expose.values; + } + if ('value_min' in expose && 'value_max' in expose) { + const numExpose = expose as any; + if (numExpose.value_min !== undefined && numExpose.value_max !== undefined) { + return [numExpose.value_min, numExpose.value_max]; + } + } + return null; + } +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-adapter.service.spec.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-adapter.service.spec.ts new file mode 100644 index 0000000000..be05d18d85 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-adapter.service.spec.ts @@ -0,0 +1,74 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { ZigbeeHerdsmanAdapterService } from './zigbee-herdsman-adapter.service'; + +describe('ZigbeeHerdsmanAdapterService', () => { + let service: ZigbeeHerdsmanAdapterService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ZigbeeHerdsmanAdapterService], + }).compile(); + + service = module.get(ZigbeeHerdsmanAdapterService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('initial state', () => { + it('should not be started initially', () => { + expect(service.isStarted()).toBe(false); + }); + + it('should not have permit join enabled', () => { + expect(service.isPermitJoinEnabled()).toBe(false); + }); + + it('should have empty discovered devices', () => { + expect(service.getDiscoveredDevices()).toEqual([]); + }); + + it('should return null for coordinator info when not started', async () => { + expect(await service.getCoordinatorInfo()).toBeNull(); + }); + }); + + describe('setCallbacks', () => { + it('should accept callbacks without error', () => { + expect(() => { + service.setCallbacks({ + onDeviceJoined: async () => {}, + onMessage: async () => {}, + }); + }).not.toThrow(); + }); + }); + + describe('stop', () => { + it('should handle stop when not started', async () => { + await expect(service.stop()).resolves.toBeUndefined(); + }); + }); + + describe('permitJoin', () => { + it('should throw when adapter not started', async () => { + await expect(service.permitJoin(true)).rejects.toThrow('Adapter not started'); + }); + }); + + describe('removeDevice', () => { + it('should throw when adapter not started', async () => { + await expect(service.removeDevice('0x00124b002216a490')).rejects.toThrow('Adapter not started'); + }); + }); + + describe('sendCommand', () => { + it('should throw when adapter not started', async () => { + await expect(service.sendCommand('0x00124b002216a490', 1, 'genOnOff', 'toggle', {})).rejects.toThrow( + 'Adapter not started', + ); + }); + }); +}); diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-adapter.service.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-adapter.service.ts new file mode 100644 index 0000000000..385c3246e3 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-adapter.service.ts @@ -0,0 +1,505 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */ +import { randomBytes, randomInt } from 'crypto'; + +import { Injectable, OnModuleDestroy } from '@nestjs/common'; + +import { ExtensionLoggerService, createExtensionLogger } from '../../../common/logger'; +import { DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME } from '../devices-zigbee-herdsman.constants'; +import { + ZhAdapterCallbacks, + ZhDeviceAnnounceEvent, + ZhDeviceInterviewEvent, + ZhDeviceJoinedEvent, + ZhDeviceLeaveEvent, + ZhDiscoveredDevice, + ZhMessageEvent, +} from '../interfaces/zigbee-herdsman.interface'; +import { ZhNetworkConfigModel, ZhSerialConfigModel } from '../models/config.model'; + +/** + * Wraps the zigbee-herdsman Controller with typed events and lifecycle management. + * Handles direct serial communication with Zigbee coordinators. + */ +@Injectable() +export class ZigbeeHerdsmanAdapterService implements OnModuleDestroy { + private readonly logger: ExtensionLoggerService = createExtensionLogger( + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + 'AdapterService', + ); + + private controller: any = null; + private callbacks: ZhAdapterCallbacks = {}; + private started = false; + private permitJoinEnabled = false; + private permitJoinTimeout: ReturnType | null = null; + private configuredChannel = 0; + private configuredPanId = 0; + private reconnectTimer: ReturnType | null = null; + private discoveredDevices: Map = new Map(); + + setCallbacks(callbacks: ZhAdapterCallbacks): void { + this.callbacks = callbacks; + } + + isStarted(): boolean { + return this.started; + } + + isPermitJoinEnabled(): boolean { + return this.permitJoinEnabled; + } + + getDiscoveredDevices(): ZhDiscoveredDevice[] { + return Array.from(this.discoveredDevices.values()); + } + + getDiscoveredDevice(ieeeAddress: string): ZhDiscoveredDevice | undefined { + return this.discoveredDevices.get(ieeeAddress); + } + + /** + * Returns the raw zigbee-herdsman Device object for direct ZCL operations. + */ + getHerdsmanDevice(ieeeAddress: string): any { + if (!this.controller || !this.started) { + return null; + } + return this.controller.getDeviceByIeeeAddr(ieeeAddress) ?? null; + } + + async start( + serialConfig: ZhSerialConfigModel, + networkConfig: ZhNetworkConfigModel, + databasePath: string, + ): Promise { + if (this.started) { + this.logger.warn('Adapter already started, stopping first'); + await this.stop(); + } + + this.logger.debug(`Starting zigbee-herdsman adapter on ${serialConfig.path}`); + + try { + // Dynamic import for zigbee-herdsman + const { Controller } = await import('zigbee-herdsman'); + + // zigbee-herdsman persists network params in its database file. + // On first start with no DB, it generates them from the values below. + // On subsequent starts, it reads from the DB and ignores these. + // We provide sensible defaults for first-time network formation. + const networkKey = networkConfig.networkKey ?? this.generateNetworkKey(); + const panId = networkConfig.panId ?? this.generatePanId(); + const extendedPanId = networkConfig.extendedPanId ?? this.generateExtendedPanId(); + + // Store configured values for getCoordinatorInfo fallback + this.configuredChannel = networkConfig.channel; + this.configuredPanId = panId; + + this.controller = new Controller({ + network: { + panID: panId, + extendedPanID: extendedPanId, + channelList: [networkConfig.channel], + networkKey, + }, + serialPort: { + path: serialConfig.path, + baudRate: serialConfig.baudRate, + rtscts: false, + adapter: serialConfig.adapterType === 'auto' ? undefined : serialConfig.adapterType, + }, + databasePath, + backupPath: databasePath.replace('.db', '-backup.db'), + adapter: { + concurrent: 16, + delay: 0, + disableLED: false, + }, + acceptJoiningDeviceHandler: () => Promise.resolve(true), + } as any); + + // Register event listeners + this.controller.on('deviceJoined', this.onDeviceJoined.bind(this)); + this.controller.on('deviceInterview', this.onDeviceInterview.bind(this)); + this.controller.on('deviceLeave', this.onDeviceLeave.bind(this)); + this.controller.on('deviceAnnounce', this.onDeviceAnnounce.bind(this)); + this.controller.on('message', this.onMessage.bind(this)); + this.controller.on('adapterDisconnected', this.onAdapterDisconnected.bind(this)); + + await this.controller.start(); + this.started = true; + + // Load existing devices + await this.loadExistingDevices(); + + this.logger.debug('Zigbee-herdsman adapter started successfully'); + } catch (error) { + const err = error as Error; + this.logger.error(`Failed to start zigbee-herdsman adapter: ${err.message}`, { stack: err.stack }); + this.started = false; + throw error; + } + } + + async stop(): Promise { + if (!this.started || !this.controller) { + return; + } + + this.logger.debug('Stopping zigbee-herdsman adapter'); + + if (this.permitJoinTimeout) { + clearTimeout(this.permitJoinTimeout); + this.permitJoinTimeout = null; + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + try { + await this.controller.stop(); + } catch (error) { + const err = error as Error; + this.logger.error(`Error stopping adapter: ${err.message}`); + } + + this.controller = null; + this.started = false; + this.permitJoinEnabled = false; + this.logger.debug('Zigbee-herdsman adapter stopped'); + } + + async permitJoin(enabled: boolean, timeout?: number): Promise { + if (!this.controller || !this.started) { + throw new Error('Adapter not started'); + } + + if (this.permitJoinTimeout) { + clearTimeout(this.permitJoinTimeout); + this.permitJoinTimeout = null; + } + + if (enabled) { + const joinTimeout = Math.min(timeout && timeout > 0 ? timeout : 254, 254); + // Pass timeout to the native zigbee-herdsman permitJoin API — + // the controller handles the timer internally and auto-disables join. + await this.controller.permitJoin(true, joinTimeout); + this.permitJoinEnabled = true; + + // Safety fallback: also set our own timer in case the controller's + // internal timer doesn't fire (e.g. adapter disconnection). + this.permitJoinTimeout = setTimeout( + () => { + this.permitJoinEnabled = false; + }, + (joinTimeout + 1) * 1000, + ); + + this.logger.debug(`Permit join enabled for ${joinTimeout} seconds`); + } else { + await this.controller.permitJoin(false); + this.permitJoinEnabled = false; + this.logger.debug('Permit join disabled'); + } + } + + async removeDevice(ieeeAddress: string): Promise { + if (!this.controller || !this.started) { + throw new Error('Adapter not started'); + } + + const device = this.controller.getDeviceByIeeeAddr(ieeeAddress); + if (!device) { + throw new Error(`Device ${ieeeAddress} not found on network`); + } + + await device.removeFromNetwork(); + this.discoveredDevices.delete(ieeeAddress); + this.logger.debug(`Device ${ieeeAddress} removed from network`); + } + + async sendCommand( + ieeeAddress: string, + endpointId: number, + clusterKey: string, + commandKey: string, + payload: Record, + ): Promise { + if (!this.controller || !this.started) { + throw new Error('Adapter not started'); + } + + const device = this.controller.getDeviceByIeeeAddr(ieeeAddress); + if (!device) { + throw new Error(`Device ${ieeeAddress} not found`); + } + + const endpoint = device.getEndpoint(endpointId); + if (!endpoint) { + throw new Error(`Endpoint ${endpointId} not found on device ${ieeeAddress}`); + } + + await endpoint.command(clusterKey, commandKey, payload); + } + + async writeAttribute( + ieeeAddress: string, + endpointId: number, + clusterKey: string, + attributes: Record, + ): Promise { + if (!this.controller || !this.started) { + throw new Error('Adapter not started'); + } + + const device = this.controller.getDeviceByIeeeAddr(ieeeAddress); + if (!device) { + throw new Error(`Device ${ieeeAddress} not found`); + } + + const endpoint = device.getEndpoint(endpointId); + if (!endpoint) { + throw new Error(`Endpoint ${endpointId} not found on device ${ieeeAddress}`); + } + + await endpoint.write(clusterKey, attributes); + } + + async getCoordinatorInfo(): Promise<{ + type: string; + ieeeAddress: string; + firmwareVersion: string | null; + channel: number; + panId: number; + pairedDeviceCount: number; + } | null> { + if (!this.controller || !this.started) { + return null; + } + + try { + const coordinator = this.controller.getDevicesByType('Coordinator')[0]; + const devices = this.controller.getDevices().filter((d: any) => d.type !== 'Coordinator'); + + let channel = this.configuredChannel; + let panId = this.configuredPanId; + + if (typeof this.controller.getNetworkParameters === 'function') { + try { + const networkParams = await this.controller.getNetworkParameters(); + if (networkParams?.channel) { + channel = networkParams.channel; + } + if (networkParams?.panID) { + panId = networkParams.panID; + } + } catch { + // Fall back to configured values + } + } + + return { + type: coordinator?.manufacturerName ?? 'Unknown', + ieeeAddress: coordinator?.ieeeAddr ?? '', + firmwareVersion: coordinator?.softwareBuildID ?? null, + channel, + panId, + pairedDeviceCount: devices.length, + }; + } catch { + return null; + } + } + + async onModuleDestroy(): Promise { + await this.stop(); + } + + // ========================================================================= + // Private methods + // ========================================================================= + + private async loadExistingDevices(): Promise { + if (!this.controller) { + return; + } + + try { + const devices = this.controller.getDevices(); + + for (const device of devices) { + if (device.type === 'Coordinator') { + continue; + } + + await this.registerDiscoveredDevice(device); + } + + this.logger.debug(`Loaded ${this.discoveredDevices.size} existing devices`); + } catch (error) { + const err = error as Error; + this.logger.error(`Failed to load existing devices: ${err.message}`); + } + } + + private async registerDiscoveredDevice(device: any): Promise { + let definition = null; + + try { + const converters = await import('zigbee-herdsman-converters'); + const findByDevice = converters.findByDevice ?? converters.default?.findByDevice; + + if (findByDevice) { + definition = await findByDevice(device); + } + } catch { + this.logger.debug(`Could not resolve device definition for ${device.ieeeAddr}`); + } + + const discoveredDevice: ZhDiscoveredDevice = { + ieeeAddress: device.ieeeAddr, + networkAddress: device.networkAddress, + friendlyName: definition?.model + ? `${definition.model}_${device.networkAddress.toString(16)}` + : String(device.ieeeAddr), + type: device.type, + manufacturerName: device.manufacturerName ?? null, + modelId: device.modelID ?? null, + dateCode: device.dateCode ?? null, + softwareBuildId: device.softwareBuildID ?? null, + interviewStatus: device.interviewCompleted ? 'completed' : device.interviewing ? 'in_progress' : 'not_started', + definition: definition + ? { + model: definition.model, + vendor: definition.vendor, + description: definition.description, + exposes: definition.exposes ?? [], + fromZigbee: definition.fromZigbee ?? [], + toZigbee: definition.toZigbee ?? [], + } + : null, + powerSource: device.powerSource ?? null, + lastSeen: device.lastSeen ? new Date(device.lastSeen) : null, + available: true, + }; + + this.discoveredDevices.set(device.ieeeAddr, discoveredDevice); + } + + private async onDeviceJoined(payload: any): Promise { + const event: ZhDeviceJoinedEvent = { + ieeeAddress: payload.device.ieeeAddr, + networkAddress: payload.device.networkAddress, + }; + + this.logger.debug(`Device joined: ${event.ieeeAddress}`); + await this.registerDiscoveredDevice(payload.device); + await this.callbacks.onDeviceJoined?.(event); + } + + private async onDeviceInterview(payload: any): Promise { + const event: ZhDeviceInterviewEvent = { + ieeeAddress: payload.device.ieeeAddr, + status: payload.status, + }; + + this.logger.debug(`Device interview: ${event.ieeeAddress} status=${event.status}`); + + // Re-register to update definition + await this.registerDiscoveredDevice(payload.device); + + // Update interview status + const discovered = this.discoveredDevices.get(event.ieeeAddress); + if (discovered) { + discovered.interviewStatus = + event.status === 'successful' ? 'completed' : event.status === 'failed' ? 'failed' : 'in_progress'; + } + + await this.callbacks.onDeviceInterview?.(event); + } + + private async onDeviceLeave(payload: any): Promise { + const event: ZhDeviceLeaveEvent = { + ieeeAddress: payload.ieeeAddr, + networkAddress: payload.networkAddress ?? 0, + }; + + this.logger.debug(`Device left: ${event.ieeeAddress}`); + this.discoveredDevices.delete(event.ieeeAddress); + + await this.callbacks.onDeviceLeave?.(event); + } + + private async onDeviceAnnounce(payload: any): Promise { + const event: ZhDeviceAnnounceEvent = { + ieeeAddress: payload.device.ieeeAddr, + networkAddress: payload.device.networkAddress, + }; + + this.logger.debug(`Device announce: ${event.ieeeAddress}`); + + const discovered = this.discoveredDevices.get(event.ieeeAddress); + if (discovered) { + discovered.available = true; + discovered.lastSeen = new Date(); + } + + await this.callbacks.onDeviceAnnounce?.(event); + } + + private async onMessage(payload: any): Promise { + const event: ZhMessageEvent = { + ieeeAddress: payload.device.ieeeAddr, + type: payload.type, + cluster: payload.cluster, + data: payload.data ?? {}, + endpoint: payload.endpoint?.ID ?? 1, + linkquality: payload.linkquality, + groupId: payload.groupID, + }; + + // Update last seen + const discovered = this.discoveredDevices.get(event.ieeeAddress); + if (discovered) { + discovered.available = true; + discovered.lastSeen = new Date(); + } + + await this.callbacks.onMessage?.(event); + } + + private async onAdapterDisconnected(): Promise { + this.logger.warn('Zigbee adapter disconnected'); + this.started = false; + + // Clean up the controller reference to prevent leaking serial port + // handles, timers, and event listeners on subsequent start() calls. + this.controller = null; + + if (this.permitJoinTimeout) { + clearTimeout(this.permitJoinTimeout); + this.permitJoinTimeout = null; + } + this.permitJoinEnabled = false; + + // Mark all devices as unavailable + for (const device of this.discoveredDevices.values()) { + device.available = false; + } + + await this.callbacks.onAdapterDisconnected?.(); + } + + private generateNetworkKey(): number[] { + return Array.from(randomBytes(16)); + } + + private generatePanId(): number { + return randomInt(1, 0xfffe); + } + + private generateExtendedPanId(): number[] { + return Array.from(randomBytes(8)); + } +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-config-validator.service.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-config-validator.service.ts new file mode 100644 index 0000000000..199aed3ddb --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman-config-validator.service.ts @@ -0,0 +1,77 @@ +import * as fs from 'fs'; + +import { Injectable } from '@nestjs/common'; + +import { ExtensionLoggerService, createExtensionLogger } from '../../../common/logger'; +import { + ALLOWED_CHANNELS_MAX, + ALLOWED_CHANNELS_MIN, + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, +} from '../devices-zigbee-herdsman.constants'; +import { ZigbeeHerdsmanConfigModel } from '../models/config.model'; + +@Injectable() +export class ZigbeeHerdsmanConfigValidatorService { + private readonly logger: ExtensionLoggerService = createExtensionLogger( + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + 'ConfigValidatorService', + ); + + validate(config: ZigbeeHerdsmanConfigModel): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Validate serial port path + if (!config.serial.path) { + errors.push('Serial port path is required'); + } else if (this.isTcpPath(config.serial.path)) { + // Network-attached coordinators (e.g. SLZB-06, tcp://host:port) + // validated by format only — actual connectivity is checked at start() + const parsed = this.parseTcpPath(config.serial.path); + if (!parsed) { + errors.push(`Invalid TCP path "${config.serial.path}". Expected format: tcp://host:port`); + } + } else { + // Local serial port — check file accessibility + try { + fs.accessSync(config.serial.path, fs.constants.R_OK | fs.constants.W_OK); + } catch { + errors.push(`Serial port ${config.serial.path} is not accessible`); + } + } + + // Validate channel + if (config.network.channel < ALLOWED_CHANNELS_MIN || config.network.channel > ALLOWED_CHANNELS_MAX) { + errors.push(`Channel must be between ${ALLOWED_CHANNELS_MIN} and ${ALLOWED_CHANNELS_MAX}`); + } + + // Validate network key length + if (config.network.networkKey && config.network.networkKey.length !== 16) { + errors.push('Network key must be exactly 16 bytes'); + } + + // Validate extended PAN ID length + if (config.network.extendedPanId && config.network.extendedPanId.length !== 8) { + errors.push('Extended PAN ID must be exactly 8 bytes'); + } + + return { valid: errors.length === 0, errors }; + } + + private isTcpPath(path: string): boolean { + return path.startsWith('tcp://'); + } + + private parseTcpPath(path: string): { host: string; port: number } | null { + const match = /^tcp:\/\/([^:]+):(\d+)$/.exec(path); + if (!match) { + return null; + } + + const port = parseInt(match[2], 10); + if (port < 1 || port > 65535) { + return null; + } + + return { host: match[1], port }; + } +} diff --git a/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman.service.ts b/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman.service.ts new file mode 100644 index 0000000000..ff24f151d9 --- /dev/null +++ b/apps/backend/src/plugins/devices-zigbee-herdsman/services/zigbee-herdsman.service.ts @@ -0,0 +1,344 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ +import { Injectable } from '@nestjs/common'; + +import { ExtensionLoggerService, createExtensionLogger } from '../../../common/logger'; +import { toInstance } from '../../../common/utils/transform.utils'; +import { ConfigService } from '../../../modules/config/services/config.service'; +import { ConnectionState } from '../../../modules/devices/devices.constants'; +import { ChannelsPropertiesService } from '../../../modules/devices/services/channels.properties.service'; +import { DeviceConnectivityService } from '../../../modules/devices/services/device-connectivity.service'; +import { DevicesService } from '../../../modules/devices/services/devices.service'; +import { + ConfigChangeResult, + IManagedPluginService, + ServiceState, +} from '../../../modules/extensions/services/managed-plugin-service.interface'; +import { + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + DEVICES_ZIGBEE_HERDSMAN_TYPE, +} from '../devices-zigbee-herdsman.constants'; +import { UpdateZigbeeHerdsmanChannelPropertyDto } from '../dto/update-channel-property.dto'; +import { ZigbeeHerdsmanChannelPropertyEntity } from '../entities/devices-zigbee-herdsman.entity'; +import { ZhDiscoveredDevice } from '../interfaces/zigbee-herdsman.interface'; +import { ZigbeeHerdsmanConfigModel } from '../models/config.model'; + +import { ZhDeviceConnectivityService } from './device-connectivity.service'; +import { ZigbeeHerdsmanAdapterService } from './zigbee-herdsman-adapter.service'; +import { ZigbeeHerdsmanConfigValidatorService } from './zigbee-herdsman-config-validator.service'; + +@Injectable() +export class ZigbeeHerdsmanService implements IManagedPluginService { + private readonly logger: ExtensionLoggerService = createExtensionLogger( + DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME, + 'Service', + ); + + readonly pluginName = DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME; + readonly serviceId = 'connector'; + + private state: ServiceState = 'stopped'; + private startStopLock = false; + + constructor( + private readonly adapterService: ZigbeeHerdsmanAdapterService, + private readonly configService: ConfigService, + private readonly configValidator: ZigbeeHerdsmanConfigValidatorService, + private readonly devicesService: DevicesService, + private readonly channelsPropertiesService: ChannelsPropertiesService, + private readonly deviceConnectivityService: DeviceConnectivityService, + private readonly zhConnectivityService: ZhDeviceConnectivityService, + ) {} + + async start(): Promise { + if (this.startStopLock) { + this.logger.warn('Start/stop already in progress'); + return; + } + + this.startStopLock = true; + this.state = 'starting'; + + try { + const config = this.getConfig(); + if (!config) { + throw new Error('Plugin configuration not found'); + } + + // Validate config before starting (catches bad serial port, invalid channel, etc.) + const validation = this.configValidator.validate(config); + if (!validation.valid) { + throw new Error(`Invalid configuration: ${validation.errors.join(', ')}`); + } + + // Set up event callbacks + this.adapterService.setCallbacks({ + onDeviceJoined: (event) => { + this.logger.debug(`Device joined network: ${event.ieeeAddress}`); + }, + onDeviceInterview: (event) => { + this.logger.debug(`Device interview ${event.status}: ${event.ieeeAddress}`); + + if (event.status === 'successful') { + this.logger.debug(`Device interview completed: ${event.ieeeAddress}`); + } + }, + onDeviceLeave: async (event) => { + this.logger.debug(`Device left network: ${event.ieeeAddress}`); + await this.markDeviceOffline(event.ieeeAddress); + }, + onMessage: async (event) => { + await this.processIncomingMessage(event.ieeeAddress, event.cluster, event.data, event.linkquality); + }, + onAdapterDisconnected: async () => { + this.logger.warn('Coordinator disconnected'); + this.state = 'error'; + await this.markAllDevicesOffline(); + }, + }); + + await this.adapterService.start(config.serial, config.network, config.databasePath); + + // Sync existing device states on startup + if (config.discovery.syncOnStartup) { + this.logger.debug('Syncing all device states on startup'); + } + + // Start device connectivity monitoring + this.zhConnectivityService.startMonitoring(); + + this.state = 'started'; + this.logger.debug('Zigbee Herdsman service started'); + } catch (error) { + const err = error as Error; + this.state = 'error'; + this.logger.error(`Failed to start service: ${err.message}`, { stack: err.stack }); + throw error; + } finally { + this.startStopLock = false; + } + } + + async stop(): Promise { + if (this.startStopLock) { + this.logger.warn('Start/stop already in progress'); + return; + } + + this.startStopLock = true; + this.state = 'stopping'; + + try { + this.zhConnectivityService.stopMonitoring(); + await this.adapterService.stop(); + this.state = 'stopped'; + this.logger.debug('Zigbee Herdsman service stopped'); + } catch (error) { + const err = error as Error; + this.state = 'error'; + this.logger.error(`Failed to stop service: ${err.message}`, { stack: err.stack }); + throw error; + } finally { + this.startStopLock = false; + } + } + + getState(): ServiceState { + return this.state; + } + + onConfigChanged(): Promise { + return Promise.resolve({ restartRequired: true }); + } + + isCoordinatorOnline(): boolean { + return this.adapterService.isStarted(); + } + + isPermitJoinEnabled(): boolean { + return this.adapterService.isPermitJoinEnabled(); + } + + getDiscoveredDevices(): ZhDiscoveredDevice[] { + return this.adapterService.getDiscoveredDevices(); + } + + getDiscoveredDevice(ieeeAddress: string): ZhDiscoveredDevice | undefined { + return this.adapterService.getDiscoveredDevice(ieeeAddress); + } + + async permitJoin(enabled: boolean, timeout?: number): Promise { + await this.adapterService.permitJoin(enabled, timeout); + } + + async removeDevice(ieeeAddress: string): Promise { + await this.adapterService.removeDevice(ieeeAddress); + } + + async getCoordinatorInfo() { + return this.adapterService.getCoordinatorInfo(); + } + + // ========================================================================= + // Private helpers + // ========================================================================= + + private getConfig(): ZigbeeHerdsmanConfigModel | null { + try { + return this.configService.getPluginConfig(DEVICES_ZIGBEE_HERDSMAN_PLUGIN_NAME); + } catch { + return null; + } + } + + private async processIncomingMessage( + ieeeAddress: string, + cluster: string, + data: Record, + linkquality?: number, + ): Promise { + try { + // Find adopted device by IEEE address + const devices = await this.devicesService.findAll(DEVICES_ZIGBEE_HERDSMAN_TYPE); + const device = devices.find((d) => (d as any).ieeeAddress === ieeeAddress); + + if (!device) { + return; // Device not adopted yet + } + + const discovered = this.adapterService.getDiscoveredDevice(ieeeAddress); + if (!discovered?.definition) { + return; + } + + // Run fromZigbee converters to translate raw ZCL data into a state object + // (e.g. { temperature: 22.5, humidity: 65.3 }) + let convertedState: Record = {}; + + const herdsmanDevice = this.adapterService.getHerdsmanDevice(ieeeAddress); + + // Build the message object that fromZigbee converters expect: + // convert(model, msg, publish, options, meta) + const msg = { + data, + cluster, + type: 'attributeReport', + device: herdsmanDevice, + endpoint: herdsmanDevice?.getEndpoint?.(1) ?? null, + linkquality: linkquality ?? 0, + }; + + const publish = () => {}; + + for (const converter of discovered.definition.fromZigbee as any[]) { + try { + if (!converter.cluster || (converter.cluster !== cluster && converter.cluster !== '*')) { + continue; + } + if (typeof converter.convert !== 'function') { + continue; + } + + const meta = { + state: convertedState, + logger: this.logger, + device: herdsmanDevice, + options: {}, + }; + + // fromZigbee converters may be sync or async + const result = await converter.convert(discovered.definition, msg, publish, {}, meta); + + if (result && typeof result === 'object' && !(result instanceof Promise)) { + convertedState = { ...convertedState, ...(result as Record) }; + } + } catch { + // Individual converter failure is not critical + } + } + + // Add linkquality to the state if present + if (linkquality !== undefined) { + convertedState['linkquality'] = linkquality; + } + + if (Object.keys(convertedState).length === 0) { + return; + } + + // Write converted values to matching channel properties. + // Property identifiers are set to zigbee expose names during adoption. + // Use channelsPropertiesService.update() (exported from DevicesModule) + // to persist values — this also triggers WebSocket events. + const deviceChannelIds = device.channels?.map((c) => c.id) ?? []; + if (deviceChannelIds.length === 0) { + return; + } + + const deviceProperties = await this.channelsPropertiesService.findAll( + deviceChannelIds, + DEVICES_ZIGBEE_HERDSMAN_TYPE, + ); + + for (const property of deviceProperties) { + const stateKey = property.identifier; + if (!stateKey || !(stateKey in convertedState)) { + continue; + } + + const value = convertedState[stateKey]; + if (value === undefined || value === null) { + continue; + } + + try { + const writeValue = typeof value === 'object' ? JSON.stringify(value) : value; + await this.channelsPropertiesService.update< + ZigbeeHerdsmanChannelPropertyEntity, + UpdateZigbeeHerdsmanChannelPropertyDto + >( + property.id, + toInstance(UpdateZigbeeHerdsmanChannelPropertyDto, { + type: DEVICES_ZIGBEE_HERDSMAN_TYPE, + value: writeValue, + }), + ); + } catch { + // Non-critical — individual property write failures + } + } + } catch (error) { + const err = error as Error; + this.logger.error(`Error processing message from ${ieeeAddress}: ${err.message}`); + } + } + + private async markDeviceOffline(ieeeAddress: string): Promise { + try { + const devices = await this.devicesService.findAll(DEVICES_ZIGBEE_HERDSMAN_TYPE); + const device = devices.find((d) => (d as any).ieeeAddress === ieeeAddress); + + if (device) { + await this.deviceConnectivityService.setConnectionState(device.id, { + state: ConnectionState.DISCONNECTED, + }); + } + } catch (error) { + const err = error as Error; + this.logger.error(`Failed to mark device ${ieeeAddress} offline: ${err.message}`); + } + } + + private async markAllDevicesOffline(): Promise { + try { + const devices = await this.devicesService.findAll(DEVICES_ZIGBEE_HERDSMAN_TYPE); + for (const device of devices) { + await this.deviceConnectivityService.setConnectionState(device.id, { + state: ConnectionState.UNKNOWN, + }); + } + } catch (error) { + const err = error as Error; + this.logger.error(`Failed to mark all devices offline: ${err.message}`); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d64c344dc7..0f9c616161 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,7 +202,7 @@ importers: version: 10.2.0(@types/eslint@9.6.1)(eslint@10.0.3(jiti@2.6.1))(prettier@3.8.1) '@vue/eslint-config-typescript': specifier: ^14.7.0 - version: 14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + version: 14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) '@vue/test-utils': specifier: ^2.4.6 version: 2.4.6 @@ -214,13 +214,13 @@ importers: version: 10.0.3(jiti@2.6.1) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)) + version: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-playwright: specifier: ^2.9.0 version: 2.9.0(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-vue: specifier: ^10 - version: 10.8.0(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))) + version: 10.8.0(@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))) jiti: specifier: ^2.6.1 version: 2.6.1 @@ -302,9 +302,6 @@ importers: '@anthropic-ai/sdk': specifier: ^0.78.0 version: 0.78.0(zod@4.3.6) - '@fastify/helmet': - specifier: ^13.0.2 - version: 13.0.2 '@fastify/multipart': specifier: ^9.4.0 version: 9.4.0 @@ -356,9 +353,6 @@ importers: '@nestjs/swagger': specifier: ^11.2.6 version: 11.2.6(@fastify/static@9.0.0)(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) - '@nestjs/throttler': - specifier: ^6.5.0 - version: 6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^11.0.0 version: 11.0.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(babel-plugin-macros@3.1.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.15.18(@swc/helpers@0.5.17))(@types/node@24.12.0)(typescript@5.9.3))) @@ -434,12 +428,15 @@ importers: rxjs: specifier: ^7.8.2 version: 7.8.2 + serialport: + specifier: ^12.0.0 + version: 12.0.0 shellies: specifier: ^1.7.0 version: 1.7.0 shellies-ds9: specifier: github:fastybird/node-shellies-ds9#main - version: https://codeload.github.com/fastybird/node-shellies-ds9/tar.gz/27756253d015aa20382c35937d9a2119a928a351 + version: https://codeload.github.com/fastybird/node-shellies-ds9/tar.gz/dcfc245794a9ef7aa0e6f0f1300da85e511f7aaf socket.io: specifier: ^4.8.3 version: 4.8.3 @@ -467,6 +464,12 @@ importers: yaml: specifier: ^2.8.2 version: 2.8.2 + zigbee-herdsman: + specifier: ^10.0.4 + version: 10.0.4 + zigbee-herdsman-converters: + specifier: ^26.26.0 + version: 26.26.0 devDependencies: '@eslint/eslintrc': specifier: ^3.3.5 @@ -527,10 +530,10 @@ importers: version: 8.18.1 '@typescript-eslint/eslint-plugin': specifier: latest - version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + version: 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: latest - version: 8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + version: 8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) eslint: specifier: ^10.0.3 version: 10.0.3(jiti@2.6.1) @@ -609,7 +612,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.5.0 - version: 4.7.0(vite@6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.41.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.41.0)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^10.0.0 version: 10.0.3(jiti@2.6.1) @@ -642,7 +645,7 @@ importers: version: 8.57.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^6.3.0 - version: 6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.41.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.41.0)(tsx@4.21.0)(yaml@2.8.2) apps/website: dependencies: @@ -700,7 +703,7 @@ importers: version: 4.2.1 tsup: specifier: ^8.5.1 - version: 8.5.1(@swc/core@1.15.18(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@swc/core@1.15.18(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -728,10 +731,10 @@ importers: version: 5.2.2(@vue/compiler-sfc@3.5.30)(prettier@3.5.3) '@typescript-eslint/eslint-plugin': specifier: latest - version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) + version: 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) '@typescript-eslint/parser': specifier: latest - version: 8.58.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) + version: 8.57.2(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) eslint: specifier: ^9.28.0 version: 9.28.0(jiti@2.6.1) @@ -1239,6 +1242,9 @@ packages: resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@discordjs/builders@1.13.1': resolution: {integrity: sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==} engines: {node: '>=16.11.0'} @@ -1557,9 +1563,6 @@ packages: '@fastify/forwarded@3.0.0': resolution: {integrity: sha512-kJExsp4JCms7ipzg7SJ3y8DwmePaELHxKYtg+tZow+k0znUTf3cb+npgyqm8+ATZOdmfgfydIebPDWM172wfyA==} - '@fastify/helmet@13.0.2': - resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==} - '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} @@ -2757,13 +2760,6 @@ packages: '@nestjs/platform-express': optional: true - '@nestjs/throttler@6.5.0': - resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} - peerDependencies: - '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 - '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 - reflect-metadata: ^0.1.13 || ^0.2.0 - '@nestjs/typeorm@11.0.0': resolution: {integrity: sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==} peerDependencies: @@ -3555,6 +3551,86 @@ packages: pinia: optional: true + '@serialport/binding-mock@10.2.2': + resolution: {integrity: sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==} + engines: {node: '>=12.0.0'} + + '@serialport/bindings-cpp@12.0.1': + resolution: {integrity: sha512-r2XOwY2dDvbW7dKqSPIk2gzsr6M6Qpe9+/Ngs94fNaNlcTRCV02PfaoDmRgcubpNVVcLATlxSxPTIDw12dbKOg==} + engines: {node: '>=16.0.0'} + + '@serialport/bindings-cpp@13.0.1': + resolution: {integrity: sha512-j8nD5Om5MaQwzptncOiajIKj5RuMy7RiretZYmW4ZpaaLBc5XrapdojjL6AVwhpMWao8iSstlwEx/hpTIAp6mw==} + engines: {node: '>=18.0.0'} + + '@serialport/bindings-interface@1.2.2': + resolution: {integrity: sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==} + engines: {node: ^12.22 || ^14.13 || >=16} + + '@serialport/parser-byte-length@12.0.0': + resolution: {integrity: sha512-0ei0txFAj+s6FTiCJFBJ1T2hpKkX8Md0Pu6dqMrYoirjPskDLJRgZGLqoy3/lnU1bkvHpnJO+9oJ3PB9v8rNlg==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-cctalk@12.0.0': + resolution: {integrity: sha512-0PfLzO9t2X5ufKuBO34DQKLXrCCqS9xz2D0pfuaLNeTkyGUBv426zxoMf3rsMRodDOZNbFblu3Ae84MOQXjnZw==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-delimiter@11.0.0': + resolution: {integrity: sha512-aZLJhlRTjSmEwllLG7S4J8s8ctRAS0cbvCpO87smLvl3e4BgzbVgF6Z6zaJd3Aji2uSiYgfedCdNc4L6W+1E2g==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-delimiter@12.0.0': + resolution: {integrity: sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-delimiter@13.0.0': + resolution: {integrity: sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig==} + engines: {node: '>=20.0.0'} + + '@serialport/parser-inter-byte-timeout@12.0.0': + resolution: {integrity: sha512-GnCh8K0NAESfhCuXAt+FfBRz1Cf9CzIgXfp7SdMgXwrtuUnCC/yuRTUFWRvuzhYKoAo1TL0hhUo77SFHUH1T/w==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-packet-length@12.0.0': + resolution: {integrity: sha512-p1hiCRqvGHHLCN/8ZiPUY/G0zrxd7gtZs251n+cfNTn+87rwcdUeu9Dps3Aadx30/sOGGFL6brIRGK4l/t7MuQ==} + engines: {node: '>=8.6.0'} + + '@serialport/parser-readline@11.0.0': + resolution: {integrity: sha512-rRAivhRkT3YO28WjmmG4FQX6L+KMb5/ikhyylRfzWPw0nSXy97+u07peS9CbHqaNvJkMhH1locp2H36aGMOEIA==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-readline@12.0.0': + resolution: {integrity: sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-readline@13.0.0': + resolution: {integrity: sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA==} + engines: {node: '>=20.0.0'} + + '@serialport/parser-ready@12.0.0': + resolution: {integrity: sha512-ygDwj3O4SDpZlbrRUraoXIoIqb8sM7aMKryGjYTIF0JRnKeB1ys8+wIp0RFMdFbO62YriUDextHB5Um5cKFSWg==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-regex@12.0.0': + resolution: {integrity: sha512-dCAVh4P/pZrLcPv9NJ2mvPRBg64L5jXuiRxIlyxxdZGH4WubwXVXY/kBTihQmiAMPxbT3yshSX8f2+feqWsxqA==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-slip-encoder@12.0.0': + resolution: {integrity: sha512-0APxDGR9YvJXTRfY+uRGhzOhTpU5akSH183RUcwzN7QXh8/1jwFsFLCu0grmAUfi+fItCkR+Xr1TcNJLR13VNA==} + engines: {node: '>=12.0.0'} + + '@serialport/parser-spacepacket@12.0.0': + resolution: {integrity: sha512-dozONxhPC/78pntuxpz/NOtVps8qIc/UZzdc/LuPvVsqCoJXiRxOg6ZtCP/W58iibJDKPZPAWPGYeZt9DJxI+Q==} + engines: {node: '>=12.0.0'} + + '@serialport/stream@12.0.0': + resolution: {integrity: sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==} + engines: {node: '>=12.0.0'} + + '@serialport/stream@13.0.0': + resolution: {integrity: sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==} + engines: {node: '>=20.0.0'} + '@shikijs/core@3.23.0': resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} @@ -4245,9 +4321,6 @@ packages: '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} - '@types/node@24.12.2': - resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} - '@types/nprogress@0.2.3': resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==} @@ -4347,13 +4420,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/eslint-plugin@8.58.0': - resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + '@typescript-eslint/eslint-plugin@8.57.2': + resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.58.0 + '@typescript-eslint/parser': ^8.57.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/parser@8.34.0': resolution: {integrity: sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==} @@ -4369,12 +4442,12 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.58.0': - resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + '@typescript-eslint/parser@8.57.2': + resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/project-service@8.34.0': resolution: {integrity: sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw==} @@ -4394,11 +4467,11 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.58.0': - resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + '@typescript-eslint/project-service@8.57.2': + resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/scope-manager@8.34.0': resolution: {integrity: sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw==} @@ -4412,8 +4485,8 @@ packages: resolution: {integrity: sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.58.0': - resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + '@typescript-eslint/scope-manager@8.57.2': + resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.34.0': @@ -4434,11 +4507,11 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.58.0': - resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + '@typescript-eslint/tsconfig-utils@8.57.2': + resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/type-utils@8.34.0': resolution: {integrity: sha512-n7zSmOcUVhcRYC75W2pnPpbO1iwhJY3NLoHEtbJwJSNlVAZuwqu05zY3f3s2SDWWDSo9FdN5szqc73DCtDObAg==} @@ -4454,12 +4527,12 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.58.0': - resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + '@typescript-eslint/type-utils@8.57.2': + resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/types@8.34.0': resolution: {integrity: sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA==} @@ -4473,8 +4546,8 @@ packages: resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.58.0': - resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + '@typescript-eslint/types@8.57.2': + resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@8.34.0': @@ -4495,11 +4568,11 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.58.0': - resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + '@typescript-eslint/typescript-estree@8.57.2': + resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/utils@8.34.0': resolution: {integrity: sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ==} @@ -4522,12 +4595,12 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.58.0': - resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + '@typescript-eslint/utils@8.57.2': + resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: '>=4.8.4 <6.0.0' '@typescript-eslint/visitor-keys@8.34.0': resolution: {integrity: sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==} @@ -4541,8 +4614,8 @@ packages: resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.58.0': - resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + '@typescript-eslint/visitor-keys@8.57.2': + resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/vfs@1.6.4': @@ -6231,6 +6304,10 @@ packages: dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debounce@3.0.0: + resolution: {integrity: sha512-64byRbF0/AirwbuHqB3/ZpMG9/nckDa6ZA0yd6UnaQNwbbemCOwvz2sL5sjXLHhZHADyiwLm0M5qMhltUUx+TA==} + engines: {node: '>=20'} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -7403,10 +7480,6 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - helmet@8.1.0: - resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} - engines: {node: '>=18.0.0'} - help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -7486,10 +7559,6 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - iconv-lite@0.7.0: - resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -9060,9 +9129,16 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@7.0.0: + resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-addon-api@8.3.0: + resolution: {integrity: sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==} + engines: {node: ^18 || ^20 || >= 21} + node-addon-api@8.3.1: resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==} engines: {node: ^18 || ^20 || >= 21} @@ -9082,6 +9158,10 @@ packages: encoding: optional: true + node-gyp-build@4.6.0: + resolution: {integrity: sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==} + hasBin: true + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -9586,10 +9666,6 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.9: - resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} - engines: {node: ^10 || ^12 || >=14} - prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -10142,6 +10218,10 @@ packages: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} + serialport@12.0.0: + resolution: {integrity: sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA==} + engines: {node: '>=16.0.0'} + serve-handler@6.1.7: resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==} @@ -10199,8 +10279,8 @@ packages: resolution: {integrity: sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==} engines: {node: '>= 0.4'} - shellies-ds9@https://codeload.github.com/fastybird/node-shellies-ds9/tar.gz/27756253d015aa20382c35937d9a2119a928a351: - resolution: {tarball: https://codeload.github.com/fastybird/node-shellies-ds9/tar.gz/27756253d015aa20382c35937d9a2119a928a351} + shellies-ds9@https://codeload.github.com/fastybird/node-shellies-ds9/tar.gz/dcfc245794a9ef7aa0e6f0f1300da85e511f7aaf: + resolution: {tarball: https://codeload.github.com/fastybird/node-shellies-ds9/tar.gz/dcfc245794a9ef7aa0e6f0f1300da85e511f7aaf} version: 1.1.9 shellies@1.7.0: @@ -10262,6 +10342,9 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + slip@1.0.2: + resolution: {integrity: sha512-XrcHe3NAcyD3wO+O4I13RcS4/3AF+S9RvGNj9JhJeS02HyImwD2E3QWLrmn9hBfL+fB6yapagwxRkeyYzhk98g==} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -10823,12 +10906,6 @@ packages: peerDependencies: typescript: '>=4.8.4' - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -11767,6 +11844,17 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zigbee-herdsman-converters@26.26.0: + resolution: {integrity: sha512-HpZguRu5ljPv4v9HhejhIQIyGyVMvGAq1knjIyDfS59IRfaEvgrn3+HD56LX8eCVXu1G2CG565q42fRKOkFuMw==} + engines: {node: '>=20.15.0'} + + zigbee-herdsman@10.0.4: + resolution: {integrity: sha512-RC8bJx3eKbuvI1d6FCzIQBXqCtNebAkOmbBozArmJ1kMXJamL3vK4JEkFU0f0JG2t4F0MN7VfJJR/6QJwxlpag==} + + zigbee-on-host@0.2.4: + resolution: {integrity: sha512-NIG6CWp+Yfn7PjqEIRvenHqpwT1U7rSkyimnFOUIFpnmxQOJKrmcwWDfn6WjbU/YIYYWSQPlj+g13icu1xwlyg==} + engines: {node: ^20.19.0 || >=22.12.0} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -12373,6 +12461,8 @@ snapshots: '@ctrl/tinycolor@4.2.0': {} + '@date-fns/tz@1.4.1': {} + '@discordjs/builders@1.13.1': dependencies: '@discordjs/formatters': 0.6.2 @@ -12663,11 +12753,6 @@ snapshots: '@fastify/forwarded@3.0.0': {} - '@fastify/helmet@13.0.2': - dependencies: - fastify-plugin: 5.1.0 - helmet: 8.1.0 - '@fastify/merge-json-schemas@0.2.1': dependencies: dequal: 2.0.3 @@ -13002,7 +13087,7 @@ snapshots: '@inquirer/external-editor@1.0.3(@types/node@24.12.0)': dependencies: chardet: 2.1.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.2 optionalDependencies: '@types/node': 24.12.0 @@ -13901,12 +13986,6 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) - '@nestjs/throttler@6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)': - dependencies: - '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(@nestjs/websockets@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) - reflect-metadata: 0.2.2 - '@nestjs/typeorm@11.0.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(babel-plugin-macros@3.1.0)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.15.18(@swc/helpers@0.5.17))(@types/node@24.12.0)(typescript@5.9.3)))': dependencies: '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -14456,6 +14535,83 @@ snapshots: optionalDependencies: pinia: 3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + '@serialport/binding-mock@10.2.2': + dependencies: + '@serialport/bindings-interface': 1.2.2 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@serialport/bindings-cpp@12.0.1': + dependencies: + '@serialport/bindings-interface': 1.2.2 + '@serialport/parser-readline': 11.0.0 + debug: 4.4.3(supports-color@10.2.2) + node-addon-api: 7.0.0 + node-gyp-build: 4.6.0 + transitivePeerDependencies: + - supports-color + + '@serialport/bindings-cpp@13.0.1': + dependencies: + '@serialport/bindings-interface': 1.2.2 + '@serialport/parser-readline': 13.0.0 + debug: 4.4.3(supports-color@10.2.2) + node-addon-api: 8.3.0 + node-gyp-build: 4.8.4 + transitivePeerDependencies: + - supports-color + + '@serialport/bindings-interface@1.2.2': {} + + '@serialport/parser-byte-length@12.0.0': {} + + '@serialport/parser-cctalk@12.0.0': {} + + '@serialport/parser-delimiter@11.0.0': {} + + '@serialport/parser-delimiter@12.0.0': {} + + '@serialport/parser-delimiter@13.0.0': {} + + '@serialport/parser-inter-byte-timeout@12.0.0': {} + + '@serialport/parser-packet-length@12.0.0': {} + + '@serialport/parser-readline@11.0.0': + dependencies: + '@serialport/parser-delimiter': 11.0.0 + + '@serialport/parser-readline@12.0.0': + dependencies: + '@serialport/parser-delimiter': 12.0.0 + + '@serialport/parser-readline@13.0.0': + dependencies: + '@serialport/parser-delimiter': 13.0.0 + + '@serialport/parser-ready@12.0.0': {} + + '@serialport/parser-regex@12.0.0': {} + + '@serialport/parser-slip-encoder@12.0.0': {} + + '@serialport/parser-spacepacket@12.0.0': {} + + '@serialport/stream@12.0.0': + dependencies: + '@serialport/bindings-interface': 1.2.2 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@serialport/stream@13.0.0': + dependencies: + '@serialport/bindings-interface': 1.2.2 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@shikijs/core@3.23.0': dependencies: '@shikijs/types': 3.23.0 @@ -15323,10 +15479,6 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@24.12.2': - dependencies: - undici-types: 7.16.0 - '@types/nprogress@0.2.3': {} '@types/parse-json@4.0.2': @@ -15380,7 +15532,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.0 '@types/tough-cookie@4.0.5': {} @@ -15446,34 +15598,34 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/parser': 8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/type-utils': 8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 eslint: 10.0.3(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/parser': 8.57.2(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/type-utils': 8.57.2(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.57.2 eslint: 9.28.0(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.8.3) + ts-api-utils: 2.4.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -15496,30 +15648,30 @@ snapshots: '@typescript-eslint/types': 8.57.0 '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.0 - debug: 4.4.1 + debug: 4.4.3(supports-color@10.2.2) eslint: 10.0.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.1 eslint: 10.0.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/parser@8.57.2(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.1 eslint: 9.28.0(jiti@2.6.1) typescript: 5.8.3 @@ -15553,19 +15705,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.0(typescript@5.8.3)': + '@typescript-eslint/project-service@8.57.2(typescript@5.8.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.8.3) - '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.8.3) + '@typescript-eslint/types': 8.57.2 debug: 4.4.3(supports-color@10.2.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: @@ -15586,10 +15738,10 @@ snapshots: '@typescript-eslint/types': 8.57.0 '@typescript-eslint/visitor-keys': 8.57.0 - '@typescript-eslint/scope-manager@8.58.0': + '@typescript-eslint/scope-manager@8.57.2': dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 '@typescript-eslint/tsconfig-utils@8.34.0(typescript@5.8.3)': dependencies: @@ -15607,11 +15759,11 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.8.3)': + '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.8.3)': dependencies: typescript: 5.8.3 - '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -15638,26 +15790,26 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3(supports-color@10.2.2) eslint: 10.0.3(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.58.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.57.2(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.8.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3) debug: 4.4.3(supports-color@10.2.2) eslint: 9.28.0(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@5.8.3) + ts-api-utils: 2.4.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -15668,7 +15820,7 @@ snapshots: '@typescript-eslint/types@8.57.0': {} - '@typescript-eslint/types@8.58.0': {} + '@typescript-eslint/types@8.57.2': {} '@typescript-eslint/typescript-estree@8.34.0(typescript@5.8.3)': dependencies: @@ -15694,7 +15846,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.4 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -15707,7 +15859,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) '@typescript-eslint/types': 8.57.0 '@typescript-eslint/visitor-keys': 8.57.0 - debug: 4.4.1 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 @@ -15716,32 +15868,32 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.58.0(typescript@5.8.3)': + '@typescript-eslint/typescript-estree@8.57.2(typescript@5.8.3)': dependencies: - '@typescript-eslint/project-service': 8.58.0(typescript@5.8.3) - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.8.3) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/project-service': 8.57.2(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.8.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@5.8.3) + ts-api-utils: 2.4.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -15779,23 +15931,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) eslint: 10.0.3(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.0(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': + '@typescript-eslint/utils@8.57.2(eslint@9.28.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.28.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.8.3) eslint: 9.28.0(jiti@2.6.1) typescript: 5.8.3 transitivePeerDependencies: @@ -15816,9 +15968,9 @@ snapshots: '@typescript-eslint/types': 8.57.0 eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.58.0': + '@typescript-eslint/visitor-keys@8.57.2': dependencies: - '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/types': 8.57.2 eslint-visitor-keys: 5.0.1 '@typescript/vfs@1.6.4(typescript@5.9.3)': @@ -16018,7 +16170,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.41.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.41.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -16026,7 +16178,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.41.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.41.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -16277,11 +16429,11 @@ snapshots: transitivePeerDependencies: - '@types/eslint' - '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) eslint: 10.0.3(jiti@2.6.1) - eslint-plugin-vue: 10.8.0(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))) + eslint-plugin-vue: 10.8.0(@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))) fast-glob: 3.3.3 typescript-eslint: 8.57.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) vue-eslint-parser: 10.4.0(eslint@10.0.3(jiti@2.6.1)) @@ -16982,7 +17134,7 @@ snapshots: content-type: 1.0.5 debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.2 on-finished: 2.4.1 qs: 6.15.0 raw-body: 3.0.2 @@ -17771,6 +17923,8 @@ snapshots: dayjs@1.11.19: {} + debounce@3.0.0: {} + debug@4.4.1: dependencies: ms: 2.1.3 @@ -18291,17 +18445,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.3(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.3(jiti@2.6.1)): dependencies: debug: 4.4.3(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) eslint: 10.0.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -18312,7 +18466,7 @@ snapshots: doctrine: 2.1.0 eslint: 10.0.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.3(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.0.3(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -18324,7 +18478,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -18359,7 +18513,7 @@ snapshots: dependencies: eslint: 10.0.3(jiti@2.6.1) - eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))): + eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.3(jiti@2.6.1))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) eslint: 10.0.3(jiti@2.6.1) @@ -18370,7 +18524,7 @@ snapshots: vue-eslint-parser: 10.4.0(eslint@10.0.3(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) eslint-scope@5.1.1: dependencies: @@ -18911,7 +19065,7 @@ snapshots: minimatch: 3.1.5 node-abort-controller: 3.1.1 schema-utils: 3.3.0 - semver: 7.7.3 + semver: 7.7.4 tapable: 2.3.0 typescript: 5.9.3 webpack: 5.104.1(@swc/core@1.15.18(@swc/helpers@0.5.17)) @@ -19349,8 +19503,6 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 - helmet@8.1.0: {} - help-me@5.0.0: {} hookable@5.5.3: {} @@ -19443,10 +19595,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.0: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -20268,7 +20416,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.7.3 + semver: 7.7.4 jwa@2.0.1: dependencies: @@ -21518,8 +21666,12 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@7.0.0: {} + node-addon-api@7.1.1: {} + node-addon-api@8.3.0: {} + node-addon-api@8.3.1: {} node-emoji@1.11.0: @@ -21534,6 +21686,8 @@ snapshots: optionalDependencies: encoding: 0.1.13 + node-gyp-build@4.6.0: {} + node-gyp-build@4.8.4: {} node-gyp@8.4.1: @@ -22037,15 +22191,15 @@ snapshots: dependencies: htmlparser2: 8.0.2 js-tokens: 9.0.1 - postcss: 8.5.9 - postcss-safe-parser: 6.0.0(postcss@8.5.9) + postcss: 8.5.8 + postcss-safe-parser: 6.0.0(postcss@8.5.8) - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(yaml@2.8.2): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 - postcss: 8.5.9 + postcss: 8.5.8 tsx: 4.21.0 yaml: 2.8.2 @@ -22053,9 +22207,9 @@ snapshots: postcss-resolve-nested-selector@0.1.6: {} - postcss-safe-parser@6.0.0(postcss@8.5.9): + postcss-safe-parser@6.0.0(postcss@8.5.8): dependencies: - postcss: 8.5.9 + postcss: 8.5.8 postcss-safe-parser@7.0.1(postcss@8.5.8): dependencies: @@ -22082,12 +22236,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.9: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -22175,7 +22323,7 @@ snapshots: '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 '@types/long': 4.0.2 - '@types/node': 24.12.2 + '@types/node': 24.12.0 long: 4.0.0 protobufjs@7.5.4: @@ -22245,7 +22393,7 @@ snapshots: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.2 unpipe: 1.0.0 rc@1.2.8: @@ -22779,6 +22927,25 @@ snapshots: transitivePeerDependencies: - supports-color + serialport@12.0.0: + dependencies: + '@serialport/binding-mock': 10.2.2 + '@serialport/bindings-cpp': 12.0.1 + '@serialport/parser-byte-length': 12.0.0 + '@serialport/parser-cctalk': 12.0.0 + '@serialport/parser-delimiter': 12.0.0 + '@serialport/parser-inter-byte-timeout': 12.0.0 + '@serialport/parser-packet-length': 12.0.0 + '@serialport/parser-readline': 12.0.0 + '@serialport/parser-ready': 12.0.0 + '@serialport/parser-regex': 12.0.0 + '@serialport/parser-slip-encoder': 12.0.0 + '@serialport/parser-spacepacket': 12.0.0 + '@serialport/stream': 12.0.0 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + serve-handler@6.1.7: dependencies: bytes: 3.0.0 @@ -22889,7 +23056,7 @@ snapshots: shell-quote@1.8.2: {} - shellies-ds9@https://codeload.github.com/fastybird/node-shellies-ds9/tar.gz/27756253d015aa20382c35937d9a2119a928a351: + shellies-ds9@https://codeload.github.com/fastybird/node-shellies-ds9/tar.gz/dcfc245794a9ef7aa0e6f0f1300da85e511f7aaf: dependencies: '@types/multicast-dns': 7.2.4 eventemitter3: 5.0.1 @@ -22986,6 +23153,8 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + slip@1.0.2: {} + smart-buffer@4.2.0: {} socket.io-adapter@2.5.5: @@ -23672,14 +23841,6 @@ snapshots: dependencies: typescript: 5.9.3 - ts-api-utils@2.5.0(typescript@5.8.3): - dependencies: - typescript: 5.8.3 - - ts-api-utils@2.5.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - ts-dedent@2.2.0: {} ts-interface-checker@0.1.13: {} @@ -23765,7 +23926,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(@swc/core@1.15.18(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.5.1(@swc/core@1.15.18(@swc/helpers@0.5.17))(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.25.5) cac: 6.7.14 @@ -23776,7 +23937,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.9)(tsx@4.21.0)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.2) resolve-from: 5.0.0 rollup: 4.59.0 source-map: 0.7.6 @@ -23786,7 +23947,7 @@ snapshots: tree-kill: 1.2.2 optionalDependencies: '@swc/core': 1.15.18(@swc/helpers@0.5.17) - postcss: 8.5.9 + postcss: 8.5.8 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -24249,7 +24410,7 @@ snapshots: transitivePeerDependencies: - supports-color - vite@6.4.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.41.0)(tsx@4.21.0)(yaml@2.8.2): + vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.41.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.5 fdir: 6.5.0(picomatch@4.0.3) @@ -24258,7 +24419,7 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.12.2 + '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 @@ -24343,7 +24504,7 @@ snapshots: eslint-visitor-keys: 5.0.1 espree: 11.2.0 esquery: 1.7.0 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - supports-color @@ -24738,6 +24899,30 @@ snapshots: yoctocolors-cjs@2.1.3: {} + zigbee-herdsman-converters@26.26.0: + dependencies: + iconv-lite: 0.7.2 + semver: 7.7.4 + zigbee-herdsman: 10.0.4 + transitivePeerDependencies: + - supports-color + + zigbee-herdsman@10.0.4: + dependencies: + '@date-fns/tz': 1.4.1 + '@serialport/bindings-cpp': 13.0.1 + '@serialport/parser-delimiter': 13.0.0 + '@serialport/stream': 13.0.0 + bonjour-service: 1.3.0 + debounce: 3.0.0 + fast-deep-equal: 3.1.3 + slip: 1.0.2 + zigbee-on-host: 0.2.4 + transitivePeerDependencies: + - supports-color + + zigbee-on-host@0.2.4: {} + zod@4.3.6: {} zustand@5.0.5(@types/react@19.2.14)(immer@9.0.21)(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4)): diff --git a/tasks/features/FEATURE-PLUGIN-ZIGBEE-HERDSMAN.md b/tasks/features/FEATURE-PLUGIN-ZIGBEE-HERDSMAN.md index bb2a4cea7a..954372d55f 100644 --- a/tasks/features/FEATURE-PLUGIN-ZIGBEE-HERDSMAN.md +++ b/tasks/features/FEATURE-PLUGIN-ZIGBEE-HERDSMAN.md @@ -4,7 +4,7 @@ Type: feature Scope: backend, admin Size: large Parent: (none) -Status: planned +Status: in-progress ## 1. Business goal @@ -68,58 +68,58 @@ I want a native plugin that communicates directly with Zigbee devices using zigb ### Phase 1 — Core infrastructure & coordinator management -- [ ] New plugin directory `apps/backend/src/plugins/devices-zigbee-herdsman/` with standard plugin structure -- [ ] Plugin module registered with NestJS, discoverable by extension system -- [ ] Plugin entities: `ZigbeeHerdsmanDeviceEntity`, `ZigbeeHerdsmanChannelEntity`, `ZigbeeHerdsmanChannelPropertyEntity` extending base entities -- [ ] Device entity stores: `ieeeAddress` (string), `networkAddress` (number), `manufacturerName` (string|null), `modelId` (string|null), `dateCode` (string|null), `softwareBuildId` (string|null), `interviewCompleted` (boolean) -- [ ] Channel property entity stores: `zigbeeCluster` (string|null), `zigbeeAttribute` (string|null) for mapping back to ZCL -- [ ] Plugin config model with fields: `serialPort` (string), `baudRate` (number, default 115200), `adapterType` (enum: zstack, ember, deconz, zigate, auto), `channel` (number, default 11), `panId` (number), `extendedPanId` (number[]), `networkKey` (number[]), `permitJoinTimeout` (number, default 254 seconds) -- [ ] Config validation service that checks serial port accessibility -- [ ] `ZigbeeHerdsmanAdapterService` — wraps `zigbee-herdsman` Controller with start/stop/restart -- [ ] Adapter service handles coordinator options: serial port, adapter type, network config, database path (`data/zigbee-herdsman.db`) -- [ ] Adapter service emits typed events: `deviceJoined`, `deviceInterview`, `deviceLeave`, `deviceAnnounce`, `message`, `adapterDisconnected` -- [ ] `ZigbeeHerdsmanService` implements `IManagedPluginService` with proper lifecycle (start → initialize controller → form/join network → listen for events) -- [ ] Service gracefully handles coordinator disconnection and attempts reconnection -- [ ] Service registers with `PluginServiceManagerService` and `PlatformRegistryService` -- [ ] Plugin metadata registered with `ExtensionsService` (name, description, author, readme) +- [x] New plugin directory `apps/backend/src/plugins/devices-zigbee-herdsman/` with standard plugin structure +- [x] Plugin module registered with NestJS, discoverable by extension system +- [x] Plugin entities: `ZigbeeHerdsmanDeviceEntity`, `ZigbeeHerdsmanChannelEntity`, `ZigbeeHerdsmanChannelPropertyEntity` extending base entities +- [x] Device entity stores: `ieeeAddress` (string), `networkAddress` (number), `manufacturerName` (string|null), `modelId` (string|null), `dateCode` (string|null), `softwareBuildId` (string|null), `interviewCompleted` (boolean) +- [x] Channel property entity stores: `zigbeeCluster` (string|null), `zigbeeAttribute` (string|null) for mapping back to ZCL +- [x] Plugin config model with fields: `serialPort` (string), `baudRate` (number, default 115200), `adapterType` (enum: zstack, ember, deconz, zigate, auto), `channel` (number, default 11), `panId` (number), `extendedPanId` (number[]), `networkKey` (number[]), `permitJoinTimeout` (number, default 254 seconds) +- [x] Config validation service that checks serial port accessibility +- [x] `ZigbeeHerdsmanAdapterService` — wraps `zigbee-herdsman` Controller with start/stop/restart +- [x] Adapter service handles coordinator options: serial port, adapter type, network config, database path (`data/zigbee-herdsman.db`) +- [x] Adapter service emits typed events: `deviceJoined`, `deviceInterview`, `deviceLeave`, `deviceAnnounce`, `message`, `adapterDisconnected` +- [x] `ZigbeeHerdsmanService` implements `IManagedPluginService` with proper lifecycle (start → initialize controller → form/join network → listen for events) +- [x] Service gracefully handles coordinator disconnection and attempts reconnection +- [x] Service registers with `PluginServiceManagerService` and `PlatformRegistryService` +- [x] Plugin metadata registered with `ExtensionsService` (name, description, author, readme) ### Phase 2 — Device discovery & converter integration -- [ ] On startup, iterate `controller.getDevices()` to load known devices -- [ ] Listen to `deviceJoined` + `deviceInterview` events for new devices -- [ ] Use `zigbee-herdsman-converters` `findByDevice()` to resolve device definitions from zigbee model ID -- [ ] Extract `exposes` from device definition — these are the same format as Z2M exposes +- [x] On startup, iterate `controller.getDevices()` to load known devices +- [x] Listen to `deviceJoined` + `deviceInterview` events for new devices +- [x] Use `zigbee-herdsman-converters` `findByDevice()` to resolve device definitions from zigbee model ID +- [x] Extract `exposes` from device definition — these are the same format as Z2M exposes - [ ] Reuse or adapt the Z2M plugin's converter registry (`converters/converter.registry.ts`) to map exposes → Smart Panel channels/properties - [ ] Reuse or adapt the Z2M plugin's mapping layer (`mappings/`) for value transformation -- [ ] `ZigbeeDiscoveredDevicesService` maintains an in-memory registry of paired-but-not-adopted devices -- [ ] Each discovered device record includes: IEEE address, friendly name (defaults to model + short addr), model, vendor, description, exposes, interview status, last seen timestamp -- [ ] Devices that fail interview are still listed with a "interview incomplete" status -- [ ] `ZigbeeDeviceConnectivityService` tracks online/offline state based on `lastSeen` + configurable timeout (default 3600s for mains, 7200s for battery) +- [x] `ZigbeeDiscoveredDevicesService` maintains an in-memory registry of paired-but-not-adopted devices +- [x] Each discovered device record includes: IEEE address, friendly name (defaults to model + short addr), model, vendor, description, exposes, interview status, last seen timestamp +- [x] Devices that fail interview are still listed with a "interview incomplete" status +- [x] `ZigbeeDeviceConnectivityService` tracks online/offline state based on `lastSeen` + configurable timeout (default 3600s for mains, 7200s for battery) ### Phase 3 — Device adoption & state synchronization -- [ ] `ZigbeeMappingPreviewService` generates channel/property mapping preview from exposes (reuse Z2M pattern) -- [ ] `ZigbeeDeviceAdoptionService` creates Device/Channel/Property entities from confirmed mapping -- [ ] Adoption assigns device category based on primary exposes (light → LIGHTING, switch → OUTLET, climate → THERMOSTAT, sensor → SENSOR, etc.) -- [ ] REST controller `ZigbeeHerdsmanDiscoveredDevicesController` with endpoints: +- [x] `ZigbeeMappingPreviewService` generates channel/property mapping preview from exposes (reuse Z2M pattern) +- [x] `ZigbeeDeviceAdoptionService` creates Device/Channel/Property entities from confirmed mapping +- [x] Adoption assigns device category based on primary exposes (light → LIGHTING, switch → OUTLET, climate → THERMOSTAT, sensor → SENSOR, etc.) +- [x] REST controller `ZigbeeHerdsmanDiscoveredDevicesController` with endpoints: - `GET /api/plugins/devices-zigbee-herdsman/discovered-devices` — list discovered unadopted devices - `GET /api/plugins/devices-zigbee-herdsman/mapping-preview/:ieeeAddress` — preview channel/property mapping - `POST /api/plugins/devices-zigbee-herdsman/adopt` — adopt device with confirmed mapping - `DELETE /api/plugins/devices-zigbee-herdsman/devices/:ieeeAddress` — remove device from Zigbee network - `POST /api/plugins/devices-zigbee-herdsman/permit-join` — enable/disable permit join (with timeout) - `GET /api/plugins/devices-zigbee-herdsman/coordinator-info` — coordinator firmware, type, channel info -- [ ] All endpoints decorated with Swagger/OpenAPI annotations +- [x] All endpoints decorated with Swagger/OpenAPI annotations - [ ] Incoming `message` events from zigbee-herdsman processed through `fromZigbee` converters to extract property values - [ ] Property values written to `PropertyValueService` and broadcast via WebSocket -- [ ] `ZigbeeHerdsmanDevicePlatform` implements `IDevicePlatform` to handle outgoing commands -- [ ] Platform handler resolves the correct `toZigbee` converter for each property -- [ ] Platform handler calls `device.getEndpoint(n).command()` or `.write()` with the correct ZCL cluster/attribute -- [ ] Batch command support: multiple property updates grouped per endpoint to reduce Zigbee traffic -- [ ] Command retry logic with configurable attempts (default 3) for unreliable devices +- [x] `ZigbeeHerdsmanDevicePlatform` implements `IDevicePlatform` to handle outgoing commands +- [x] Platform handler resolves the correct `toZigbee` converter for each property +- [x] Platform handler calls `device.getEndpoint(n).command()` or `.write()` with the correct ZCL cluster/attribute +- [x] Batch command support: multiple property updates grouped per endpoint to reduce Zigbee traffic +- [x] Command retry logic with configurable attempts (default 3) for unreliable devices ### Phase 4 — Admin UI -- [ ] Plugin configuration form in admin: serial port input (with auto-detect dropdown), baud rate, adapter type selector, channel number, advanced network settings (PAN ID, network key) with sensible defaults +- [x] Plugin configuration form in admin: serial port input (with auto-detect dropdown), baud rate, adapter type selector, channel number, advanced network settings (PAN ID, network key) with sensible defaults - [ ] Network status indicator showing coordinator state (online/offline/forming) - [ ] "Permit Join" toggle button with countdown timer - [ ] Discovered devices list with columns: model, vendor, IEEE address, interview status, signal quality (LQI) @@ -129,21 +129,21 @@ I want a native plugin that communicates directly with Zigbee devices using zigb ### Phase 5 — Zigbee network management -- [ ] Network key generation on first startup (random 16-byte key) with persistent storage -- [ ] PAN ID auto-generation if not configured -- [ ] Channel selection validation (must be one of: 11, 15, 20, 25 recommended; 11-26 allowed) -- [ ] Permit join broadcasts to all routers (not just coordinator) -- [ ] Device leave handling: mark device offline, optionally remove entities +- [x] Network key generation on first startup (random 16-byte key) with persistent storage +- [x] PAN ID auto-generation if not configured +- [x] Channel selection validation (must be one of: 11, 15, 20, 25 recommended; 11-26 allowed) +- [x] Permit join broadcasts to all routers (not just coordinator) +- [x] Device leave handling: mark device offline, optionally remove entities - [ ] Basic group support: create group, add device to group, remove device from group, send commands to group - [ ] Zigbee network backup/restore via database file ### Cross-cutting -- [ ] Unit tests for converter integration, adoption service, connectivity tracking +- [x] Unit tests for converter integration, adoption service, connectivity tracking - [ ] E2E tests for discovery → adoption → command flow (with mocked zigbee-herdsman Controller) -- [ ] Plugin is completely isolated — disabling it has no effect on other plugins -- [ ] Existing Z2M plugin continues to work independently (both cannot use the same coordinator simultaneously) -- [ ] Database migration for new entity columns +- [x] Plugin is completely isolated — disabling it has no effect on other plugins +- [x] Existing Z2M plugin continues to work independently (both cannot use the same coordinator simultaneously) +- [x] Database migration for new entity columns ## 5. Example scenarios (optional, Gherkin-style)