├── renovate.json ├── nodemon.json ├── .idea ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── misc.xml ├── vcs.xml ├── jsLinters │ └── eslint.xml ├── .gitignore ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml ├── homebridge-flair.iml └── aws.xml ├── src ├── utils.ts ├── settings.ts ├── index.ts ├── puckPlatformAccessory.ts ├── structurePlatformAccessory.ts ├── roomPlatformAccessory.ts ├── ventPlatformAccessory.ts └── platform.ts ├── .github └── workflows │ ├── test.yml │ ├── publish.yml │ └── linter.yml ├── tsconfig.json ├── .eslintrc.js ├── package.json ├── .gitignore ├── .npmignore ├── config.schema.json └── README.md /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>Pocket/renovate-config" 4 | ] 5 | } -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src" 4 | ], 5 | "ext": "ts", 6 | "ignore": [], 7 | "exec": "tsc && homebridge -I -D" 8 | } 9 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jsLinters/eslint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function getRandomIntInclusive(min: number, max: number): number { 2 | min = Math.ceil(min); 3 | max = Math.floor(max); 4 | return Math.floor(Math.random() * (max - min + 1)) + min; //The maximum is inclusive and the minimum is inclusive 5 | } 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/homebridge-flair.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the name of the platform that users will use to register the plugin in the Homebridge config.json 3 | */ 4 | export const PLATFORM_NAME = 'Flair'; 5 | 6 | /** 7 | * This must match the name of your plugin as defined the package.json 8 | */ 9 | export const PLUGIN_NAME = 'homebridge-flair'; 10 | -------------------------------------------------------------------------------- /.idea/aws.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { API } from 'homebridge'; 2 | 3 | import { PLATFORM_NAME } from './settings'; 4 | import { FlairPlatform } from './platform'; 5 | import 'reflect-metadata'; 6 | 7 | /** 8 | * This method registers the platform with Homebridge 9 | */ 10 | export = (api: API): void => { 11 | api.registerPlatform('homebridge-flair', PLATFORM_NAME, FlairPlatform); 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test & Build 2 | 3 | on: 4 | push 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2 11 | - uses: actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561 # renovate: tag=v2 12 | with: 13 | node-version: '16' 14 | - run: npm install 15 | - run: npm run build -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2015", 7 | "es2016", 8 | "es2017", 9 | "es2018" 10 | ], 11 | "declaration": true, 12 | "declarationMap": true, 13 | "sourceMap": true, 14 | "outDir": "./dist", 15 | "rootDir": "./src", 16 | "strict": true, 17 | "esModuleInterop": true, 18 | "noImplicitAny": false 19 | }, 20 | "include": [ 21 | "src/" 22 | ], 23 | "exclude": [ 24 | "**/*.spec.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2 14 | with: 15 | fetch-depth: 0 16 | persist-credentials: false 17 | - uses: actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561 # renovate: tag=v2 18 | with: 19 | node-version: '16' 20 | - run: npm install 21 | - run: npm run build 22 | - name: Semantic Release 23 | uses: cycjimmy/semantic-release-action@v2 24 | env: 25 | # secrets.GITHUB_TOKEN does not have necessary permissions 26 | GH_TOKEN: ${{ secrets.SEMENTIC_AND_DEPBOT_TOKEN }} 27 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 22 | 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/eslint-recommended', 6 | 'plugin:@typescript-eslint/recommended', // uses the recommended rules from the @typescript-eslint/eslint-plugin 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 2018, 10 | sourceType: 'module', 11 | }, 12 | ignorePatterns: [ 13 | 'dist', 14 | ], 15 | rules: { 16 | 'quotes': ['warn', 'single'], 17 | 'indent': ['warn', 2, { 'SwitchCase': 1 }], 18 | 'linebreak-style': ['warn', 'unix'], 19 | 'semi': ['warn', 'always'], 20 | 'comma-dangle': ['warn', 'always-multiline'], 21 | 'dot-notation': 'warn', 22 | 'eqeqeq': 'warn', 23 | 'curly': ['warn', 'all'], 24 | 'brace-style': ['warn'], 25 | 'prefer-arrow-callback': ['warn'], 26 | 'max-len': ['warn', 140], 27 | 'no-console': ['warn'], // use the provided Homebridge log method instead 28 | 'lines-between-class-members': ['warn', 'always', {'exceptAfterSingleLine': true}], 29 | '@typescript-eslint/explicit-function-return-type': 'off', 30 | '@typescript-eslint/no-non-null-assertion': 'off', 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "displayName": "Homebridge Flair", 3 | "name": "homebridge-flair", 4 | "version": "0.0.0-development", 5 | "publishConfig": { 6 | "registry": "https://registry.npmjs.org/" 7 | }, 8 | "description": "Brings the flair smart vents into homekit", 9 | "license": "ISC", 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/bassrock/homebridge-flair.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/bassrock/homebridge-flair/issues" 16 | }, 17 | "engines": { 18 | "node": ">=10.17.0", 19 | "homebridge": ">0.4.53" 20 | }, 21 | "main": "dist/index.js", 22 | "scripts": { 23 | "lint": "eslint src/**.ts --max-warnings=0", 24 | "lint:fix": "eslint --fix src/**", 25 | "watch": "npm run build && npm link && nodemon", 26 | "build": "rimraf ./dist && tsc" 27 | }, 28 | "release": { 29 | "branches": [ 30 | "main" 31 | ], 32 | "plugins": [ 33 | "@semantic-release/commit-analyzer", 34 | "@semantic-release/release-notes-generator", 35 | "@semantic-release/npm", 36 | "@semantic-release/github" 37 | ] 38 | }, 39 | "keywords": [ 40 | "homebridge-plugin", 41 | "flair" 42 | ], 43 | "dependencies": { 44 | "class-transformer": "^0.5.1", 45 | "flair-api-ts": "^1.0.28", 46 | "reflect-metadata": "^0.1.13" 47 | }, 48 | "devDependencies": { 49 | "@semantic-release/changelog": "6.0.2", 50 | "@semantic-release/commit-analyzer": "9.0.2", 51 | "@semantic-release/git": "10.0.1", 52 | "@semantic-release/github": "8.0.7", 53 | "@semantic-release/npm": "9.0.1", 54 | "@semantic-release/release-notes-generator": "10.0.3", 55 | "@types/node": "18.11.18", 56 | "@typescript-eslint/eslint-plugin": "5.47.1", 57 | "@typescript-eslint/parser": "5.47.1", 58 | "eslint": "8.31.0", 59 | "homebridge": "1.6.0", 60 | "nodemon": "2.0.20", 61 | "rimraf": "3.0.2", 62 | "ts-node": "10.9.1", 63 | "typescript": "4.9.4" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore compiled code 2 | dist 3 | 4 | # ------------- Defaults ------------- # 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variables file 77 | .env 78 | .env.test 79 | 80 | # parcel-bundler cache (https://parceljs.org/) 81 | .cache 82 | .parcel-cache 83 | 84 | # Next.js build output 85 | .next 86 | 87 | # Nuxt.js build / generate output 88 | .nuxt 89 | dist 90 | 91 | # Gatsby files 92 | .cache/ 93 | # Comment in the public line in if your project uses Gatsby and not Next.js 94 | # https://nextjs.org/blog/next-9-1#public-directory-support 95 | # public 96 | 97 | # vuepress build output 98 | .vuepress/dist 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | # TernJS port file 110 | .tern-port 111 | 112 | # Stores VSCode versions used for testing VSCode extensions 113 | .vscode-test 114 | 115 | # yarn v2 116 | 117 | .yarn/cache 118 | .yarn/unplugged 119 | .yarn/build-state.yml 120 | .pnp.* 121 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Ignore source code 2 | src 3 | 4 | # ------------- Defaults ------------- # 5 | 6 | # eslint 7 | .eslintrc 8 | 9 | # typescript 10 | tsconfig.json 11 | 12 | # vscode 13 | .vscode 14 | 15 | # nodemon 16 | nodemon.json 17 | 18 | # Logs 19 | logs 20 | *.log 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | lerna-debug.log* 25 | 26 | # Diagnostic reports (https://nodejs.org/api/report.html) 27 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 28 | 29 | # Runtime data 30 | pids 31 | *.pid 32 | *.seed 33 | *.pid.lock 34 | 35 | # Directory for instrumented libs generated by jscoverage/JSCover 36 | lib-cov 37 | 38 | # Coverage directory used by tools like istanbul 39 | coverage 40 | *.lcov 41 | 42 | # nyc test coverage 43 | .nyc_output 44 | 45 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 46 | .grunt 47 | 48 | # Bower dependency directory (https://bower.io/) 49 | bower_components 50 | 51 | # node-waf configuration 52 | .lock-wscript 53 | 54 | # Compiled binary addons (https://nodejs.org/api/addons.html) 55 | build/Release 56 | 57 | # Dependency directories 58 | node_modules/ 59 | jspm_packages/ 60 | 61 | # Snowpack dependency directory (https://snowpack.dev/) 62 | web_modules/ 63 | 64 | # TypeScript cache 65 | *.tsbuildinfo 66 | 67 | # Optional npm cache directory 68 | .npm 69 | 70 | # Optional eslint cache 71 | .eslintcache 72 | 73 | # Microbundle cache 74 | .rpt2_cache/ 75 | .rts2_cache_cjs/ 76 | .rts2_cache_es/ 77 | .rts2_cache_umd/ 78 | 79 | # Optional REPL history 80 | .node_repl_history 81 | 82 | # Output of 'npm pack' 83 | *.tgz 84 | 85 | # Yarn Integrity file 86 | .yarn-integrity 87 | 88 | # dotenv environment variables file 89 | .env 90 | .env.test 91 | 92 | # parcel-bundler cache (https://parceljs.org/) 93 | .cache 94 | .parcel-cache 95 | 96 | # Next.js build output 97 | .next 98 | 99 | # Nuxt.js build / generate output 100 | .nuxt 101 | dist 102 | 103 | # Gatsby files 104 | .cache/ 105 | # Comment in the public line in if your project uses Gatsby and not Next.js 106 | # https://nextjs.org/blog/next-9-1#public-directory-support 107 | # public 108 | 109 | # vuepress build output 110 | .vuepress/dist 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .pnp.* 133 | -------------------------------------------------------------------------------- /config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginAlias": "Flair", 3 | "pluginType": "platform", 4 | "singular": true, 5 | "schema": { 6 | "type": "object", 7 | "properties": { 8 | "clientId": { 9 | "title": "Client ID", 10 | "type": "string", 11 | "description": "Client ID obtained from Flair Support", 12 | "required": true, 13 | "default": "" 14 | }, 15 | "clientSecret": { 16 | "title": "Client Secret", 17 | "type": "string", 18 | "description": "Client Secret obtained from Flair Support", 19 | "required": true, 20 | "default": "" 21 | }, 22 | "username": { 23 | "title": "username", 24 | "type": "string", 25 | "description": "Username used to access Flair", 26 | "required": true, 27 | "default": "" 28 | }, 29 | "password": { 30 | "title": "password", 31 | "type": "string", 32 | "description": "Password used to access Flair", 33 | "required": true, 34 | "default": "" 35 | }, 36 | "pollInterval": { 37 | "title": "Poll Interval", 38 | "type": "number", 39 | "description": "How often the plugin should poll the Flair API for updates.", 40 | "required": true, 41 | "default": 60 42 | }, 43 | "hidePuckSensors": { 44 | "title": "Hide puck sensors", 45 | "description": "Hides the Puck Sensors", 46 | "type": "boolean", 47 | "required": false, 48 | "default": true 49 | }, 50 | "hidePuckRooms": { 51 | "title": "Hide room thermostats", 52 | "description": "Hides the Puck Rooms (thermostats)", 53 | "type": "boolean", 54 | "required": false, 55 | "default": false 56 | }, 57 | "hidePrimaryStructure": { 58 | "title": "Hide primary structure", 59 | "description": "Hides the primary structure thermostat", 60 | "type": "boolean", 61 | "required": false, 62 | "default": true 63 | }, 64 | "hideVentTemperatureSensors": { 65 | "title": "Hide vent temperature sensors", 66 | "description": "Hides the Vent Temperature Sensors", 67 | "type": "boolean", 68 | "required": false, 69 | "default": false 70 | }, 71 | "ventAccessoryType": { 72 | "title": "Vent Accessory Type", 73 | "description": "Controls how the vents should show up in HomeKit", 74 | "type": "string", 75 | "required": true, 76 | "default": "windowCovering", 77 | "oneOf": [ 78 | { 79 | "title": "Window Covering", 80 | "enum": [ 81 | "windowCovering" 82 | ] 83 | }, 84 | { 85 | "title": "Fan", 86 | "enum": [ 87 | "fan" 88 | ] 89 | }, 90 | { 91 | "title": "Air Purifier", 92 | "enum": [ 93 | "airPurifier" 94 | ] 95 | }, 96 | { 97 | "title": "Hidden (if you just want to use Flair Auto with Rooms)", 98 | "enum": [ 99 | "hidden" 100 | ] 101 | } 102 | ] 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homebridge-flair 2 | [![verified-by-homebridge](https://badgen.net/badge/homebridge/verified/purple)](https://github.com/homebridge/homebridge/wiki/Verified-Plugins) 3 | 4 | [Flair Smart Vent](https://flair.co/products/vent) plug-in for [Homebridge](https://github.com/nfarina/homebridge) using the Flair API. 5 | 6 | 7 | # Installation 8 | 9 | 10 | 1. Install homebridge using: `npm install -g homebridge` 11 | 2. Install this plug-in using: `npm install -g homebridge-flair` 12 | 3. Update your configuration file. See example `config.json` snippet below. 13 | 14 | # Configuration 15 | 16 | Configuration sample (edit `~/.homebridge/config.json`): 17 | 18 | ```json 19 | { 20 | "platforms": [ 21 | { 22 | "clientId": "client_id", 23 | "clientSecret": "client_secret", 24 | "username": "user", 25 | "password": "pass", 26 | "pollInterval": 60, 27 | "platform": "Flair", 28 | "ventAccessoryType": "windowCovering" 29 | } 30 | ] 31 | } 32 | ``` 33 | 34 | # Obtaining Credentials 35 | 36 | In order to use this plugin you will need to obtain a client id and client secret from Flair. 37 | 38 | Start by creating a Flair account at [my.flair.co](https://my.flair.co/) (if you haven't already), then use [this web form to request credentials](https://forms.gle/VohiQjWNv9CAP2ASA). 39 | 40 | More [API docs and details](https://flair.co/api) 41 | 42 | # Auto Vs Manual Mode 43 | 44 | When you use Pucks with your setup the pucks will appear in the app as a Thermostat. 45 | 46 | ~~If you turn those thermostats off it will put the Flair system into Manual mode. If you turn the thermostat to any other setting it will set your system to Flair's Auto mode.~~ As of Version 1.3.0 homekit does not do any switching from Auto to Manual mode. This must be done through the flair app, the Puck thermostats now respect the "off" setting. 47 | 48 | # Vent Accessory Type 49 | 50 | You can specify how vent accessories are shown in HomeKit with the `ventAccessoryType` property. 51 | 52 | `windowCovering` - Window Covering 53 | `fan` - Fan 54 | `airPurifier` - Air Purifier 55 | `hidden` - Hidden, this is useful if you have a puck in each room and want to only expose the room "thermostats" 56 | 57 | 58 | ### Commit format 59 | 60 | Commits should be formatted as `type(scope): message` 61 | 62 | The following types are allowed: 63 | 64 | | Type | Description | 65 | |---|---| 66 | | feat | A new feature | 67 | | fix | A bug fix | 68 | | docs | Documentation only changes | 69 | | style | Changes that do not affect the meaning of the code (white-space, formatting,missing semi-colons, etc) | 70 | | refactor | A code change that neither fixes a bug nor adds a feature | 71 | | perf | A code change that improves performance | 72 | | test | Adding missing or correcting existing tests | 73 | | chore | Changes to the build process or auxiliary tools and libraries such as documentation generation | 74 | 75 | ### Releasing 76 | 77 | A new version is released when a merge or push to `main` occurs. 78 | 79 | We use the rules at [default-release-rules.js](https://github.com/semantic-release/commit-analyzer/blob/master/lib/default-release-rules.js) as our guide to when a series of commits should create a release. 80 | -------------------------------------------------------------------------------- /src/puckPlatformAccessory.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Service, 3 | PlatformAccessory, 4 | } from 'homebridge'; 5 | 6 | import {FlairPlatform} from './platform'; 7 | import {Puck, Client} from 'flair-api-ts'; 8 | import {getRandomIntInclusive} from './utils'; 9 | 10 | /** 11 | * Platform Accessory 12 | * An instance of this class is created for each accessory your platform registers 13 | * Each accessory may expose multiple services of different service types. 14 | */ 15 | export class FlairPuckPlatformAccessory { 16 | private temperatureService: Service; 17 | private humidityService: Service; 18 | private accessoryInformationService: Service; 19 | 20 | private client: Client; 21 | private puck: Puck; 22 | 23 | 24 | constructor( 25 | private readonly platform: FlairPlatform, 26 | private readonly accessory: PlatformAccessory, 27 | client: Client, 28 | ) { 29 | this.puck = this.accessory.context.device; 30 | this.client = client; 31 | 32 | // set accessory information 33 | this.accessoryInformationService = this.accessory.getService(this.platform.Service.AccessoryInformation)! 34 | .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Flair') 35 | .setCharacteristic(this.platform.Characteristic.Model, 'Puck') 36 | .setCharacteristic(this.platform.Characteristic.SerialNumber, this.puck.displayNumber); 37 | 38 | // you can create multiple services for each accessory 39 | this.temperatureService = this.accessory.getService(this.platform.Service.TemperatureSensor) 40 | ?? this.accessory.addService(this.platform.Service.TemperatureSensor); 41 | this.temperatureService.setPrimaryService(true); 42 | this.temperatureService.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.name); 43 | this.temperatureService.setCharacteristic( 44 | this.platform.Characteristic.CurrentTemperature, 45 | this.puck.currentTemperatureC, 46 | ); 47 | 48 | this.humidityService = this.accessory.getService(this.platform.Service.HumiditySensor) 49 | ?? this.accessory.addService(this.platform.Service.HumiditySensor); 50 | this.humidityService.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.name); 51 | this.humidityService.setCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity, this.puck.currentHumidity); 52 | this.temperatureService.addLinkedService(this.humidityService); 53 | 54 | setInterval(async () => { 55 | await this.getNewPuckReadings(); 56 | }, (platform.config.pollInterval + getRandomIntInclusive(1, 20)) * 1000); 57 | this.getNewPuckReadings(); 58 | } 59 | 60 | async getNewPuckReadings(): Promise { 61 | try { 62 | const puck = await this.client.getPuckReading(this.puck); 63 | this.updatePuckReadingsFromPuck(puck); 64 | return puck; 65 | } catch (e) { 66 | this.platform.log.debug(e as string); 67 | } 68 | 69 | return this.puck; 70 | } 71 | 72 | updatePuckReadingsFromPuck(puck: Puck):void { 73 | this.accessory.context.device = puck; 74 | this.puck = puck; 75 | 76 | // push the new value to HomeKit 77 | this.temperatureService.updateCharacteristic(this.platform.Characteristic.CurrentTemperature, this.puck.currentTemperatureC); 78 | this.humidityService.updateCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity, this.puck.currentHumidity); 79 | 80 | this.accessory.getService(this.platform.Service.AccessoryInformation)! 81 | .updateCharacteristic(this.platform.Characteristic.FirmwareRevision, String(this.puck.firmwareVersionS)); 82 | 83 | this.platform.log.debug(`Pushed updated current temperature state for ${this.puck.name!} to HomeKit:`, this.puck.currentTemperatureC); 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ########################### 3 | ########################### 4 | ## Linter GitHub Actions ## 5 | ########################### 6 | ########################### 7 | name: Lint Code Base 8 | 9 | # 10 | # Documentation: 11 | # https://help.github.com/en/articles/workflow-syntax-for-github-actions 12 | # 13 | 14 | ############################# 15 | # Start the job on all pull requests # 16 | ############################# 17 | on: 18 | # Run on every pull request created or updated 19 | # https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#pull_request 20 | pull_request: 21 | 22 | ############### 23 | # Set the Job # 24 | ############### 25 | jobs: 26 | build: 27 | # Name the Job 28 | name: Lint Code Base 29 | # Set the agent to run on 30 | runs-on: ubuntu-latest 31 | 32 | ################## 33 | # Load all steps # 34 | ################## 35 | steps: 36 | ########################## 37 | # Checkout the code base # 38 | ########################## 39 | - name: Checkout Code 40 | uses: actions/checkout@dc323e67f16fb5f7663d20ff7941f27f5809e9b6 # v2 41 | 42 | ########################## 43 | # Github Super Linter needs 44 | # the latest definitions installed 45 | ########################## 46 | - name: Use Node.js 16.x 47 | uses: actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561 # renovate: tag=v2.5.1 48 | with: 49 | node-version: 16.x 50 | # Install our eslint packages. 51 | # We may have custom tsconfigs, eslints that are brought in via a package. 52 | - run: npm install 53 | 54 | ################################ 55 | # Run Linter against code base # 56 | ################################ 57 | - name: Lint Code Base 58 | 59 | # We use the Github super linter, it can be cranky at times, so in that moment here are the docs https://github.com/github/super-linter 60 | uses: docker://ghcr.io/github/super-linter:slim-v4@sha256:900277f36d47d5ddc460d901ea9dfcb1d348f7390066f800a0895cd88866b31f 61 | 62 | # All Environment variables are defined here https://github.com/github/super-linter#environment-variables 63 | env: 64 | # The name of the repository default branch. 65 | DEFAULT_BRANCH: main 66 | 67 | # Directory for all linter configuration rules. 68 | # This is the root of our codebase. 69 | LINTER_RULES_PATH: / 70 | 71 | # Will parse the entire repository and find all files to validate across all types. 72 | # NOTE: When set to false, only new or edited files will be parsed for validation. 73 | VALIDATE_ALL_CODEBASE: true 74 | 75 | # Filename for ESLint configuration (ex: .eslintrc.yml, .eslintrc.json) 76 | TYPESCRIPT_ES_CONFIG_FILE: .eslintrc.js 77 | 78 | ##### 79 | # Note: All the VALIDATE[LANGUAGE] variables behave in a specific way. 80 | # If none of them are passed, then they all default to true. 81 | # However if any one of the variables are set, we default to leaving any unset variable to false. 82 | # This means that if you run the linter “out of the box”, all languages will be checked. 83 | # But if you wish to select specific linters, we give you full control to choose which linters are run, and won’t run anything unexpected. 84 | #### 85 | 86 | # Flag to enable or disable the linting process of the JavaScript language. (Utilizing: eslint) 87 | # Will validate any raw *.js in the repo like a jest.config.js 88 | VALIDATE_JAVASCRIPT_ES: true 89 | 90 | # Flag to enable or disable the linting process of the TypeScript language. (Utilizing: eslint) 91 | VALIDATE_TYPESCRIPT_ES: true 92 | 93 | # Flag to enable or disable the linting process of the YAML language. 94 | VALIDATE_YAML: true 95 | -------------------------------------------------------------------------------- /src/structurePlatformAccessory.ts: -------------------------------------------------------------------------------- 1 | import type {PlatformAccessory, Service} from 'homebridge'; 2 | import { 3 | CharacteristicEventTypes, 4 | CharacteristicGetCallback, 5 | CharacteristicSetCallback, 6 | CharacteristicValue, 7 | } from 'homebridge'; 8 | 9 | import {FlairPlatform} from './platform'; 10 | import {Structure, StructureHeatCoolMode, Client} from 'flair-api-ts'; 11 | 12 | /** 13 | * Platform Accessory 14 | * An instance of this class is created for each accessory your platform registers 15 | * Each accessory may expose multiple services of different service types. 16 | */ 17 | export class FlairStructurePlatformAccessory { 18 | private accessoryInformationService: Service; 19 | private thermostatService: Service; 20 | 21 | private client: Client; 22 | private structure: Structure; 23 | 24 | 25 | constructor( 26 | private readonly platform: FlairPlatform, 27 | private readonly accessory: PlatformAccessory, 28 | client: Client, 29 | ) { 30 | this.structure = this.accessory.context.device; 31 | this.client = client; 32 | 33 | // set accessory information 34 | this.accessoryInformationService = this.accessory.getService(this.platform.Service.AccessoryInformation)! 35 | .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Flair') 36 | .setCharacteristic(this.platform.Characteristic.Model, 'Structure') 37 | .setCharacteristic(this.platform.Characteristic.SerialNumber, this.structure.id!); 38 | 39 | this.thermostatService = this.accessory.getService(this.platform.Service.Thermostat) 40 | ?? this.accessory.addService(this.platform.Service.Thermostat); 41 | this.thermostatService.setPrimaryService(true); 42 | this.thermostatService 43 | .setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.name) 44 | .setCharacteristic(this.platform.Characteristic.CurrentTemperature, this.structure.setPointTemperatureC!) 45 | .setCharacteristic(this.platform.Characteristic.TargetTemperature, this.structure.setPointTemperatureC!) 46 | .setCharacteristic( 47 | this.platform.Characteristic.TargetHeatingCoolingState, 48 | this.getTargetHeatingCoolingState(this.structure)!, 49 | ) 50 | .setCharacteristic( 51 | this.platform.Characteristic.CurrentHeatingCoolingState, 52 | this.getCurrentHeatingCoolingState(this.structure)!, 53 | ); 54 | 55 | this.thermostatService.getCharacteristic(this.platform.Characteristic.TargetTemperature) 56 | .on(CharacteristicEventTypes.SET, this.setTargetTemperature.bind(this)) 57 | .on(CharacteristicEventTypes.GET, this.getTargetTemperature.bind(this)); 58 | 59 | this.thermostatService.getCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState) 60 | .on(CharacteristicEventTypes.SET, this.setTargetHeatingCoolingState.bind(this)); 61 | } 62 | 63 | setTargetHeatingCoolingState(value: CharacteristicValue, callback: CharacteristicSetCallback): void { 64 | if (value === this.platform.Characteristic.TargetHeatingCoolingState.OFF) { 65 | this.platform.setStructureMode(StructureHeatCoolMode.OFF).then((structure: Structure) => { 66 | callback(null); 67 | this.updateFromStructure(structure); 68 | }); 69 | } else if (value === this.platform.Characteristic.TargetHeatingCoolingState.COOL) { 70 | this.platform.setStructureMode(StructureHeatCoolMode.COOL).then((structure: Structure) => { 71 | callback(null); 72 | this.updateFromStructure(structure); 73 | }); 74 | } else if (value === this.platform.Characteristic.TargetHeatingCoolingState.HEAT) { 75 | this.platform.setStructureMode(StructureHeatCoolMode.HEAT).then((structure: Structure) => { 76 | callback(null); 77 | this.updateFromStructure(structure); 78 | }); 79 | } else if (value === this.platform.Characteristic.TargetHeatingCoolingState.AUTO) { 80 | this.platform.setStructureMode(StructureHeatCoolMode.AUTO).then((structure: Structure) => { 81 | callback(null); 82 | this.updateFromStructure(structure); 83 | }); 84 | } 85 | } 86 | 87 | setTargetTemperature(value: CharacteristicValue, callback: CharacteristicSetCallback): void { 88 | this.client.setStructureSetPoint(this.structure, value as number).then((structure: Structure) => { 89 | // you must call the callback function 90 | callback(null); 91 | this.updateFromStructure(structure); 92 | this.platform.log.debug('Set Characteristic Temperature -> ', value); 93 | 94 | }); 95 | 96 | } 97 | 98 | getTargetTemperature(callback: CharacteristicGetCallback): void { 99 | callback(null, this.platform.structure ? this.platform.structure!.setPointTemperatureC : 0); 100 | } 101 | 102 | public updateFromStructure(structure: Structure): void { 103 | this.structure = structure; 104 | 105 | // push the new value to HomeKit 106 | this.thermostatService 107 | .updateCharacteristic(this.platform.Characteristic.TargetTemperature, this.structure.setPointTemperatureC!) 108 | .updateCharacteristic(this.platform.Characteristic.CurrentTemperature, this.structure.setPointTemperatureC!) 109 | .updateCharacteristic( 110 | this.platform.Characteristic.TargetHeatingCoolingState, 111 | this.getTargetHeatingCoolingState(this.structure)!, 112 | ) 113 | .updateCharacteristic( 114 | this.platform.Characteristic.CurrentHeatingCoolingState, 115 | this.getCurrentHeatingCoolingState(this.structure)!, 116 | ); 117 | 118 | this.platform.log.debug( 119 | `Pushed updated current structure state for ${this.structure.name!} to HomeKit:`, 120 | this.structure.structureHeatCoolMode!, 121 | ); 122 | } 123 | 124 | private getCurrentHeatingCoolingState(structure: Structure) { 125 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.COOL) { 126 | return this.platform.Characteristic.CurrentHeatingCoolingState.COOL; 127 | } 128 | 129 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.HEAT) { 130 | return this.platform.Characteristic.CurrentHeatingCoolingState.HEAT; 131 | } 132 | 133 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.AUTO) { 134 | //TODO: When the structure api shows the current thermostat mode change this to that. 135 | // For now active always means cool. 136 | return this.platform.Characteristic.CurrentHeatingCoolingState.COOL; 137 | } 138 | 139 | return this.platform.Characteristic.CurrentHeatingCoolingState.OFF; 140 | } 141 | 142 | private getTargetHeatingCoolingState(structure: Structure) { 143 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.COOL) { 144 | return this.platform.Characteristic.TargetHeatingCoolingState.COOL; 145 | } 146 | 147 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.HEAT) { 148 | return this.platform.Characteristic.TargetHeatingCoolingState.HEAT; 149 | } 150 | 151 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.AUTO) { 152 | return this.platform.Characteristic.TargetHeatingCoolingState.AUTO; 153 | } 154 | 155 | return this.platform.Characteristic.TargetHeatingCoolingState.OFF; 156 | } 157 | 158 | } 159 | -------------------------------------------------------------------------------- /src/roomPlatformAccessory.ts: -------------------------------------------------------------------------------- 1 | import type {PlatformAccessory, Service} from 'homebridge'; 2 | import { 3 | CharacteristicEventTypes, 4 | CharacteristicGetCallback, 5 | CharacteristicSetCallback, 6 | CharacteristicValue, 7 | } from 'homebridge'; 8 | 9 | import {FlairPlatform} from './platform'; 10 | import {Room, Structure, StructureHeatCoolMode, Client} from 'flair-api-ts'; 11 | import {getRandomIntInclusive} from './utils'; 12 | 13 | /** 14 | * Platform Accessory 15 | * An instance of this class is created for each accessory your platform registers 16 | * Each accessory may expose multiple services of different service types. 17 | */ 18 | export class FlairRoomPlatformAccessory { 19 | private accessoryInformationService: Service; 20 | private thermostatService: Service; 21 | 22 | private client: Client; 23 | private room: Room; 24 | private structure: Structure; 25 | 26 | 27 | constructor( 28 | private readonly platform: FlairPlatform, 29 | private readonly accessory: PlatformAccessory, 30 | client: Client, 31 | structure: Structure, 32 | ) { 33 | this.room = this.accessory.context.device; 34 | this.client = client; 35 | this.structure = structure; 36 | 37 | // set accessory information 38 | this.accessoryInformationService = this.accessory.getService(this.platform.Service.AccessoryInformation)! 39 | .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Flair') 40 | .setCharacteristic(this.platform.Characteristic.Model, 'Room') 41 | .setCharacteristic(this.platform.Characteristic.SerialNumber, this.room.id!); 42 | 43 | this.thermostatService = this.accessory.getService(this.platform.Service.Thermostat) 44 | ?? this.accessory.addService(this.platform.Service.Thermostat); 45 | this.thermostatService.setPrimaryService(true); 46 | this.thermostatService 47 | .setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.name) 48 | .setCharacteristic(this.platform.Characteristic.CurrentTemperature, this.room.currentTemperatureC!) 49 | .setCharacteristic(this.platform.Characteristic.TargetTemperature, this.room.setPointC!) 50 | .setCharacteristic( 51 | this.platform.Characteristic.TargetHeatingCoolingState, 52 | this.getTargetHeatingCoolingStateFromStructureAndRoom(this.structure)!, 53 | ) 54 | .setCharacteristic( 55 | this.platform.Characteristic.CurrentHeatingCoolingState, 56 | this.getCurrentHeatingCoolingStateFromStructureAndRoom(this.structure)!, 57 | ) 58 | .setCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity, this.room.currentHumidity!); 59 | 60 | this.thermostatService.getCharacteristic(this.platform.Characteristic.TargetTemperature) 61 | .on(CharacteristicEventTypes.SET, this.setTargetTemperature.bind(this)) 62 | .on(CharacteristicEventTypes.GET, this.getTargetTemperature.bind(this)); 63 | 64 | this.thermostatService.getCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState) 65 | .on(CharacteristicEventTypes.SET, this.setTargetHeatingCoolingState.bind(this)); 66 | 67 | setInterval(async () => { 68 | await this.getNewRoomReadings(); 69 | }, (platform.config.pollInterval + getRandomIntInclusive(1, 20)) * 1000); 70 | this.getNewRoomReadings(); 71 | } 72 | 73 | setTargetHeatingCoolingState(value: CharacteristicValue, callback: CharacteristicSetCallback): void { 74 | if (value === this.platform.Characteristic.TargetHeatingCoolingState.OFF) { 75 | this.client.setRoomAway(this.room, true).then((room: Room) => { 76 | this.updateRoomReadingsFromRoom(room); 77 | this.platform.log.debug('Set Room to away', value); 78 | // you must call the callback function 79 | callback(null); 80 | }); 81 | } else if (value === this.platform.Characteristic.TargetHeatingCoolingState.COOL) { 82 | this.setRoomActive(); 83 | this.platform.setStructureMode(StructureHeatCoolMode.COOL).then((structure: Structure) => { 84 | callback(null); 85 | this.updateFromStructure(structure); 86 | }); 87 | } else if (value === this.platform.Characteristic.TargetHeatingCoolingState.HEAT) { 88 | this.setRoomActive(); 89 | this.platform.setStructureMode(StructureHeatCoolMode.HEAT).then((structure: Structure) => { 90 | callback(null); 91 | this.updateFromStructure(structure); 92 | }); 93 | } else if (value === this.platform.Characteristic.TargetHeatingCoolingState.AUTO) { 94 | this.setRoomActive(); 95 | this.platform.setStructureMode(StructureHeatCoolMode.AUTO).then((structure: Structure) => { 96 | callback(null); 97 | this.updateFromStructure(structure); 98 | }); 99 | } 100 | } 101 | 102 | setRoomActive(): void { 103 | if (this.room.active) { 104 | return; 105 | } 106 | this.client.setRoomAway(this.room, false).then(() => { 107 | this.platform.log.debug('Set Room to active'); 108 | }); 109 | } 110 | 111 | 112 | setTargetTemperature(value: CharacteristicValue, callback: CharacteristicSetCallback): void { 113 | this.client.setRoomSetPoint(this.room, value as number).then((room: Room) => { 114 | this.updateRoomReadingsFromRoom(room); 115 | this.platform.log.debug('Set Characteristic Temperature -> ', value); 116 | // you must call the callback function 117 | callback(null); 118 | }); 119 | 120 | } 121 | 122 | getTargetTemperature(callback: CharacteristicGetCallback): void { 123 | this.getNewRoomReadings().then((room: Room) => { 124 | callback(null, room.setPointC); 125 | }); 126 | } 127 | 128 | 129 | async getNewRoomReadings(): Promise { 130 | try { 131 | const room = await this.client.getRoom(this.room); 132 | this.updateRoomReadingsFromRoom(room); 133 | return room; 134 | } catch (e) { 135 | this.platform.log.debug(e as string); 136 | } 137 | 138 | return this.room; 139 | } 140 | 141 | public updateFromStructure(structure: Structure): void { 142 | this.structure = structure; 143 | 144 | // push the new value to HomeKit 145 | this.updateRoomReadingsFromRoom(this.room); 146 | 147 | this.platform.log.debug( 148 | `Pushed updated current structure state for ${this.room.name!} to HomeKit:`, 149 | this.structure.structureHeatCoolMode!, 150 | ); 151 | } 152 | 153 | updateRoomReadingsFromRoom(room: Room): void { 154 | this.accessory.context.device = room; 155 | this.room = room; 156 | 157 | // push the new value to HomeKit 158 | this.thermostatService 159 | .updateCharacteristic(this.platform.Characteristic.CurrentTemperature, this.room.currentTemperatureC!) 160 | .updateCharacteristic(this.platform.Characteristic.TargetTemperature, this.room.setPointC!) 161 | .updateCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity, this.room.currentHumidity!) 162 | .updateCharacteristic( 163 | this.platform.Characteristic.TargetHeatingCoolingState, 164 | this.getTargetHeatingCoolingStateFromStructureAndRoom(this.structure)!, 165 | ) 166 | .updateCharacteristic( 167 | this.platform.Characteristic.CurrentHeatingCoolingState, 168 | this.getCurrentHeatingCoolingStateFromStructureAndRoom(this.structure)!, 169 | ); 170 | this.platform.log.debug( 171 | `Pushed updated current temperature state for ${this.room.name!} to HomeKit:`, 172 | this.room.currentTemperatureC!, 173 | ); 174 | } 175 | 176 | private getCurrentHeatingCoolingStateFromStructureAndRoom(structure: Structure) { 177 | if (!this.room.active) { 178 | return this.platform.Characteristic.CurrentHeatingCoolingState.OFF; 179 | } 180 | 181 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.COOL) { 182 | return this.platform.Characteristic.CurrentHeatingCoolingState.COOL; 183 | } 184 | 185 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.HEAT) { 186 | return this.platform.Characteristic.CurrentHeatingCoolingState.HEAT; 187 | } 188 | 189 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.AUTO) { 190 | //TODO: When the structure api shows the current thermostat mode change this to that. 191 | // For now active always means cool. 192 | return this.platform.Characteristic.CurrentHeatingCoolingState.COOL; 193 | } 194 | 195 | return this.platform.Characteristic.CurrentHeatingCoolingState.OFF; 196 | } 197 | 198 | 199 | private getTargetHeatingCoolingStateFromStructureAndRoom(structure: Structure) { 200 | if (!this.room.active) { 201 | return this.platform.Characteristic.TargetHeatingCoolingState.OFF; 202 | } 203 | 204 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.COOL) { 205 | return this.platform.Characteristic.TargetHeatingCoolingState.COOL; 206 | } 207 | 208 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.HEAT) { 209 | return this.platform.Characteristic.TargetHeatingCoolingState.HEAT; 210 | } 211 | 212 | if (structure.structureHeatCoolMode === StructureHeatCoolMode.AUTO) { 213 | return this.platform.Characteristic.TargetHeatingCoolingState.AUTO; 214 | } 215 | 216 | return this.platform.Characteristic.TargetHeatingCoolingState.AUTO; 217 | } 218 | 219 | } 220 | -------------------------------------------------------------------------------- /src/ventPlatformAccessory.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CharacteristicValue, 3 | PlatformAccessory, 4 | Service, 5 | } from 'homebridge'; 6 | import {FlairPlatform} from './platform'; 7 | import {Vent, Client} from 'flair-api-ts'; 8 | import {getRandomIntInclusive} from './utils'; 9 | 10 | export enum VentAccessoryType { 11 | WindowCovering = 'windowCovering', 12 | Fan = 'fan', 13 | AirPurifier = 'airPurifier', 14 | Hidden = 'hidden' 15 | } 16 | 17 | /** 18 | * Platform Accessory 19 | * An instance of this class is created for each accessory your platform registers 20 | * Each accessory may expose multiple services of different service types. 21 | */ 22 | export class FlairVentPlatformAccessory { 23 | private fanService?: Service; 24 | private windowService?: Service; 25 | private airPurifierService?: Service; 26 | private mainService: Service; 27 | 28 | private temperatureService: Service | undefined; 29 | private accessoryInformationService: Service; 30 | 31 | private vent: Vent; 32 | private client: Client; 33 | private accessoryType: VentAccessoryType; 34 | 35 | constructor( 36 | private readonly platform: FlairPlatform, 37 | private readonly accessory: PlatformAccessory, 38 | client: Client, 39 | ) { 40 | this.vent = this.accessory.context.device; 41 | this.client = client; 42 | this.accessoryType = this.platform.config.ventAccessoryType as VentAccessoryType; 43 | if (!this.accessoryType) { 44 | this.accessoryType = VentAccessoryType.WindowCovering; 45 | } 46 | 47 | // set accessory information 48 | this.accessoryInformationService = this.accessory.getService(this.platform.Service.AccessoryInformation)! 49 | .setCharacteristic(this.platform.Characteristic.Manufacturer, 'Flair') 50 | .setCharacteristic(this.platform.Characteristic.Model, 'Vent') 51 | .setCharacteristic(this.platform.Characteristic.SerialNumber, this.vent.id!); 52 | 53 | this.fanService = this.accessory.getService(this.platform.Service.Fanv2); 54 | this.windowService = this.accessory.getService(this.platform.Service.WindowCovering); 55 | this.airPurifierService = this.accessory.getService(this.platform.Service.AirPurifier); 56 | 57 | // We fake a vent as a any type. 58 | switch (this.accessoryType) { 59 | case VentAccessoryType.WindowCovering: 60 | if (this.fanService) { 61 | this.accessory.removeService(this.fanService); 62 | } 63 | 64 | if (this.airPurifierService) { 65 | this.accessory.removeService(this.airPurifierService); 66 | } 67 | 68 | this.windowService = this.windowService 69 | ?? this.accessory.addService(this.platform.Service.WindowCovering); 70 | this.windowService.getCharacteristic(this.platform.Characteristic.TargetPosition).setProps({ 71 | minStep: 50, 72 | }) 73 | .onSet(this.setTargetPosition.bind(this)) 74 | .onGet(this.getTargetPosition.bind(this)); 75 | 76 | this.windowService.getCharacteristic(this.platform.Characteristic.CurrentPosition).setProps({ 77 | minStep: 50, 78 | }); 79 | this.windowService.setCharacteristic(this.platform.Characteristic.TargetPosition, this.vent.percentOpen); 80 | this.windowService.setCharacteristic(this.platform.Characteristic.CurrentPosition, this.vent.percentOpen); 81 | this.windowService.setCharacteristic( 82 | this.platform.Characteristic.PositionState, 83 | this.platform.Characteristic.PositionState.STOPPED, 84 | ); 85 | this.mainService = this.windowService; 86 | break; 87 | case VentAccessoryType.AirPurifier: 88 | if (this.fanService) { 89 | this.accessory.removeService(this.fanService); 90 | } 91 | 92 | if (this.windowService) { 93 | this.accessory.removeService(this.windowService); 94 | } 95 | 96 | this.airPurifierService = this.airPurifierService 97 | ?? this.accessory.addService(this.platform.Service.AirPurifier); 98 | 99 | this.airPurifierService.getCharacteristic(this.platform.Characteristic.RotationSpeed).setProps({ 100 | minStep: 50, 101 | }); 102 | this.airPurifierService.getCharacteristic(this.platform.Characteristic.RotationSpeed) 103 | .onSet(this.setTargetPosition.bind(this)) 104 | .onGet(this.getTargetPosition.bind(this)); 105 | this.mainService = this.airPurifierService; 106 | break; 107 | case VentAccessoryType.Fan: 108 | if (this.airPurifierService) { 109 | this.accessory.removeService(this.airPurifierService); 110 | } 111 | 112 | if (this.windowService) { 113 | this.accessory.removeService(this.windowService); 114 | } 115 | this.fanService = this.fanService 116 | ?? this.accessory.addService(this.platform.Service.Fanv2); 117 | 118 | this.fanService.getCharacteristic(this.platform.Characteristic.RotationSpeed).setProps({ 119 | minStep: 50, 120 | }); 121 | 122 | this.fanService.getCharacteristic(this.platform.Characteristic.RotationSpeed) 123 | .onSet(this.setTargetPosition.bind(this)) 124 | .onGet(this.getTargetPosition.bind(this)); 125 | this.mainService = this.fanService; 126 | break; 127 | default: 128 | throw Error('No Vent Accessory Type Selected.'); 129 | break; 130 | } 131 | 132 | this.mainService.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.name); 133 | this.mainService.setPrimaryService(true); 134 | 135 | //Add our temperature sensor 136 | if (platform.config.hideVentTemperatureSensors) { 137 | const temperatureService = this.accessory.getService(this.platform.Service.TemperatureSensor); 138 | if (temperatureService) { 139 | this.mainService.removeLinkedService(temperatureService); 140 | this.accessory.removeService(temperatureService); 141 | } 142 | } else { 143 | this.temperatureService = this.accessory.getService(this.platform.Service.TemperatureSensor) 144 | ?? this.accessory.addService(this.platform.Service.TemperatureSensor); 145 | this.temperatureService.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.name); 146 | this.temperatureService.setCharacteristic( 147 | this.platform.Characteristic.CurrentTemperature, 148 | this.vent.ductTemperatureC, 149 | ); 150 | this.mainService.addLinkedService(this.temperatureService); 151 | } 152 | 153 | setInterval(async () => { 154 | await this.getNewVentReadings(); 155 | }, (platform.config.pollInterval + getRandomIntInclusive(1, 20)) * 1000); 156 | this.getNewVentReadings(); 157 | } 158 | 159 | /** 160 | // * Handle "SET" requests from HomeKit 161 | // * These are sent when the user changes the state of an accessory, for example, changing the Brightness 162 | // */ 163 | async setTargetPosition(value: CharacteristicValue): Promise { 164 | const vent: Vent = await this.client.setVentPercentOpen(this.vent, value as number); 165 | this.updateVentReadingsFromVent(vent); 166 | this.platform.log.debug('Set Characteristic Percent Open -> ', value); 167 | } 168 | 169 | async getTargetPosition(): Promise { 170 | const vent: Vent = await this.getNewVentReadings(); 171 | return vent.percentOpen; 172 | } 173 | 174 | async getNewVentReadings(): Promise { 175 | try { 176 | const vent = await this.client.getVentReading(this.vent); 177 | this.updateVentReadingsFromVent(vent); 178 | return vent; 179 | } catch (e) { 180 | this.platform.log.debug(e as string); 181 | } 182 | 183 | return this.vent; 184 | } 185 | 186 | updateVentReadingsFromVent(vent: Vent): void { 187 | this.accessory.context.device = vent; 188 | this.vent = vent; 189 | 190 | if (this.temperatureService) { 191 | this.temperatureService.updateCharacteristic( 192 | this.platform.Characteristic.CurrentTemperature, 193 | this.vent.ductTemperatureC, 194 | ); 195 | } 196 | 197 | 198 | // We fake a vent as a window covering. 199 | switch (this.accessoryType) { 200 | case VentAccessoryType.WindowCovering: 201 | this.mainService.updateCharacteristic(this.platform.Characteristic.TargetPosition, this.vent.percentOpen); 202 | this.mainService.updateCharacteristic(this.platform.Characteristic.CurrentPosition, this.vent.percentOpen); 203 | this.mainService.updateCharacteristic( 204 | this.platform.Characteristic.PositionState, 205 | this.platform.Characteristic.PositionState.STOPPED, 206 | ); 207 | break; 208 | case VentAccessoryType.AirPurifier: 209 | this.mainService.updateCharacteristic(this.platform.Characteristic.RotationSpeed, this.vent.percentOpen); 210 | this.mainService.updateCharacteristic(this.platform.Characteristic.Active, this.vent.percentOpen > 0); 211 | this.mainService.updateCharacteristic(this.platform.Characteristic.CurrentAirPurifierState, this.vent.percentOpen > 0); 212 | break; 213 | case VentAccessoryType.Fan: 214 | this.mainService.updateCharacteristic(this.platform.Characteristic.RotationSpeed, this.vent.percentOpen); 215 | this.mainService.updateCharacteristic(this.platform.Characteristic.Active, this.vent.percentOpen > 0); 216 | break; 217 | default: 218 | throw Error('No Vent Accessory Type Selected.'); 219 | break; 220 | } 221 | 222 | 223 | this.accessoryInformationService.updateCharacteristic( 224 | this.platform.Characteristic.FirmwareRevision, 225 | String(this.vent.firmwareVersionS), 226 | ); 227 | 228 | this.platform.log.debug(`Pushed updated state for vent: ${this.vent.name!} to HomeKit`, { 229 | open: this.vent.percentOpen, 230 | pressure: this.vent.ductPressure, 231 | temperature: this.vent.ductTemperatureC, 232 | }); 233 | } 234 | 235 | } 236 | -------------------------------------------------------------------------------- /src/platform.ts: -------------------------------------------------------------------------------- 1 | import { APIEvent } from 'homebridge'; 2 | import type { 3 | API, 4 | DynamicPlatformPlugin, 5 | Logger, 6 | PlatformAccessory, 7 | PlatformConfig, 8 | } from 'homebridge'; 9 | 10 | import { PLATFORM_NAME, PLUGIN_NAME } from './settings'; 11 | import { FlairPuckPlatformAccessory } from './puckPlatformAccessory'; 12 | import {FlairVentPlatformAccessory, VentAccessoryType} from './ventPlatformAccessory'; 13 | import { FlairRoomPlatformAccessory } from './roomPlatformAccessory'; 14 | import { 15 | Puck, 16 | Vent, 17 | Room, 18 | Structure, 19 | StructureHeatCoolMode, 20 | Client, 21 | Model, 22 | } from 'flair-api-ts'; 23 | import { plainToClass } from 'class-transformer'; 24 | import { getRandomIntInclusive } from './utils'; 25 | import {FlairStructurePlatformAccessory} from './structurePlatformAccessory'; 26 | 27 | /** 28 | * HomebridgePlatform 29 | * This class is the main constructor for your plugin, this is where you should 30 | * parse the user config and discover/register accessories with Homebridge. 31 | */ 32 | export class FlairPlatform implements DynamicPlatformPlugin { 33 | public readonly Service = this.api.hap.Service; 34 | public readonly Characteristic = this.api.hap.Characteristic; 35 | 36 | // this is used to track restored cached accessories 37 | public readonly accessories: PlatformAccessory[] = []; 38 | 39 | private client?: Client; 40 | 41 | public structure?: Structure; 42 | 43 | private rooms: [FlairRoomPlatformAccessory?] = []; 44 | 45 | private primaryStructureAccessory?: FlairStructurePlatformAccessory; 46 | 47 | private _hasValidConfig?: boolean; 48 | 49 | private _hasValidCredentials?: boolean; 50 | 51 | constructor( 52 | public readonly log: Logger, 53 | public readonly config: PlatformConfig, 54 | public readonly api: API, 55 | ) { 56 | this.log.debug('Finished initializing platform:', this.config.name); 57 | 58 | if (!this.validConfig()) { 59 | return; 60 | } 61 | 62 | this.client = new Client( 63 | this.config.clientId, 64 | this.config.clientSecret, 65 | this.config.username, 66 | this.config.password, 67 | ); 68 | 69 | // When this event is fired it means Homebridge has restored all cached accessories from disk. 70 | // Dynamic Platform plugins should only register new accessories after this event was fired, 71 | // in order to ensure they weren't added to homebridge already. This event can also be used 72 | // to start discovery of new accessories. 73 | this.api.on(APIEvent.DID_FINISH_LAUNCHING, async () => { 74 | if (!this.validConfig()) { 75 | return; 76 | } 77 | 78 | if (!(await this.checkCredentials())) { 79 | return; 80 | } 81 | 82 | // run the method to discover / register your devices as accessories 83 | await this.discoverDevices(); 84 | 85 | setInterval(async () => { 86 | await this.getNewStructureReadings(); 87 | }, (this.config.pollInterval + getRandomIntInclusive(1, 20)) * 1000); 88 | }); 89 | } 90 | 91 | private validConfig(): boolean { 92 | if (this._hasValidConfig !== undefined) { 93 | return this._hasValidConfig!; 94 | } 95 | 96 | this._hasValidConfig = true; 97 | 98 | if (!this.config.clientId) { 99 | this.log.error('You need to enter a Flair Client Id'); 100 | this._hasValidConfig = false; 101 | } 102 | 103 | if (!this.config.clientSecret) { 104 | this.log.error('You need to enter a Flair Client Id'); 105 | this._hasValidConfig = false; 106 | } 107 | 108 | if (!this.config.username) { 109 | this.log.error('You need to enter your flair username'); 110 | this._hasValidConfig = false; 111 | } 112 | 113 | if (!this.config.password) { 114 | this.log.error('You need to enter your flair password'); 115 | this._hasValidConfig = false; 116 | } 117 | 118 | return this._hasValidConfig!; 119 | } 120 | 121 | private async checkCredentials(): Promise { 122 | if (this._hasValidCredentials !== undefined) { 123 | return this._hasValidCredentials!; 124 | } 125 | 126 | try { 127 | await this.client!.getUsers(); 128 | this._hasValidCredentials = true; 129 | } catch (e) { 130 | this._hasValidCredentials = false; 131 | this.log.error( 132 | 'Error getting structure readings this is usually incorrect credentials, ensure you entered the right credentials.', 133 | ); 134 | } 135 | return this._hasValidCredentials; 136 | } 137 | 138 | private async getNewStructureReadings() { 139 | try { 140 | const structure = await this.client!.getStructure( 141 | await this.getStructure(), 142 | ); 143 | this.updateStructureFromStructureReading(structure); 144 | } catch (e) { 145 | this.log.debug(e as string); 146 | } 147 | } 148 | 149 | private updateStructureFromStructureReading(structure: Structure) { 150 | this.structure = structure; 151 | for (const room of this.rooms) { 152 | if (room) { 153 | room.updateFromStructure(this.structure); 154 | } 155 | } 156 | if (this.primaryStructureAccessory) { 157 | this.primaryStructureAccessory.updateFromStructure(this.structure); 158 | } 159 | return this.structure; 160 | } 161 | 162 | public async setStructureMode( 163 | heatingCoolingMode: StructureHeatCoolMode, 164 | ): Promise { 165 | const structure = await this.client!.setStructureHeatingCoolMode( 166 | await this.getStructure(), 167 | heatingCoolingMode, 168 | ); 169 | 170 | return this.updateStructureFromStructureReading(structure); 171 | } 172 | 173 | private async getStructure(): Promise { 174 | if (this.structure) { 175 | return this.structure!; 176 | } 177 | try { 178 | this.structure = await this.client!.getPrimaryStructure(); 179 | } catch (e) { 180 | throw ( 181 | 'There was an error getting your primary flair home from the api: ' + 182 | (e as Error).message 183 | ); 184 | } 185 | 186 | if (!this.structure) { 187 | throw 'The structure is not available, this should not happen.'; 188 | } 189 | 190 | return this.structure!; 191 | } 192 | 193 | /** 194 | * This function is invoked when homebridge restores cached accessories from disk at startup. 195 | * It should be used to setup event handlers for characteristics and update respective values. 196 | */ 197 | async configureAccessory(accessory: PlatformAccessory): Promise { 198 | if (!this.validConfig()) { 199 | return; 200 | } 201 | 202 | if (!(await this.checkCredentials())) { 203 | return; 204 | } 205 | 206 | if (accessory.context.type === Vent.type && this.config.ventAccessoryType === VentAccessoryType.Hidden) { 207 | this.log.info('Removing vent accessory from cache since vents are now hidden:', accessory.displayName); 208 | await this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [ 209 | accessory, 210 | ]); 211 | return; 212 | } 213 | 214 | // add the restored accessory to the accessories cache so we can track if it has already been registered 215 | this.accessories.push(accessory); 216 | this.log.info('Restoring accessory from cache:', accessory.displayName); 217 | 218 | if (accessory.context.type === Puck.type) { 219 | this.log.info('Restoring puck from cache:', accessory.displayName); 220 | accessory.context.device = plainToClass(Puck, accessory.context.device); 221 | new FlairPuckPlatformAccessory(this, accessory, this.client!); 222 | } else if (accessory.context.type === Vent.type) { 223 | this.log.info('Restoring vent from cache:', accessory.displayName); 224 | accessory.context.device = plainToClass(Vent, accessory.context.device); 225 | new FlairVentPlatformAccessory(this, accessory, this.client!); 226 | } else if (accessory.context.type === Room.type) { 227 | this.log.info('Restoring room from cache:', accessory.displayName); 228 | accessory.context.device = plainToClass(Room, accessory.context.device); 229 | const structure = await this.getStructure(); 230 | this.rooms.push( 231 | new FlairRoomPlatformAccessory( 232 | this, 233 | accessory, 234 | this.client!, 235 | structure, 236 | ), 237 | ); 238 | } else if (accessory.context.type === Structure.type) { 239 | this.log.info('Restoring structure from cache:', accessory.displayName); 240 | accessory.context.device = plainToClass(Structure, accessory.context.device); 241 | this.primaryStructureAccessory = new FlairStructurePlatformAccessory(this, accessory, this.client!); 242 | } 243 | } 244 | 245 | /** 246 | * This is an example method showing how to register discovered accessories. 247 | * Accessories must only be registered once, previously created accessories 248 | * must not be registered again to prevent "duplicate UUID" errors. 249 | */ 250 | async discoverDevices(): Promise { 251 | let currentUUIDs: string[] = []; 252 | 253 | const promisesToResolve: [Promise?] = []; 254 | 255 | if (this.config.ventAccessoryType !== VentAccessoryType.Hidden) { 256 | promisesToResolve.push(this.addDevices(await this.client!.getVents())); 257 | } 258 | 259 | if (!this.config.hidePrimaryStructure) { 260 | promisesToResolve.push(this.addDevices([await this.client!.getPrimaryStructure()])); 261 | } 262 | 263 | if (!this.config.hidePuckRooms) { 264 | promisesToResolve.push( 265 | this.addDevices( 266 | (await this.client!.getRooms()).filter((value: Room) => { 267 | return value.pucksInactive === 'Active'; 268 | }) as [Room], 269 | ), 270 | ); 271 | } 272 | 273 | if (!this.config.hidePuckSensors) { 274 | promisesToResolve.push(this.addDevices(await this.client!.getPucks())); 275 | } 276 | 277 | const uuids : (string[] | undefined)[] = await Promise.all(promisesToResolve); 278 | 279 | currentUUIDs = currentUUIDs.concat(...uuids as string[][]); 280 | 281 | //Loop over the current uuid's and if they don't exist then remove them. 282 | for (const accessory of this.accessories) { 283 | if (currentUUIDs.length === 0 || !currentUUIDs.find((uuid) => uuid === accessory.UUID)) { 284 | await this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [ 285 | accessory, 286 | ]); 287 | delete this.accessories[this.accessories.indexOf(accessory, 0)]; 288 | this.log.debug('Removing not found device:', accessory.displayName); 289 | } 290 | } 291 | } 292 | 293 | async addDevices(devices: [Model]): Promise { 294 | const currentUUIDs: string[] = []; 295 | 296 | // loop over the discovered devices and register each one if it has not already been registered 297 | for (const device of devices) { 298 | // generate a unique id for the accessory this should be generated from 299 | // something globally unique, but constant, for example, the device serial 300 | // number or MAC address 301 | const uuid = this.api.hap.uuid.generate(device.id!); 302 | currentUUIDs.push(uuid); 303 | 304 | // check that the device has not already been registered by checking the 305 | // cached devices we stored in the `configureAccessory` method above 306 | if (!this.accessories.find((accessory) => accessory.UUID === uuid)) { 307 | // create a new accessory 308 | const accessory = new this.api.platformAccessory(device.name!, uuid); 309 | 310 | // store a copy of the device object in the `accessory.context` 311 | // the `context` property can be used to store any data about the accessory you may need 312 | accessory.context.device = device; 313 | 314 | // create the accessory handler 315 | // this is imported from `puckPlatformAccessory.ts` 316 | if (device instanceof Puck) { 317 | accessory.context.type = Puck.type; 318 | new FlairPuckPlatformAccessory(this, accessory, this.client!); 319 | } else if (device instanceof Vent) { 320 | accessory.context.type = Vent.type; 321 | new FlairVentPlatformAccessory(this, accessory, this.client!); 322 | } else if (device instanceof Room) { 323 | accessory.context.type = Room.type; 324 | const structure = await this.getStructure(); 325 | this.rooms.push( 326 | new FlairRoomPlatformAccessory( 327 | this, 328 | accessory, 329 | this.client!, 330 | structure, 331 | ), 332 | ); 333 | } else if (device instanceof Structure) { 334 | accessory.context.type = Structure.type; 335 | this.primaryStructureAccessory = new FlairStructurePlatformAccessory( 336 | this, 337 | accessory, 338 | this.client!, 339 | ); 340 | } else { 341 | continue; 342 | } 343 | this.log.info( 344 | `Registering new ${accessory.context.type}`, 345 | device.name!, 346 | ); 347 | 348 | // link the accessory to your platform 349 | this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [ 350 | accessory, 351 | ]); 352 | 353 | // push into accessory cache 354 | this.accessories.push(accessory); 355 | 356 | // it is possible to remove platform accessories at any time using `api.unregisterPlatformAccessories`, eg.: 357 | // this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); 358 | } else { 359 | this.log.debug('Discovered accessory already exists:', device.name!); 360 | } 361 | } 362 | 363 | return currentUUIDs; 364 | } 365 | } 366 | --------------------------------------------------------------------------------