├── 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 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/jsLinters/eslint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
4 |
5 |
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 |
5 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 | [](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 |
--------------------------------------------------------------------------------