diff --git a/config.schema.json b/config.schema.json index 4cdb662..a7b99ca 100644 --- a/config.schema.json +++ b/config.schema.json @@ -46,20 +46,30 @@ { "title": "Garage Door", "enum": ["garagedoor"] }, { "title": "Lock", "enum": ["lock"] }, { "title": "Sensor", "enum": ["sensor"] }, - { "title": "Switch", "enum": ["switch"] } + { "title": "Switch", "enum": ["switch"] }, + { "title": "Window Covering - Blinds, Shades", "enum": ["windowcovering"] } ], "default": "switch" }, - "switchDefaultState": { - "title": "Switch Default State *", - "description": "Switch default state", + "doorbellVolume": { + "title": "Doorbell Volume *", + "description": "Doorbell volume *", + "type": "integer", + "minimum": 0, + "condition": { + "functionBody": "return model.devices[arrayIndices].accessoryType === 'doorbell';" + } + }, + "garageDoorDefaultState": { + "title": "Garage Door Default State *", + "description": "Garage Door default state", "type": "string", "oneOf": [ - { "title": "Off", "enum": ["off"] }, - { "title": "On", "enum": ["on"] } + { "title": "Closed", "enum": ["closed"] }, + { "title": "Open", "enum": ["open"] } ], "condition": { - "functionBody": "return model.devices[arrayIndices].accessoryType === 'switch';" + "functionBody": "return model.devices[arrayIndices].accessoryType === 'garagedoor';" } }, "lockDefaultState": { @@ -74,18 +84,6 @@ "functionBody": "return model.devices[arrayIndices].accessoryType === 'lock';" } }, - "garageDoorDefaultState": { - "title": "Garage Door Default State *", - "description": "Garage Door default state", - "type": "string", - "oneOf": [ - { "title": "Closed", "enum": ["closed"] }, - { "title": "Open", "enum": ["open"] } - ], - "condition": { - "functionBody": "return model.devices[arrayIndices].accessoryType === 'garagedoor';" - } - }, "lockHardwareFinish": { "title": "Lock Hardware Finish *", "description": "Color of the virtual HomeKey card in the Wallet app", @@ -100,13 +98,28 @@ "functionBody": "return model.devices[arrayIndices].accessoryType === 'lock';" } }, - "doorbellVolume": { - "title": "Doorbell Volume *", - "description": "Doorbell volume *", - "type": "integer", - "minimum": 0, + "switchDefaultState": { + "title": "Switch Default State *", + "description": "Switch default state", + "type": "string", + "oneOf": [ + { "title": "Off", "enum": ["off"] }, + { "title": "On", "enum": ["on"] } + ], "condition": { - "functionBody": "return model.devices[arrayIndices].accessoryType === 'doorbell';" + "functionBody": "return model.devices[arrayIndices].accessoryType === 'switch';" + } + }, + "windowCoveringDefaultState": { + "title": "Window Covering Default State *", + "description": "Window Covering default state", + "type": "string", + "oneOf": [ + { "title": "Closed", "enum": ["closed"] }, + { "title": "Open", "enum": ["open"] } + ], + "condition": { + "functionBody": "return model.devices[arrayIndices].accessoryType === 'windowcovering';" } }, "sensorType": { @@ -143,7 +156,7 @@ "description": "Accessory state survives Homebridge restart", "type": "boolean", "condition": { - "functionBody": "return ['switch', 'lock', 'garagedoor'].includes(model.devices[arrayIndices].accessoryType) && [undefined, false].includes(model.devices[arrayIndices].accessoryHasResetTimer);" + "functionBody": "return ['switch', 'lock', 'garagedoor', 'windowcovering'].includes(model.devices[arrayIndices].accessoryType) && [undefined, false].includes(model.devices[arrayIndices].accessoryHasResetTimer);" } }, "accessoryHasResetTimer": { diff --git a/package-lock.json b/package-lock.json index affe7e6..b1a2357 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "homebridge-virtual-accessories", - "version": "1.0.5", + "version": "1.1.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "homebridge-virtual-accessories", - "version": "1.0.5", + "version": "1.1.0-beta.1", "license": "Apache-2.0", "dependencies": { "@js-joda/core": "^5.6.3", diff --git a/package.json b/package.json index 2e0d3f5..a72ad5a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "homebridge-virtual-accessories", "displayName": "Virtual Accessories for Homebridge", "type": "module", - "version": "1.0.5", + "version": "1.1.0-beta.2", "description": "Virtual accessories for Homebridge.", "author": "justjam2013", "license": "Apache-2.0", diff --git a/src/accessories/virtualAccessoryGarageDoor.ts b/src/accessories/virtualAccessoryGarageDoor.ts index 2e668d2..02f50ab 100644 --- a/src/accessories/virtualAccessoryGarageDoor.ts +++ b/src/accessories/virtualAccessoryGarageDoor.ts @@ -103,7 +103,7 @@ export class GarageDoor extends Accessory { // implement your own code to check if the device is on const garageDoorState = this.states.GarageDoorState; - this.platform.log.debug(`[${this.accessoryConfiguration.accessoryName}] Getting Current Door State: ${this.getStateName(garageDoorState)}`); + this.platform.log.debug(`[${this.accessoryConfiguration.accessoryName}] Getting Current Garage Door State: ${this.getStateName(garageDoorState)}`); // if you need to return an error to show the device as "Not Responding" in the Home app: // throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); @@ -158,7 +158,7 @@ export class GarageDoor extends Accessory { // implement your own code to check if the device is on const garageDoorState = this.states.GarageDoorState; - this.platform.log.debug(`[${this.accessoryConfiguration.accessoryName}] Getting Target Door State: ${this.getStateName(garageDoorState)}`); + this.platform.log.debug(`[${this.accessoryConfiguration.accessoryName}] Getting Target Garage Door State: ${this.getStateName(garageDoorState)}`); // if you need to return an error to show the device as "Not Responding" in the Home app: // throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); diff --git a/src/accessories/virtualAccessoryWindowCovering.ts b/src/accessories/virtualAccessoryWindowCovering.ts new file mode 100644 index 0000000..7453bcc --- /dev/null +++ b/src/accessories/virtualAccessoryWindowCovering.ts @@ -0,0 +1,221 @@ +/* eslint-disable max-len */ + +import type { CharacteristicValue, PlatformAccessory } from 'homebridge'; + +import { VirtualAccessoryPlatform } from '../platform.js'; +import { Accessory } from './virtualAccessory.js'; + +/** + * WindowCovering - Accessory implementation + */ +export class WindowCovering extends Accessory { + + static readonly CLOSED: number = 0; // 0% + static readonly OPEN: number = 100; // 100% + + static readonly DECREASING: number = 0; // Characteristic.PositionState.DECREASING; -> CLOSING + static readonly INCREASING: number = 1; // Characteristic.PositionState.INCREASING; -> OPENING + static readonly STOPPED: number = 2; // Characteristic.PositionState.STOPPED; -> OPEN or CLOSED + + /** + * These are just used to create a working example + * You should implement your own code to track the state of your accessory + */ + private states = { + WindowCoveringCurrentPosition: WindowCovering.CLOSED, + // WindowCoveringTargetPosition: WindowCovering.CLOSED, + WindowCoveringPositionState: WindowCovering.STOPPED, + }; + + private readonly stateStorageKey: string = 'WindowCoveringPosition'; + + constructor( + platform: VirtualAccessoryPlatform, + accessory: PlatformAccessory, + ) { + super(platform, accessory); + + // First configure the device based on the accessory details + this.defaultState = this.accessoryConfiguration.windowCoveringDefaultState === 'open' ? WindowCovering.OPEN : WindowCovering.CLOSED; + + // If the accessory is stateful retrieve stored state, otherwise set to default state + if (this.accessoryConfiguration.accessoryIsStateful) { + const cachedState = this.loadState(this.storagePath, this.stateStorageKey) as number; + + if (cachedState !== undefined) { + this.states.WindowCoveringCurrentPosition = cachedState; + } else { + this.states.WindowCoveringCurrentPosition = this.defaultState; + } + } else { + this.states.WindowCoveringCurrentPosition = this.defaultState; + } + + // set accessory information + this.accessory.getService(this.platform.Service.AccessoryInformation)! + .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Virtual Accessories for Homebridge') + .setCharacteristic(this.platform.Characteristic.Model, 'Virtual Accessory - Window Covering') + .setCharacteristic(this.platform.Characteristic.SerialNumber, this.accessory.UUID); + + // get the LightBulb service if it exists, otherwise create a new LightBulb service + // you can create multiple services for each accessory + this.service = this.accessory.getService(this.platform.Service.WindowCovering) || this.accessory.addService(this.platform.Service.WindowCovering); + + // set the service name, this is what is displayed as the default name on the Home app + // in this example we are using the name we stored in the `accessory.context` in the `discoverDevices` method. + this.service.setCharacteristic(this.platform.Characteristic.Name, this.accessoryConfiguration.accessoryName); + + // Update the initial state of the accessory + this.platform.log.debug(`[${this.accessoryConfiguration.accessoryName}] Setting Window Covering Current Position: ${this.getStateName(this.states.WindowCoveringCurrentPosition)}`); + this.service.updateCharacteristic(this.platform.Characteristic.CurrentPosition, (this.states.WindowCoveringCurrentPosition)); + this.service.updateCharacteristic(this.platform.Characteristic.TargetPosition, (this.states.WindowCoveringCurrentPosition)); + this.service.updateCharacteristic(this.platform.Characteristic.PositionState, (this.states.WindowCoveringPositionState)); + + // each service must implement at-minimum the "required characteristics" for the given service type + // see https://developers.homebridge.io/#/service/Lightbulb + + // register handlers for the CurrentDoorState Characteristic + this.service.getCharacteristic(this.platform.Characteristic.CurrentPosition) + .onGet(this.handleCurrentPositionGet.bind(this)); // GET - bind to the 'handleCurrentPositionGet` method below + + // register handlers for the TargetDoorState Characteristic + this.service.getCharacteristic(this.platform.Characteristic.TargetPosition) + .onSet(this.handleTargetPositionSet.bind(this)) // SET - bind to the `handleTargetPositionSet` method below + .onGet(this.handleTargetPositionGet.bind(this)); // GET - bind to the `handleTargetPositionGet` method below + + // register handlers for the ObstructionDetected Characteristic + this.service.getCharacteristic(this.platform.Characteristic.PositionState) + .onGet(this.handlePositionStateGet.bind(this)); // GET - bind to the 'handlePositionStateGet` method below + + /** + * Creating multiple services of the same type. + * + * To avoid "Cannot add a Service with the same UUID another Service without also defining a unique 'subtype' property." error, + * when creating multiple services of the same type, you need to use the following syntax to specify a name and subtype id: + * this.accessory.getService('NAME') || this.accessory.addService(this.platform.Service.Lightbulb, 'NAME', 'USER_DEFINED_SUBTYPE_ID'); + * + * The USER_DEFINED_SUBTYPE must be unique to the platform accessory (if you platform exposes multiple accessories, each accessory + * can use the same subtype id.) + */ + + } + + /** + * Handle "GET" requests from HomeKit + */ + async handleCurrentPositionGet() { + // implement your own code to check if the device is on + const windowCoveringCurrentPosition = this.states.WindowCoveringCurrentPosition; + + this.platform.log.debug(`[${this.accessoryConfiguration.accessoryName}] Getting Current Window Covering Position: ${this.getStateName(windowCoveringCurrentPosition)}`); + + // if you need to return an error to show the device as "Not Responding" in the Home app: + // throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + + return windowCoveringCurrentPosition; + } + + /** + * Handle "SET" requests from HomeKit + * These are sent when the user changes the state of an accessory, for example, turning on a Light bulb. + */ + async handleTargetPositionSet(value: CharacteristicValue) { + // implement your own code to turn your device on/off + this.states.WindowCoveringCurrentPosition = value as number; + + // Store device state if stateful + if (this.accessoryConfiguration.accessoryIsStateful) { + this.saveState(this.storagePath, this.stateStorageKey, this.states.WindowCoveringCurrentPosition); + } + + this.platform.log.info(`[${this.accessoryConfiguration.accessoryName}] Setting Target Window Covering Position: ${this.getStateName(this.states.WindowCoveringCurrentPosition)}`); + + // PositionState DECREASING/INCREASING + this.states.WindowCoveringPositionState = (this.states.WindowCoveringCurrentPosition === WindowCovering.OPEN) ? WindowCovering.INCREASING : WindowCovering.DECREASING; + this.service!.setCharacteristic(this.platform.Characteristic.PositionState, (this.states.WindowCoveringPositionState)); + this.platform.log.info(`[${this.accessoryConfiguration.accessoryName}] Setting Curent Window Covering State: ${this.getPositionName(this.states.WindowCoveringPositionState)}`); + + // PositionState STOPPED + // CurrentPosition OPEN/CLOSED with 3 second delay + const transitionDelayMillis: number = 3 * 1000; + setTimeout(() => { + this.states.WindowCoveringPositionState = WindowCovering.STOPPED; + this.service!.setCharacteristic(this.platform.Characteristic.PositionState, (this.states.WindowCoveringPositionState)); + this.platform.log.info(`[${this.accessoryConfiguration.accessoryName}] Setting Curent Window Covering State: ${this.getPositionName(this.states.WindowCoveringPositionState)}`); + + this.service!.setCharacteristic(this.platform.Characteristic.CurrentPosition, (this.states.WindowCoveringCurrentPosition)); + this.platform.log.info(`[${this.accessoryConfiguration.accessoryName}] Setting Current Garage Door State: ${this.getStateName(this.states.WindowCoveringCurrentPosition)}`); + }, transitionDelayMillis); + } + + /** + * Handle the "GET" requests from HomeKit + * These are sent when HomeKit wants to know the current state of the accessory, for example, checking if a Light bulb is on. + * + * GET requests should return as fast as possible. A long delay here will result in + * HomeKit being unresponsive and a bad user experience in general. + * + * If your device takes time to respond you should update the status of your device + * asynchronously instead using the `updateCharacteristic` method instead. + + * @example + * this.service.updateCharacteristic(this.platform.Characteristic.On, true) + */ + async handleTargetPositionGet(): Promise { + // implement your own code to check if the device is on + const windowCoveringPosition = this.states.WindowCoveringCurrentPosition; + + this.platform.log.debug(`[${this.accessoryConfiguration.accessoryName}] Getting Target Window Covering Position: ${this.getStateName(windowCoveringPosition)}`); + + // if you need to return an error to show the device as "Not Responding" in the Home app: + // throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + + return windowCoveringPosition; + } + + /** + * Handle "GET" requests from HomeKit + */ + async handlePositionStateGet() { + // implement your own code to check if the device is on + const windowCoveringPosition = this.states.WindowCoveringPositionState; + + this.platform.log.debug(`[${this.accessoryConfiguration.accessoryName}] Getting Window Covering State: ${this.getPositionName(windowCoveringPosition)}`); + + // if you need to return an error to show the device as "Not Responding" in the Home app: + // throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); + + return windowCoveringPosition; + } + + private getStateName(position: number): string { + let positionName: string; + + switch (position) { + case undefined: { positionName = 'undefined'; break; } + case WindowCovering.CLOSED: { positionName = 'CLOSED'; break; } + case WindowCovering.OPEN: { positionName = 'OPEN'; break; } + default: { positionName = `POSITION: ${position.toString()}%`; } + } + + if (position > WindowCovering.OPEN) { + positionName = `INVALID ${positionName}%`; + } + + return positionName; + } + + private getPositionName(state: number): string { + let stateName: string; + + switch (state) { + case undefined: { stateName = 'undefined'; break; } + case WindowCovering.DECREASING: { stateName = 'DECREASING'; break; } + case WindowCovering.INCREASING: { stateName = 'INCREASING'; break; } + case WindowCovering.STOPPED: { stateName = 'STOPPED'; break; } + default: { stateName = state.toString(); } + } + + return stateName; + } +} diff --git a/src/accessoryFactory.ts b/src/accessoryFactory.ts index c684646..0b70cfa 100644 --- a/src/accessoryFactory.ts +++ b/src/accessoryFactory.ts @@ -7,6 +7,7 @@ import { Switch } from './accessories/virtualAccessorySwitch.js'; import { Lock } from './accessories/virtualAccessoryLock.js'; import { Doorbell } from './accessories/virtualAccessoryDoorbell.js'; import { GarageDoor } from './accessories/virtualAccessoryGarageDoor.js'; +import { WindowCovering } from './accessories/virtualAccessoryWindowCovering.js'; import { VirtualSensor } from './sensors/virtualSensor.js'; import { VirtualContactSensor } from './sensors/virtualSensorContact.js'; @@ -55,6 +56,9 @@ export abstract class AccessoryFactory { case 'garagedoor': virtualAccessory = new GarageDoor(platform, accessory); break; + case 'windowcovering': + virtualAccessory = new WindowCovering(platform, accessory); + break; case 'sensor': virtualAccessory = AccessoryFactory.createVirtualSensor(platform, accessory, accessoryConfiguration.sensorType); break; diff --git a/src/configuration/configurationAccessory.ts b/src/configuration/configurationAccessory.ts index a6454b3..807f78f 100644 --- a/src/configuration/configurationAccessory.ts +++ b/src/configuration/configurationAccessory.ts @@ -34,6 +34,9 @@ export class AccessoryConfiguration { // Doorbell doorbellVolume!: number; + // Window Covering + windowCoveringDefaultState!: string; + // Sensor sensorType!: string; sensorTrigger!: string; @@ -93,6 +96,8 @@ export class AccessoryConfiguration { return this.isValidSensor(); case 'switch': return this.isValidSwitch(); + case 'windowcovering': + return this.isValidWindowCovering(); default: return false; } @@ -205,6 +210,17 @@ export class AccessoryConfiguration { ); }; + private isValidWindowCovering(): boolean { + const isValidWindowCoveringDefaultState: boolean = (this.windowCoveringDefaultState !== undefined); + + // Store fields failing validation + if (!isValidWindowCoveringDefaultState) this.errorFields.push('windowCoveringDefaultState'); + + return ( + isValidWindowCoveringDefaultState + ); + } + /** * Adornment validation */