diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 564862f..53163da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,6 +20,7 @@ jobs: uses: actions/setup-node@main with: node-version: ${{ matrix.node-version }} + cache: 'yarn' # - name: Upgrade yarn # run: yarn set version berry diff --git a/.github/workflows/semantic-release.yml b/.github/workflows/semantic-release.yml index ee80964..9d0a0e1 100644 --- a/.github/workflows/semantic-release.yml +++ b/.github/workflows/semantic-release.yml @@ -5,7 +5,7 @@ on: push: branches: [master, beta, alpha] -# fine-grained permissions +# fine-grained permissions # see https://github.com/semantic-release/github and https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token permissions: contents: write @@ -22,6 +22,7 @@ jobs: - uses: actions/setup-node@main with: node-version: '16' + cache: 'yarn' - name: Install dependencies run: yarn set version berry diff --git a/.npmignore b/.npmignore index c601615..d1f451c 100644 --- a/.npmignore +++ b/.npmignore @@ -125,13 +125,19 @@ web_modules/ # yarn v2 +.yarn/build-state.yml .yarn/cache +.yarn/install-state.gz +.yarn/plugins +.yarn/sdks .yarn/unplugged -.yarn/build-state.yml +.yarn/releases +.yarnrc.yml .pnp.* # Github templates and actions, own stuff .github api .prettierrc +.prettierignore commitlint.config.js \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ac27231 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Homebridge Debug", + "skipFiles": ["/**"], + "program": "/usr/local/bin/homebridge", + "args": "-I", + "outFiles": ["${workspaceFolder}/**/*.js"] + } + ] +} diff --git a/config.schema.json b/config.schema.json index 236bade..9bda009 100644 --- a/config.schema.json +++ b/config.schema.json @@ -46,6 +46,27 @@ "type": "string", "description": "Set a global authentication token. This will be used for auto-discovery on the local network and as default token for manually specified devices." }, + "ignore": { + "title": "Ignored Devices", + "description": "Devices in this list will be excluded when found via auto-discovery. Add an entry (MAC-Address without the colon) for each devices that shall be ignored. The MAC-Address of your myStrom and Dingz devices can be found in the respective app, or via the device's own webpage.", + "type": "array", + "items": { + "title": "Device", + "type": "object", + "properties": { + "mac": { + "title": "MAC-Address", + "type": "string", + "pattern": "^([A-Fa-f0-9]{2}){5}[A-Fa-f0-9]{2}$", + "required": true + }, + "comment": { + "title": "Comment", + "type": "string" + } + } + } + }, "callbackHostname": { "title": "Hostname / IP to use for button callbacks ", "type": "string", diff --git a/package.json b/package.json index d414c22..397f624 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "prepublishOnly": "yarn pinst --disable", "prepare": "yarn run lint && yarn run build && yarn run depcheck", "changelog": "changelog --exclude ci,chore", - "debug": "yarn dlx homebridge -I" + "debug": "yarn run lint && yarn run build && yarn dlx homebridge -I" }, "keywords": [ "homebridge-plugin", @@ -48,7 +48,7 @@ "is-valid-host": "^1.0.1", "limit-number": "^3.0.0", "qs": "^6.10.3", - "semver": "^7.3.5", + "semver": "^7.3.8", "simple-color-converter": "^2.1.13" }, "devDependencies": { diff --git a/src/dingzAccessory.ts b/src/dingzAccessory.ts index b1a3071..873835e 100644 --- a/src/dingzAccessory.ts +++ b/src/dingzAccessory.ts @@ -223,29 +223,39 @@ export class DingzAccessory extends DingzDaBaseAccessory { this.getButtonCallbackUrl() .then((callBackUrl) => { + // Set the callback URL + const endpoints = ['generic']; + const platformCallbackUrl = this.platform.getCallbackUrl(); + + // Add PIR callbacks, depending on dingz Firmware version + if (this.hw.has_pir) { + if (semver.lt(this.hw.fw_version, '1.2.0')) { + endpoints.push('pir/single'); + } else if (semver.lt(this.hw.fw_version, '1.4.0')) { + endpoints.push('pir/generic', 'pir/rise', 'pir/fall'); + } else { + // FIXES #511: Newer FW have (yet!) other endpoint for PIR callbacks + endpoints.push('pir1/rise', 'pir1/fall'); + } + } + if (this.platform.config.callbackOverride) { this.log.warn('Override callback URL ->', callBackUrl); - // Set the callback URL (Override!) - const endpoints = // Only set `pir/single` for older FW - this.hw.has_pir && semver.lt(this.hw.fw_version, '1.2.0') - ? ['generic', 'pir/single'] - : this.hw.has_pir - ? ['generic', 'pir/generic', 'pir/rise', 'pir/fall'] - : ['generic']; + this.platform.setButtonCallbackUrl({ baseUrl: this.baseUrl, token: this.device.token, endpoints: endpoints, }); - } else if (!callBackUrl?.url.includes(this.platform.getCallbackUrl())) { + } else if ( + // FIXME: because of #511 + (semver.lt(this.hw.fw_version, '1.4.0') && + !callBackUrl?.url?.includes(platformCallbackUrl)) || + (semver.gte(this.hw.fw_version, '1.4.0') && + !callBackUrl?.generic?.includes(platformCallbackUrl)) + ) { this.log.warn('Update existing callback URL ->', callBackUrl); - // Set the callback URL (Override!) - const endpoints = - this.hw.has_pir && semver.lt(this.hw.fw_version, '1.2.0') - ? ['generic', 'pir/single'] - : this.hw.has_pir - ? ['generic', 'pir/generic', 'pir/rise', 'pir/fall'] - : ['generic']; + this.platform.setButtonCallbackUrl({ baseUrl: this.baseUrl, token: this.device.token, @@ -253,7 +263,7 @@ export class DingzAccessory extends DingzDaBaseAccessory { endpoints: endpoints, }); } else { - this.log.debug('Callback URL already set ->', callBackUrl?.url); + this.log.debug('Callback URL already set ->', callBackUrl); } }) .catch(this.handleRequestErrors.bind(this)); @@ -835,12 +845,11 @@ export class DingzAccessory extends DingzDaBaseAccessory { // Set min/max Values // FIXME: Implement different lamella/blind modes #24 - const maxTiltValue = semver.lt(this.hw.fw_version, '1.2.0') ? 90 : 100; service .getCharacteristic(this.platform.Characteristic.TargetHorizontalTiltAngle) .setProps({ minValue: 0, - maxValue: maxTiltValue, + maxValue: 90, minStep: this.platform.config.minStepTiltAngle, }) // dingz Maximum values .on(CharacteristicEventTypes.SET, this.setTiltAngle.bind(this, index)); @@ -881,9 +890,6 @@ export class DingzAccessory extends DingzDaBaseAccessory { * - We're moving by setting new positions in the UI [x] * - We're moving by pressing the "up/down" buttons in the UI or Hardware [x] */ - - const maxTiltValue = semver.lt(this.hw.fw_version, '1.2.0') ? 90 : 100; - service .getCharacteristic(this.platform.Characteristic.TargetPosition) .updateValue(state.position); @@ -891,7 +897,7 @@ export class DingzAccessory extends DingzDaBaseAccessory { .getCharacteristic( this.platform.Characteristic.TargetHorizontalTiltAngle, ) - .updateValue((state.lamella / 100) * maxTiltValue); // Old FW: Set in °, Get in % (...) + .updateValue((state.lamella / 100) * 90); // Lamella position set in ° in HomeKit let positionState: number; switch (state.moving) { @@ -910,7 +916,7 @@ export class DingzAccessory extends DingzDaBaseAccessory { .getCharacteristic( this.platform.Characteristic.CurrentHorizontalTiltAngle, ) - .updateValue((state.lamella / 100) * maxTiltValue); // Set in °, Get in % (...) + .updateValue((state.lamella / 100) * 90); // Lamella position set in ° in HomeKit break; } service @@ -941,7 +947,7 @@ export class DingzAccessory extends DingzDaBaseAccessory { await this.setWindowCovering({ id: id, blind: position as number, - lamella: windowCovering.lamella, + lamella: (windowCovering.lamella / 90) * 100, // FIXES #419, we must convert ° to % callback: callback, }); } @@ -977,14 +983,14 @@ export class DingzAccessory extends DingzDaBaseAccessory { 'Set Characteristic TargetHorizontalTiltAngle on ', index, '->', - angle, + `${angle}°`, ); const id = this.getWindowCoveringId(index); if (this.dingzStates.WindowCovers[id]) { await this.setWindowCovering({ id: id, blind: this.dingzStates.WindowCovers[id].position, - lamella: angle as number, + lamella: ((angle as number) / 90) * 100, // FIXES #419, we must convert ° to % callback: callback, }); } @@ -1012,7 +1018,9 @@ export class DingzAccessory extends DingzDaBaseAccessory { tiltAngle, ); - callback(this.reachabilityState, (tiltAngle / 100) * 90); // FIXES #371: internally, it's %, HomeKit expects ° + // FIXES #371, #419: internally, it's % (but only in newer firmware, v1.2.0 and lower has ° as well), HomeKit expects ° + const maxTiltValue = semver.lt(this.hw.fw_version, '1.2.0') ? 90 : 100; + callback(this.reachabilityState, (tiltAngle / maxTiltValue) * 90); } private getPositionState( @@ -1329,9 +1337,9 @@ export class DingzAccessory extends DingzDaBaseAccessory { color: `hex #${state.rgb}`, to: 'hsv', }); - this.dingzStates.LED.hue = hsv.c; - this.dingzStates.LED.saturation = hsv.s; - this.dingzStates.LED.value = hsv.i; + this.dingzStates.LED.hue = hsv.color.h; + this.dingzStates.LED.saturation = hsv.color.s; + this.dingzStates.LED.value = hsv.color.v; } ledService @@ -1490,6 +1498,15 @@ export class DingzAccessory extends DingzDaBaseAccessory { lamella: number; callback: CharacteristicSetCallback; }) { + // The API only accepts integer numbers. + // As we juggle with ° vs %, we must round + // the values for blind and lamella to the nearest integer + blind = Math.round(blind); + lamella = Math.round(lamella); + + this.log.debug( + `Setting WindowCovering ${id} to position ${blind} and angle ${lamella}°`, + ); // The API says the parameters can be omitted. This is not true // {{ip}}/api/v1/shade/0?blind=&lamella= const setWindowCoveringEndpoint = `${this.baseUrl}/api/v1/shade/${id}`; @@ -1552,7 +1569,10 @@ export class DingzAccessory extends DingzDaBaseAccessory { * Returns the callback URL for the device */ public async getButtonCallbackUrl(): Promise { - const getCallbackEndpoint = '/api/v1/action/generic/generic'; + // FIXES #511: different endpoint URLs for Callback from FW v1.4.x forward + const getCallbackEndpoint = semver.gte(this.hw.fw_version, '1.4.0') + ? '/api/v1/action/generic' + : '/api/v1/action/generic/generic'; this.log.debug('Getting the callback URL -> ', getCallbackEndpoint); return await this.request.get(getCallbackEndpoint).then((response) => { return response.data; diff --git a/src/lib/commonTypes.ts b/src/lib/commonTypes.ts index 8512858..4df3198 100644 --- a/src/lib/commonTypes.ts +++ b/src/lib/commonTypes.ts @@ -65,6 +65,8 @@ export interface AccessoryTypes { [key: string]: AccessoryType; } +// FIXME: Needed because of #511 export interface AccessoryActionUrl { - url: string; + url?: string; + generic?: string; } diff --git a/src/lib/libs.d.ts b/src/lib/libs.d.ts index 2eedae8..69477e8 100644 --- a/src/lib/libs.d.ts +++ b/src/lib/libs.d.ts @@ -1,4 +1,3 @@ declare module 'simple-color-converter'; declare module 'is-valid-host'; -declare module 'semver'; declare module 'limit-number'; diff --git a/src/myStromPIRAccessory.ts b/src/myStromPIRAccessory.ts index 4948165..6e8cb08 100644 --- a/src/myStromPIRAccessory.ts +++ b/src/myStromPIRAccessory.ts @@ -155,7 +155,10 @@ export class MyStromPIRAccessory extends DingzDaBaseAccessory { token: this.device.token, endpoints: ['pir/generic'], }); - } else if (!callBackUrl?.url.includes(this.platform.getCallbackUrl())) { + } else if ( + // FIXME: Needed because of #511 + !callBackUrl?.url?.includes(this.platform.getCallbackUrl()) + ) { this.log.warn('Update existing callback URL ->', callBackUrl); // Set the callback URL (Override!) this.platform.setButtonCallbackUrl({ diff --git a/src/platform.ts b/src/platform.ts index 7240961..ff240da 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -83,6 +83,7 @@ export class DingzDaHomebridgePlatform implements DynamicPlatformPlugin { // this is used to track restored cached accessories public accessories: AccessoryTypes = {}; private discovered = new Map(); + private ignored = new Map(); private readonly app: e.Application = e(); constructor( @@ -92,6 +93,26 @@ export class DingzDaHomebridgePlatform implements DynamicPlatformPlugin { ) { axiosRetry(axios, { retries: 5, retryDelay: axiosRetry.exponentialDelay }); + // Adds ignored devices from Config + if (this.config.ignore) { + for (const device of this.config.ignore) { + // Only add to map if mac is set + if (device.mac) { + const mac = device.mac.toUpperCase(); + this.ignored.set(mac, { + comment: device?.comment || '', + isignored: false, + }); + this.log.info( + chalk.redBright('[Platform]'), + `Will be ignoring device ${ + device?.comment || '' + } with MAC Address ${mac}`, + ); + } + } + } + // When this event is fired it means Homebridge has restored all cached accessories from disk. // Dynamic Platform plugins should only register new accessories after this event was fired, // in order to ensure they weren't added to homebridge already. This event can also be used @@ -107,9 +128,7 @@ export class DingzDaHomebridgePlatform implements DynamicPlatformPlugin { this.addDevices(); } // Discovers devices from UDP - if (this.config.autoDiscover) { - this.setupDeviceDiscovery(); - } + this.setupDeviceDiscovery(); // set-up the callback server ... this.callbackServer(); @@ -147,6 +166,15 @@ export class DingzDaHomebridgePlatform implements DynamicPlatformPlugin { const context = accessory.context; if (context.device && context.device.accessoryClass) { + if (this.ignored.has(context.device.mac.toUpperCase())) { + this.log.warn( + chalk.redBright('[Platform]'), + 'This cached accessory is also in the list of ignored devices.', + 'The plugin will continue loading it.', + 'Consider manually removing it from Homebridge.', + ); + } + this.log.debug( 'Restoring accessory of class ->', context.device.accessoryClass, @@ -269,7 +297,7 @@ export class DingzDaHomebridgePlatform implements DynamicPlatformPlugin { token?: string; existingAccessory?: PlatformAccessory; }): Promise { - // Run a diacovery of changed things every 10 seconds + // Run a discovery of changed things every 10 seconds this.log.debug( `addDingzDevice() --> Add configured device -> ${name} (${address})`, ); @@ -739,7 +767,12 @@ export class DingzDaHomebridgePlatform implements DynamicPlatformPlugin { } } - private datagramMessageHandler(msg: Uint8Array, remoteInfo: RemoteInfo) { + /** + * Parses and interprets auto-discovery messages + * @param msg + * @param remoteInfo + */ + private autoDiscoveryMessageHandler(msg: Uint8Array, remoteInfo: RemoteInfo) { // const mac: string = dataBuffer.toString('hex', 0, 6); try { @@ -751,101 +784,124 @@ export class DingzDaHomebridgePlatform implements DynamicPlatformPlugin { const mac: string = this.byteToHexString(msg.subarray(0, 6)); const deviceSuffix: string = mac.substr(6, 6); - if (!this.discovered.has(mac)) { - switch (t) { - case DeviceTypes.MYSTROM_BUTTON_PLUS: - throw new DeviceNotImplementedError( - `Device discovered at ${remoteInfo.address} of unsupported type ${DeviceTypes[t]}`, - ); - break; - case DeviceTypes.MYSTROM_BUTTON: - retryWithBreaker - .execute(() => { - this.addMyStromButtonDevice({ - address: remoteInfo.address, - name: `Button ${deviceSuffix}`, - token: this.config.globalToken, - mac: mac, - }); - }) - .then(() => { - this.discovered.set(mac, remoteInfo); - }); - break; - case DeviceTypes.MYSTROM_LEDSTRIP: - retryWithBreaker - .execute(() => { - this.addMyStromLightbulbDevice({ - address: remoteInfo.address, - name: `LED Strip ${deviceSuffix}`, - token: this.config.globalToken, + // If auto-discovery is disabled, we will return, however + // the MAC of the discovered device will be printed + if (!this.config.autoDiscover) { + this.log.info( + `Auto-discovery disabled: ignoring discovered device ${mac} at ${remoteInfo.address}`, + ); + return; + } + + // Check if already discovered, and if not ignored + // Implements #497 + if (this.ignored.has(mac.toUpperCase())) { + if (!this.ignored.get(mac.toUpperCase()).isignored) { + this.log.info( + 'Ignoring discovered device', + this.ignored.get(mac.toUpperCase()).comment || '', + 'at', + mac, + ); + this.ignored.get(mac.toUpperCase()).isignored = true; + } + } else { + if (!this.discovered.has(mac)) { + switch (t) { + case DeviceTypes.MYSTROM_BUTTON_PLUS: + throw new DeviceNotImplementedError( + `Device discovered at ${remoteInfo.address} of unsupported type ${DeviceTypes[t]}`, + ); + break; + case DeviceTypes.MYSTROM_BUTTON: + retryWithBreaker + .execute(() => { + this.addMyStromButtonDevice({ + address: remoteInfo.address, + name: `Button ${deviceSuffix}`, + token: this.config.globalToken, + mac: mac, + }); + }) + .then(() => { + this.discovered.set(mac, remoteInfo); }); - }) - .then(() => { - this.discovered.set(mac, remoteInfo); - }); - break; - case DeviceTypes.MYSTROM_BULB: - retryWithBreaker - .execute(() => { - this.addMyStromLightbulbDevice({ - address: remoteInfo.address, - name: `Lightbulb ${deviceSuffix}`, - token: this.config.globalToken, + break; + case DeviceTypes.MYSTROM_LEDSTRIP: + retryWithBreaker + .execute(() => { + this.addMyStromLightbulbDevice({ + address: remoteInfo.address, + name: `LED Strip ${deviceSuffix}`, + token: this.config.globalToken, + }); + }) + .then(() => { + this.discovered.set(mac, remoteInfo); }); - }) - .then(() => { - this.discovered.set(mac, remoteInfo); - }); - break; - case DeviceTypes.MYSTROM_SWITCH_CHV1: - case DeviceTypes.MYSTROM_SWITCH_CHV2: - case DeviceTypes.MYSTROM_SWITCH_EU: - retryWithBreaker - .execute(() => { - this.addMyStromSwitchDevice({ - address: remoteInfo.address, - name: `Switch ${deviceSuffix}`, - token: this.config.globalToken, + break; + case DeviceTypes.MYSTROM_BULB: + retryWithBreaker + .execute(() => { + this.addMyStromLightbulbDevice({ + address: remoteInfo.address, + name: `Lightbulb ${deviceSuffix}`, + token: this.config.globalToken, + }); + }) + .then(() => { + this.discovered.set(mac, remoteInfo); }); - }) - .then(() => { - this.discovered.set(mac, remoteInfo); - }); - break; - case DeviceTypes.MYSTROM_PIR: - retryWithBreaker - .execute(() => { - this.addMyStromPIRDevice({ - address: remoteInfo.address, - name: `PIR ${deviceSuffix}`, - token: this.config.globalToken, + break; + case DeviceTypes.MYSTROM_SWITCH_CHV1: + case DeviceTypes.MYSTROM_SWITCH_CHV2: + case DeviceTypes.MYSTROM_SWITCH_EU: + retryWithBreaker + .execute(() => { + this.addMyStromSwitchDevice({ + address: remoteInfo.address, + name: `Switch ${deviceSuffix}`, + token: this.config.globalToken, + }); + }) + .then(() => { + this.discovered.set(mac, remoteInfo); }); - }) - .then(() => { - this.discovered.set(mac, remoteInfo); - }); - break; - case DeviceTypes.DINGZ: - retryWithBreaker - .execute(() => { - this.addDingzDevice({ - address: remoteInfo.address, - name: `DINGZ ${deviceSuffix}`, - token: this.config.globalToken, + break; + case DeviceTypes.MYSTROM_PIR: + retryWithBreaker + .execute(() => { + this.addMyStromPIRDevice({ + address: remoteInfo.address, + name: `PIR ${deviceSuffix}`, + token: this.config.globalToken, + }); + }) + .then(() => { + this.discovered.set(mac, remoteInfo); }); - }) - .then(() => { - this.discovered.set(mac, remoteInfo); - }) - .catch((e) => this.handleError.bind(this, e)); - break; - default: - this.log.warn(`Unknown device: ${t}`); - break; + break; + case DeviceTypes.DINGZ: + retryWithBreaker + .execute(() => { + this.addDingzDevice({ + address: remoteInfo.address, + name: `DINGZ ${deviceSuffix}`, + token: this.config.globalToken, + }); + }) + .then(() => { + this.discovered.set(mac, remoteInfo); + }) + .catch((e) => this.handleError.bind(this, e)); + break; + default: + this.log.warn(`Unknown device: ${t}`); + break; + } + } else { + this.log.debug('Stopping discovery of already known device:', mac); } - } else { - this.log.debug('Stopping discovery of already known device:', mac); } } catch (e) { if (e instanceof DeviceNotImplementedError) { @@ -881,7 +937,7 @@ export class DingzDaHomebridgePlatform implements DynamicPlatformPlugin { }); discoverySocket - .on('message', this.datagramMessageHandler.bind(this)) + .on('message', this.autoDiscoveryMessageHandler.bind(this)) .bind(DINGZ_DISCOVERY_PORT); setTimeout(() => { this.log.info('Stopping discovery'); diff --git a/yarn.lock b/yarn.lock index 7ca53f1..7b35a76 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3770,7 +3770,7 @@ __metadata: qs: ^6.10.3 rimraf: ^3.0.2 semantic-release: ^19.0.2 - semver: ^7.3.5 + semver: ^7.3.8 simple-color-converter: ^2.1.13 typescript: ^4.5.5 languageName: unknown @@ -6846,7 +6846,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7": +"semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8": version: 7.3.8 resolution: "semver@npm:7.3.8" dependencies: