├── .eslintrc
├── .github
├── ISSUE_TEMPLATE
│ ├── bug-report.md
│ ├── config.yml
│ ├── feature-request.md
│ └── support-request.md
├── dependabot.yml
└── workflows
│ └── build.yml
├── .gitignore
├── .npmignore
├── .vscode
├── extensions.json
└── settings.json
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── config-sample.json
├── config.schema.json
├── eslint.config.mjs
├── nodemon.json
├── package-lock.json
├── package.json
├── screenshots
├── Awair_Characteristics_v2.png
├── IMG_1929.jpeg
├── IMG_1930.jpeg
├── IMG_1931.jpeg
├── IMG_1932.jpeg
├── Image5.png
├── balenaEtcher.png
├── canakit_contents.jpeg
├── canakit_youtube.png
├── homebridge1.png
├── homebridge2.png
├── homebridge3.png
├── homebridge4.png
├── homebridge5.png
├── homebridge6.png
├── homebridge7.png
├── homebridge8.png
├── ios16_automation.gif
├── ios16_awair_mode_names.gif
├── ios16_awair_modes.gif
├── ios16_carbon_dioxide.gif
├── ios16_climate.gif
├── ios16_climate_battery.gif
├── ios16_developer_token.gif
├── ios16_local_api.gif
├── ios16_modes.gif
└── restart.png
├── src
├── configTypes.ts
├── global.d.ts
└── index.ts
└── tsconfig.json
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
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": "off",
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 | "no-non-null-assertion": ["off"],
29 | "comma-spacing": ["error"],
30 | "no-multi-spaces": ["warn", { "ignoreEOLComments": true }],
31 | "lines-between-class-members": ["warn", "always", {"exceptAfterSingleLine": true}],
32 | "@typescript-eslint/explicit-function-return-type": "off",
33 | "@typescript-eslint/no-non-null-assertion": "off",
34 | "@typescript-eslint/explicit-module-boundary-types": "off",
35 | "@typescript-eslint/no-explicit-any": "off",
36 | "@typescript-eslint/no-unused-expressions": "off",
37 | "no-case-declarations": "off",
38 | "no-mixed-spaces-and-tabs": "off"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug Report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 |
12 | **Describe The Bug:**
13 |
14 |
15 | **To Reproduce:**
16 |
17 |
18 | **Expected behavior:**
19 |
20 |
21 | **Logs:**
22 |
23 | ```
24 | Show the Homebridge logs here, remove any sensitive information.
25 | ```
26 |
27 | **Plugin Config:**
28 |
29 | ```json
30 | Show your Homebridge config.json here, remove any sensitive information.
31 | ```
32 |
33 | **Screenshots:**
34 |
35 |
36 | **Environment:**
37 |
38 | * **Plugin Version**:
39 | * **Homebridge Version**:
40 | * **Node.js Version**:
41 | * **NPM Version**:
42 | * **Operating System**:
43 |
44 |
45 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | # blank_issues_enabled: false
2 | # contact_links:
3 | # - name: Homebridge Discord Community
4 | # url: https://discord.gg/kqNCe2D
5 | # about: Ask your questions in the #YOUR_CHANNEL_HERE channel
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature Request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe:**
11 |
12 |
13 | **Describe the solution you'd like:**
14 |
15 |
16 | **Describe alternatives you've considered:**
17 |
18 |
19 | **Additional context:**
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/support-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Support Request
3 | about: Need help?
4 | title: ''
5 | labels: question
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 |
12 | **Describe Your Problem:**
13 |
14 |
15 | **Logs:**
16 |
17 | ```
18 | Show the Homebridge logs here, remove any sensitive information.
19 | ```
20 |
21 | **Plugin Config:**
22 |
23 | ```json
24 | Show your Homebridge config.json here, remove any sensitive information.
25 | ```
26 |
27 | **Screenshots:**
28 |
29 |
30 | **Environment:**
31 |
32 | * **Plugin Version**:
33 | * **Homebridge Version**:
34 | * **Node.js Version**:
35 | * **NPM Version**:
36 | * **Operating System**:
37 |
38 |
39 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # Query weekly for npm dependency updates.
2 | #
3 | version: 2
4 |
5 | updates:
6 |
7 | # Enable version updates for github-actions.
8 | - package-ecosystem: "github-actions"
9 |
10 | # Look for ".github/workflows" in the "root" directory.
11 | directory: "/"
12 |
13 | # Check for updated GitHub Actions every weekday.
14 | schedule:
15 | interval: "weekly"
16 |
17 | # Allow up to ten pull requests to be generated at any one time.
18 | open-pull-requests-limit: 5
19 |
20 | # Enable version updates for npm.
21 | - package-ecosystem: "npm"
22 |
23 | # Look for "package.json" and "package-lock.json" files in the "root" directory.
24 | directory: "/"
25 |
26 | # Check the npm registry for updates every weekday.
27 | schedule:
28 | interval: "weekly"
29 |
30 | # Allow up to ten pull requests to be generated at any one time.
31 | open-pull-requests-limit: 5
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build and Lint
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | strategy:
10 | matrix:
11 | # the Node.js versions to build on (homebridge only supports even-numbered releases)
12 | node-version: [18.x, 20.x, 22.x]
13 |
14 | steps:
15 | - uses: actions/checkout@v4
16 |
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v4
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 |
22 | - name: Install dependencies
23 | run: npm install
24 |
25 | - name: Lint the project
26 | run: npm run lint
27 |
28 | - name: Build the project
29 | run: npm run build
30 | env:
31 | CI: true
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore compiled code
2 | dist
3 |
4 | # ------------- Defaults ------------- #
5 |
6 | # Apple macOS operating system files
7 | .DS_Store
8 |
9 | # Logs
10 | logs
11 | *.log
12 | npm-debug.log*
13 | yarn-debug.log*
14 | yarn-error.log*
15 | lerna-debug.log*
16 |
17 | # Diagnostic reports (https://nodejs.org/api/report.html)
18 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
19 |
20 | # Runtime data
21 | pids
22 | *.pid
23 | *.seed
24 | *.pid.lock
25 |
26 | # Directory for instrumented libs generated by jscoverage/JSCover
27 | lib-cov
28 |
29 | # Coverage directory used by tools like istanbul
30 | coverage
31 | *.lcov
32 |
33 | # nyc test coverage
34 | .nyc_output
35 |
36 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
37 | .grunt
38 |
39 | # Bower dependency directory (https://bower.io/)
40 | bower_components
41 |
42 | # node-waf configuration
43 | .lock-wscript
44 |
45 | # Compiled binary addons (https://nodejs.org/api/addons.html)
46 | build/Release
47 |
48 | # Dependency directories
49 | node_modules/
50 | jspm_packages/
51 |
52 | # Snowpack dependency directory (https://snowpack.dev/)
53 | web_modules/
54 |
55 | # TypeScript cache
56 | *.tsbuildinfo
57 |
58 | # Optional npm cache directory
59 | .npm
60 |
61 | # Optional eslint cache
62 | .eslintcache
63 |
64 | # Microbundle cache
65 | .rpt2_cache/
66 | .rts2_cache_cjs/
67 | .rts2_cache_es/
68 | .rts2_cache_umd/
69 |
70 | # Optional REPL history
71 | .node_repl_history
72 |
73 | # Output of 'npm pack'
74 | *.tgz
75 |
76 | # Yarn Integrity file
77 | .yarn-integrity
78 |
79 | # dotenv environment variables file
80 | .env
81 | .env.test
82 |
83 | # parcel-bundler cache (https://parceljs.org/)
84 | .cache
85 | .parcel-cache
86 |
87 | # Next.js build output
88 | .next
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # Serverless directories
104 | .serverless/
105 |
106 | # FuseBox cache
107 | .fusebox/
108 |
109 | # DynamoDB Local files
110 | .dynamodb/
111 |
112 | # TernJS port file
113 | .tern-port
114 |
115 | # Stores VSCode versions used for testing VSCode extensions
116 | .vscode-test
117 |
118 | # yarn v2
119 | .yarn/cache
120 | .yarn/unplugged
121 | .yarn/build-state.yml
122 | .pnp.*
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # Ignore source code
2 | src
3 |
4 | # ------------- Defaults ------------- #
5 |
6 | # gitHub actions
7 | .github
8 |
9 | # eslint
10 | .eslintrc
11 |
12 | # typescript
13 | tsconfig.json
14 |
15 | # vscode
16 | .vscode
17 |
18 | # nodemon
19 | nodemon.json
20 |
21 | # Logs
22 | logs
23 | *.log
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 | lerna-debug.log*
28 |
29 | # Diagnostic reports (https://nodejs.org/api/report.html)
30 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
31 |
32 | # Runtime data
33 | pids
34 | *.pid
35 | *.seed
36 | *.pid.lock
37 |
38 | # Directory for instrumented libs generated by jscoverage/JSCover
39 | lib-cov
40 |
41 | # Coverage directory used by tools like istanbul
42 | coverage
43 | *.lcov
44 |
45 | # nyc test coverage
46 | .nyc_output
47 |
48 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
49 | .grunt
50 |
51 | # Bower dependency directory (https://bower.io/)
52 | bower_components
53 |
54 | # node-waf configuration
55 | .lock-wscript
56 |
57 | # Compiled binary addons (https://nodejs.org/api/addons.html)
58 | build/Release
59 |
60 | # Dependency directories
61 | node_modules/
62 | jspm_packages/
63 |
64 | # Snowpack dependency directory (https://snowpack.dev/)
65 | web_modules/
66 |
67 | # TypeScript cache
68 | *.tsbuildinfo
69 |
70 | # Optional npm cache directory
71 | .npm
72 |
73 | # Optional eslint cache
74 | .eslintcache
75 |
76 | # Microbundle cache
77 | .rpt2_cache/
78 | .rts2_cache_cjs/
79 | .rts2_cache_es/
80 | .rts2_cache_umd/
81 |
82 | # Optional REPL history
83 | .node_repl_history
84 |
85 | # Output of 'npm pack'
86 | *.tgz
87 |
88 | # Yarn Integrity file
89 | .yarn-integrity
90 |
91 | # dotenv environment variables file
92 | .env
93 | .env.test
94 |
95 | # parcel-bundler cache (https://parceljs.org/)
96 | .cache
97 | .parcel-cache
98 |
99 | # Next.js build output
100 | .next
101 |
102 | # Nuxt.js build / generate output
103 | .nuxt
104 | dist
105 |
106 | # Gatsby files
107 | .cache/
108 | # Comment in the public line in if your project uses Gatsby and not Next.js
109 | # https://nextjs.org/blog/next-9-1#public-directory-support
110 | # public
111 |
112 | # vuepress build output
113 | .vuepress/dist
114 |
115 | # Serverless directories
116 | .serverless/
117 |
118 | # FuseBox cache
119 | .fusebox/
120 |
121 | # DynamoDB Local files
122 | .dynamodb/
123 |
124 | # TernJS port file
125 | .tern-port
126 |
127 | # Stores VSCode versions used for testing VSCode extensions
128 | .vscode-test
129 |
130 | # yarn v2
131 |
132 | .yarn/cache
133 | .yarn/unplugged
134 | .yarn/build-state.yml
135 | .pnp.*
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint"
4 | ]
5 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.eol": "\n",
3 | "editor.codeActionsOnSave": {
4 | "source.fixAll.eslint": "explicit"
5 | },
6 | "editor.rulers": [ 140 ],
7 | "eslint.enable": true
8 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file. This project uses [Semantic Versioning](https://semver.org/).
4 |
5 | ## v5.13.1
6 | * Append .local domain suffix to localAPI calls (credit to Brent Comnes for submission).
7 |
8 | ## v5.13.0
9 | * Add `localAPI` data collection option for Awair R2, Awair Element and Awair Omni.
10 | * Update `README.md` with additional information on use of `localAPI`.
11 | * Update `config.schema.json` to provide configuration for use of `localAPI`.
12 |
13 | ## v5.12.7
14 | * Correct operation with node v20.x
15 |
16 | ## v5.12.6
17 | * Update node revisions to: [18.x, 20.x, 22.x]
18 | * Housekeeping
19 |
20 | ## v5.12.5
21 | * Update eslint rules to include `'@typescript-eslint/no-unused-expressions': 'off'` for compatibility with eslint v9.15.0.
22 |
23 | ## v5.12.4
24 | * Updates for compatibility with Homebridge v1.8.5.
25 |
26 | ## v5.12.3
27 | * Add support for updated Developer Token preamble text.
28 |
29 | ## v5.12.2
30 | * Update @typescript-eslint/eslint-plugin to "^8.0.0", and @typescript-eslint/parser: "^8.0.1".
31 | * Update eslint to "^9.0.0".
32 |
33 | ## v5.12.1
34 | * Housekeeping of error warning levels for consistency.
35 | * Address Server-Side Request Forgery in axios by making minimum axios revision level 1.7.3 - https://github.com/advisories/GHSA-8hc4-vh64-cxmj
36 |
37 | ## v5.12.0
38 | * Confirm plug-in operation with Homebridge 2.0.0. Updated package.json per homebridge instructions.
39 |
40 | ## v5.11.0
41 | * Incorporates updated Awair Score methodology for Awair Element (Firmware v1.4.0) and Awair Omni (Firmware v1.8.0) introduced by Awair in Dec 2023. NOTE that updated methology does not apply to Awair R2. See Awair [Reference](https://support.getawair.com/hc/en-us/articles/19504367520023#h_01HE1QVJ85K55N7QS8NAVBXWJM) and plugin README for additional details.
42 | * Change default IAQ method to `awair-score`.
43 |
44 | ## v5.10.9
45 | * Add plug-in Setting option to select temperature units (C or F) and time format (12hr or 24hr) when Display Modes are enabled.
46 |
47 | ## v5.10.8
48 | * Update node-version: [18.x, 20.x], remove 16.x which is no longer supported by homebridge.
49 |
50 | ## v5.10.7
51 | * [Housekeeping] Update devDependencies for "@typescript-eslint/eslint-plugin": "^6.1.0", and "@typescript-eslint/parser": "^6.1.0".
52 |
53 | ## v5.10.6
54 | * [Housekeeping] Update supported node-versions to [16.x, 18.x, 20.x] dropping 14.x which is end-of-life.
55 |
56 | ## v5.10.5
57 | * [Housekeeping] Additional logging improvements.
58 | * [Housekeeping] Update README.md to include npm version and number of npm downloads.
59 |
60 | ## v5.10.4
61 | * [Enhancement] Check if tvoc > 100,000 and if so set to 100,000.
62 | * [Housekeeping] Update devDependencies to latest versions.
63 | * [Housekeeping] Improved logging.
64 |
65 | ## v5.10.3
66 | * [Housekeeping] Update `node.js` compatible build versions. Add `18.x`, remove `12.x`, as Homebridge supports versions `14.x`, `16.x` and `18.x`.
67 | * [Housekeeping] Update `package.json` `engines` and `dependencies` to current supported versions.
68 |
69 | ## v5.10.2
70 | * [Housekeeping] Add 'ambient.d.ts' src file as workaround when updating to Homebridge 1.6.0 for "node_modules/hap-nodejs/dist/lib/Advertiser.d.ts:5:29 - error TS7016: Could not find a declaration file for module '@homebridge/dbus-native'. '…/node_modules/@homebridge/dbus-native/index.js' implicitly has an 'any' type."
71 |
72 | ## v5.10.1
73 | * [Housekeeping] Roll back axios from 1.1.3 to 0.27.2 to address plug-in startup errors.
74 |
75 | ## v5.10.0
76 | * [Functionality] Remove support for Awair v1, Glow and Glow-C which are 'sunsetted' by Awair as of 30 Nov 2022. With this change, Awair removed iOS app and Awair Cloud support for these devices which is required by the plug-in.
77 | * [Housekeeping] Update README.md and Wiki for iOS 16 and removal of 'sunsetted' devices.
78 | * [Housekeeping] Add check to confirm that Developer Token is valid JSON Web Token (JWT) as condition to starting plugin.
79 | * [Housekeeping] Bump axios from 0.27.2 to 1.1.3.
80 |
81 | ## v5.9.10
82 | * [Security] Address potential vunerabilites by updating to `minimist ^1.2.7` and `optimist ^0.5.2`.
83 | * [Housekeeping] Update dependent node modules to latest versions.
84 |
85 | ## v5.9.9
86 | * [Enhancement] Verify Awair server status prior to axios.get call to Awair servers (axios 'validateStatus' option). Add additional error logging.
87 | * [Housekeeping] Update dependent node modules to latest versions.
88 |
89 | ## v5.9.8
90 | * [Housekeeping] Check if device exists based on `deviceUUID` rather than `serial` for consistency with cache restore checks. `deviceUUID` used as basis for Homebridge `UUID`.
91 | * [Logging] Add accType to logging messages added in v5.9.7 so that UUIDs can be more easily tracked.
92 |
93 | ## v5.9.7
94 | * [Logging] Add additional logging for homebridge accessory UUID during addition of new Awair device and recovery from cache for existing Awair devices.
95 |
96 | ## v5.9.6
97 | * [Housekeeping] Plug-in initialization code and logging improvements.
98 | * [Housekeeping] Update dependent node modules to latest versions.
99 |
100 | ## v5.9.5
101 | * [Enhancement] Add option to enable/disable VOC and PM2.5 binary limit switches.
102 | * [Housekeeping] Update dependent node modules to latest versions.
103 |
104 | ## v5.9.4
105 | * [Security] Update `minimist` dependecy to version `>=0.2.1` to address [CVE-2020-7598](https://github.com/advisories/GHSA-vh95-rmgr-6w4m) security advisory.
106 | * [Housekeeping] Improve error logging for `updateAirQualityData`, `getBatteryStatus` and `getOccupancyStatus` to include `accessory.context.serial` in logging output.
107 |
108 | ## v5.9.3
109 | * [Bug] Correctly report humidity. Was returning `0%` for all Awair devices.
110 |
111 | ## v5.9.2
112 | * [Housekeeping] Added explicit return types for all functions. Added explicit `return` to close all functions as appropriate.
113 | * [Improvement] Refactored `updateAirQualityData` function for cleaner operation. Updated `axios.get` syntax.
114 | * [Bug] Corrected syntax of `voc` and `pm25` cases in `updateAirQualityData` function to correctly use `getCharacteristic` for current `value`.
115 |
116 | ## v5.9.1
117 | * [Housekeeping] Update node_module dependencies to latest versions.
118 | * [Security] Update `follow-redirects` to version 1.14.7 to address [CVE-2022-0155](https://github.com/advisories/GHSA-74fj-2j2h-c42q) security advisory.
119 |
120 | ## v5.9.0
121 | * [Enhancement] Add binary limit switches for VOC and PM2.5. The switches are implemented as dummy `occupancy sensors` and can be used to trigger HomeKit automations.
122 | * NOTE: Awair device(s) need to be deleted from Homebridge cache followed by Homebridge restart in order to add VOC and PM2.5 limit switch capability. This also will require that the Awair device(s) be reconfigured in HomeKit including room location and automations.
123 | * [Housekeeping] Typescript syntax and readability improvements.
124 |
125 | ## v5.8.14
126 | * Updates to `index.ts`, `package.json`, and `package-lock.json` for compatibility with `eslint v8.50` and `@typescript-eslint v5.7.0`.
127 |
128 | ## v5.8.13
129 | * Update to `lockfileversion 2`. The lockfile version used by npm v7, which is backwards compatible to v1 lockfiles.
130 |
131 | ## v5.8.12
132 | * Updated index.ts for compatibility with axios v0.24.0 which changed `never` type to `unknown`. Added specification that response data should be `any`.
133 |
134 | ## v5.8.11
135 | * Update index.ts code comments to support future updates. No functional changes to code.
136 |
137 | ## v5.8.10
138 | * Address dns-packet security vulnerability. Reference [CVE-2021-23386](https://github.com/advisories/GHSA-3wcq-x3mq-6r9p).
139 |
140 | ## v5.8.9
141 | * Correct `switch` statement for fall through condition in `getUserInfo()` function.
142 | * Confirm latest @dependabot updates.
143 | * Removed node v10.x from support versions.
144 |
145 | ## v5.8.8
146 | * Added node v16.x to supported versions.
147 | * Housekeeping. No functional changes.
148 |
149 | ## v5.8.7
150 | * [Bug] Correct Display and LED Mode initialization of compatible devices to ensure that 'Score' and 'Auto' are selected as defaults.
151 | * [Bug] Initialize IAQ characteristics with numberic values to address Homebride v1.3.x warning.
152 | * [Housekeeping] Remove duplicate entries from package-lock.json.
153 | * [Housekeeping] In config.schema.json change 'default' to 'placeholder' for 'carbonDioxideThreshold' and 'carbonDioxideThresholdOff' entries.
154 |
155 | ## v5.8.6
156 | * [Enhancement] Awair Device and LED modes cache values are used if available - applies to Omni, R2 and Element. For new device, Device mode is set to 'Score' and LED mode is set to 'Auto'.
157 | * [Housekeeping] Update of function names for clarity.
158 |
159 | ## v5.8.5
160 | * [Enhancement] Update 'changeLEDMode' Manual mode and brightness behavior.
161 |
162 | ## v5.8.4
163 | * [Housekeeping] aligning with @dependabot merges.
164 | * [Bug] Correct error in 'changeLEDMode' function.
165 |
166 | ## v5.8.3
167 | * Updates for Homebridge 1.3.x compatibility. Set minimum level for Omni lux to 0.0001.
168 |
169 | ## v5.8.2
170 | * [Bug] Corrected issue - `Multiple air data API calls during a single polling interval #66`. Determined that accessories were being duplicated resulting in additional API calls. For the Hobbyist `Request failed with status code 429`, was returned on API calls as API call limits were exceeded.
171 | * Update if statement logic with `()` to ensure consistency and readability.
172 | * Update logging for consistency.
173 |
174 | ## v5.8.1
175 | * [Bug] Fix correctly checking whether config entries exist [PR #66](https://github.com/DMBlakeley/homebridge-awair2/pull/65).
176 |
177 | ## v5.8.0
178 | * [Enhancement] If `awair-pm` selected, Glow and Glow-C will use `awair-aqi` method with configured `endpoint` and `limit`.
179 |
180 | ## v5.7.3
181 | * [New] Add `awair-pm` 'air quality method'. When 'awair-pm' selected, the HomeKit Air Quality tile only reflects the particulates value, which is useful for automating air purifiers.
182 |
183 | ## v5.7.2
184 | * [Enhancement] Update config schema titles to provide better description and consistency across titles.
185 | * [Enhancement] Update README.md to be consistent with config schema changes. Clarify that when upgrading from 5.6.4 to 5.7.x that you should first uninstall plug-in, reboot, reinstall, configure and reboot. This is due to change in device accessory cache format.
186 |
187 | ## v5.7.0 & v5.7.1
188 | * [New] Added functionality to control Awair display and brightness. Only applies to Omni, Awair-R2 and Element.
NOTE:
When migrating from `v5.6.3` to `v5.7.0` please first uninstall `homebridge-awair2` (copy your Developer Token first), restart Homebridge to clear 'homebridge-awair' cached accessories, install `homebridge-awair2`, add your Developer Token, and finally restart Homebridge one more time.
189 |
190 | ## v5.6.4
191 | * Change `carbonDioxideThreshold` default from 0 to 1000 and `carbonDioxideThresholdOff` default from 0 to 800.
192 |
193 | ## v5.6.3
194 | * Updates for setting up Raspberry Pi for Homebridge and awair2.
195 | * config.schema.json - changed `placeholder` to `default` on `userType`, `airQualityMethod`, `carbonDioxideThreshold` and `carbonDioxideThresholdOff`.
196 | * Add check that `carbonDioxideThresholdOff` is less than `carbonDioxideThreshold`. If not, set to `default` values.
197 |
198 | ## v5.6.2
199 | * Housekeeping - remove unused functions (getLocalData, getLocalConfig, getApiUsage).
200 |
201 | ## v5.6.1
202 | * Correctly define Awair devices as 'air quality monitor', not 'air purifier'.
203 |
204 | ## v5.6.0
205 | * Add NowCast-AQI `airQualityMethod` for Omni, Mint, Awair, Awair-R2 and Element. NowCast-AQI fixes `endpoint` to `15-min-avg` and `data points returned` to `48` (12 hours) for these devices. For Awair Glow and Awair Glow C, `airQualityMethod` is set to `awair-aqi` with same fixed `endpoint` and `data points returned`.
206 | * Correct Awair, Glow and Glow-C reporting of PM25 which is not available on these devices. NOTE: Installed devices need to be removed from Homebridge followed by reboot to correct. Individual devices can be removed through Homebridge UI settings.
207 | * Update config.schema.json to conditionally show options based on prior entries.
208 | * Logging clean-up.
209 |
210 | ## v5.5.10
211 | * Add instructions to README.md for migrating from `homebridge-awair` to `homebridge-awair2`.
212 | * Address "StatusCodeError: 400" and "404" due to errors in handling of config.json entries.
213 | * Check that data sampling `limit` does not exceed maximum allowed per `endpoint`.
214 | * Set `raw` data sampling for `non-Hobbyist` tier to have minimum `polling_interval` of 60 seconds.
215 | * Improve error handling for `axios` HTTP calls.
216 |
217 | ## v5.5.9
218 | * Replace request-promise with axios as request-promise has been depricated.
219 | * Change @dependabot scanning from 'daily' to 'weekly'.
220 | * Removed extraneous packages from package-lock.json.
221 |
222 | ## v5.5.8
223 | * Added dependabot.yml file.
224 | * Approved and merged @dependabot pull requests.
225 | * Confirmed plugin operation after merges.
226 |
227 | ## v5.5.7
228 | * Add the `Verified by Homebridge` badge and update README.md header.
229 |
230 | ## v5.5.6
231 | * Minimum Omni occupancy level changed to 48.0dBA per Omni specifications.
232 | * Addition of Wiki screenshots.
233 |
234 | ## v5.5.5
235 | * Omni occupancy detection improvments. Minimum sound level set to 47dBA based on dust sensor fan noise as guard for spurious low reading. Provide option to restart detection algorithm on Homebridge restart.
236 |
237 | ## v5.5.4
238 | * Omni occupancy detection improvments. Now based on minimum detected sound level + user defined offset.
239 | * Correct existing device recovery from cache on Homebridge restart.
240 |
241 | ## v5.5.3
242 | * Update build.yml to only include even numbered releases of node.js [10.x, 12.x, 14.x]
243 | * Update README.MD to provide additional details on adding 'test' devices to 'exclude' list.
244 | * Add experimental support for Omni auto occupancy detection base on minimum sound level detected.
245 |
246 | ## v5.5.1 & v5.5.2
247 | * Update to address build error TS6059: File 'package.json' is not under 'rootDir' '/homebridge-awair2/src'. 'rootDir' is expected to contain all source files.
248 |
249 | ## v5.5.0
250 | * Add Omni occupancy detection based on sound pressure level.
251 | * Check that Awair MAC contains Awair OUI "70886B" for 'end user' devices.
252 | * Add "Development" mode to allow use of 'test' devices.
253 | * Define MAC addresses for unregistered 'test' devices based on deviceId. Will begin with '000000'.
254 | * Rename "updateStatus" to "updateAirData" to better reflect intent of function.
255 | * Update comments to provide additional information.
256 | * Update README.md, config-sample.json, config-schema.json for these changes.
257 |
258 | ## v5.4.3
259 | * General code review for consistency.
260 | * Removed Mint battery check as this function only applies to Omni.
261 |
262 | ## v5.4.2
263 | * Revise Omni battery check to include Mint. Applies to v1.3.0 firmware and below.
264 | * Re-implemented 'vocMw' as an optional configuration in the settings
265 |
266 | ## v5.4.1
267 | * Fixed minor typos
268 | * Fixed typo on Air Quality conversion for method `aqi` to `awair-aqi`
269 | * Corrected Awair 1st Edition `dust` convertAwairAqi thresholds
270 | * Reverted `limit` behavior for `5-min-avg` and `15-min-avg` endpoints, but not `latest`
271 | * Changed default `limit` to `1` and default `endpoint` to `15-min-avg`
272 | * Added more thorough description of `limit` behavior to README.md
273 |
274 | ## v5.4.0
275 | * Add support for Omni to use LocalAPI capability for battery-charge, battery-plugged, lux and spl_a (spl_a not currently supported in HomeKit).
276 |
277 | ## v5.3.0
278 | * Add option to define Awair account devices to be ignored and not be published in HomeKit.
279 | * Updates to index.ts, configTypes.ts, config.schema.json, config-sample.json, package.json, and package-lock.json to support ignoredDevices funcionality.
280 |
281 | ## v5.2.7
282 | * Republish of v5.2.5 due to v5.2.6 error.
283 |
284 | ## v5.2.6
285 | * Version published in error and removed from npm.
286 |
287 | ## v5.2.5
288 | * Added `getUserInfo` and `getApiUsage` functions.
289 | * `polling_interval` now based on `userType` and `endpoint`.
290 | * Added `UserInfoConfig` to `configTypes.ts`.
291 | * Corrected logic in `setInterval` to fetch Omni battery status on 4th `updateStatus` check.
292 | * Updated use of `limit` in config.json to only apply to `raw` endpoint. Defaults to 1 for other endpoints.
293 | * Added `verbose` logging flag which will log results from API data calls.
294 | * Updates to config.schema.json for `limit` description and removal of `polling_interval`.
295 | * Update to `config-sample.json` to remove `polling_interval`.
296 | * Updates to README.md.
297 |
298 | ## v5.2.4
299 | * Updates from testing multiple Awair units. Base functionality confirmed for Awair, Awair-r2, Awair Element, Awair Glow C and Awair Omni.
300 | * Corrected data sampling when multiple units are configured.
301 | * Updated Awair Omni battery sampling.
302 | * Reverse order of CHANGELOG.md entries with most recent at top.
303 | * Updates to README.md.
304 |
305 | ## v5.2.3
306 | * Added low battery alert (<30%) for Omni. Battery status shows up on all 4 sensors (Air Quality, CO2, Humidity & Temperature).
307 | * Updated README.md with battery status details and added screenshot example for iOS14.
308 |
309 | ## v5.2.1
310 | * Awair Onmi has battery, not Awair Mint. Updates to README.md and indes.ts files.
311 |
312 | ## v5.1.2
313 | * Cleanup of comments in index.js code (no functional changes). Files updated: README.md, src/index.ts, package.json, package-lock.json, CHANGLOG.md.
314 |
315 | ## v5.1.1
316 | * Update of Class declarations to remove Readonly for changeable variables and provide default values. Added check for presence of optional parameteres in config.json to override defaults.
317 |
318 | ## v5.1.0
319 | * Update to correctly handle default configuration values plus general cleanup of code.
320 | * Files updated: README.md, src/index.ts, src/configType.ts, config.schema.json, package.json, package-lock.json, CHANGLOG.md.
321 |
322 | ## v5.0.0
323 | * First version of awair2 plugin in TypeScript format implementing Homebridge dynamic platform.
324 | * Started at version 5.0.0 as homebridge-awair was at version 4.6.3.
325 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at douglas.blakeley@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | <<<<<<< HEAD
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 | =======
179 | MIT License
180 |
181 | Copyright (c) 2018 Henry Poydar
182 |
183 | Permission is hereby granted, free of charge, to any person obtaining a copy
184 | of this software and associated documentation files (the "Software"), to deal
185 | in the Software without restriction, including without limitation the rights
186 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
187 | copies of the Software, and to permit persons to whom the Software is
188 | furnished to do so, subject to the following conditions:
189 |
190 | The above copyright notice and this permission notice shall be included in all
191 | copies or substantial portions of the Software.
192 |
193 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
194 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
195 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
196 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
197 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
198 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
199 | SOFTWARE.
200 | >>>>>>> 84165d937afc637e672e9fbe396164c580e5cf9c
201 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |  |
4 |  |
5 |
6 |
7 |
8 | # homebridge-awair2
9 | [](https://github.com/homebridge/homebridge/wiki/Verified-Plugins)  
10 |
11 | This is a Homebridge plugin for the Awair-R2, Awair Element and Awair Omni air quality monitors for Nfarina's [Homebridge project](https://github.com/nfarina/homebridge). The Awair2 plugin is based on the [homebridge-awair](https://github.com/deanlyoung/homebridge-awair#readme) plugin developed by Dean L. Young.
12 |
13 | The Awair2 plugin will query your Awair account using your Developer Token to determine registered Awair devices which were setup through the Awair iOS app. While running, the plugin will fetch current sensor conditions for each Awair device (e.g. Awair Mint, Awair Omni, Awair Element, or Awair 2nd Edition) and provide sensor status and value (e.g. temperature, humidity, carbon dioxide, TVOC, PM2.5, Omni lux, and Omni battery) to HomeKit. You can look at the current Awair information via HomeKit enabled Apps on your iOS device or even ask Siri for them.
14 |
15 | By default, the plugin uses the Awair `CloudAPI` and will fetch new data based on selected `endpoint` and User Account tier. For 'Hobbyist' tier, `15-min-avg` endpoint samples every 15 minutes, `5-min-avg` every 5 minutes, `latest` every 5 minutes and `raw` every 3.3 minutes (200 seconds). The main difference between the `latest` and `raw` endpoints is that you can define a `limit` (i.e. number of consecutive data points) for the `raw` endpoint, in order to create your own averaging (e.g. `.../raw?limit=12` for a 2 minute average.
16 |
17 | When the `LocalAPI` is selected, new data is sampled over your local LAN from a supported Awair device (e.g. Awair Mint, Awair Omni, Awair Element, or Awair 2nd Edition) and provides available sensor readings (e.g. temperature, humidity, carbon dioxide, TVOC, and PM2.5). Unlike the `CloudAPI`, only the `latest` data set is collected and averaging of multiple data sets is not supported. `LocalAPI` permits higher sampling rate than is supported by the `CloudAPI`. 30 second `LocalAPI` sampling is recommended, although a minimum of 10 seconds is supported. The higher sampling rate is useful when you make use of HomeKit Automation triggered by IAQ levels. Instructions for enabling `LocalAPI` on your device can be found [here](https://support.getawair.com/hc/en-us/articles/360049221014-Awair-Element-Local-API-Feature#h_01F40FBBW5323GBPV7D6XMG4J8).
18 |
19 | For both `CloudAPI` and `LocalAPI`, your Awair devices and configuration are obtained from your Awair Account. In both cases you must acquire your `Developer Token` through the Awair app.
20 |
21 |
22 | # Installation
23 |
24 | 1. Install homebridge, reference [Homebridge Wiki](https://github.com/homebridge/homebridge/wiki)
25 | 2. The easiest way to install the Awair2 plugin is through the `homebridge` interface. Select `Plugins` at the top menu bar, search for `Awair2` and then select install. Alternately, the plugin may be installed from the command line using: `[sudo] npm install -g homebridge-awair2`.
26 | 3. Update your configuration file. See the sample below.
27 |
28 | The Awair2 plugin queries your Awair account to determine devices that you have registered. This returns the same informaton that you have entered via the Awair app on your iOS device.
29 |
30 | You will need to request access to the [Awair Developer Console](https://developer.getawair.com) to obtain your Developer Token (`token`). You can also request your Developer Token directly through the Awair App on your iPhone. From the App, select 'Awair+' in the lower right hand corner, then select 'Awair APIs', select 'Cloud API' and finally 'Get API Token'.
31 |
32 | 
33 |
34 | The [Awair Developer API Documentation](https://docs.developer.getawair.com) explains the inner workings of the Awair Developer API, but for the most part is not necessary to use this plugin.
35 |
36 |
37 | # Notes
38 |
39 | 1. If you are setting up an Awair unit for the first time, it is recommended that you allow a couple of hours after adding to the iOS Awair App for the unit to calibrate, update firmware if necessary and establish connection to the Awair Cloud. Initially the accessories may show up in Homebridge and HomeKit, however, the data field may be blank. This will correct after the data stream has been established between your Awair device and the Awair Cloud.
40 |
41 | 2. The plugin uses the new Awair Score methodology for Awair Element (Firmware v1.4.0) and Awair Omni (Firmware v1.8.0) introduced by Awair in Dec 2023. See [Reference](https://support.getawair.com/hc/en-us/articles/19504367520023#h_01HE1QVJ85K55N7QS8NAVBXWJM).
42 |
43 | * The IAQ Score System integrates readings from five sensors: Temperature, Humidity, Volatile Organic Compounds (VOC), Carbon Dioxide (CO2), and Particulate Matter (PM2.5). In the previous system, each factor contributed equally (approximately 20%) to the total score. The new air quality scoring system begins with normalizing sensor data to a scale where 0 represents good quality and 1 indicates the worst, assigning values that correspond to a range of scores. The final Indoor Air Quality (IAQ) score is a composite of the highest normalized values from CO2, VOC, and PM2.5 readings.
44 |
45 | * The new Awair Score level is displayed in Homebridge on the Accessory tile for Awair Element and Awair Omni in addition to the Homekit level. Awair r2 only displays the Homekit level. Unfortunately, HomeKit levels are defined by the HomeKit API and cannot be customized.
46 |
47 |
48 |
49 | Score |
50 | new Awair level |
51 | HomeKit level |
52 |
53 |
54 | 1 |
55 | GOOD |
56 | EXCELLENT |
57 |
58 |
59 | 2 |
60 | ACCEPTABLE |
61 | GOOD |
62 |
63 |
64 | 3 |
65 | MODERATE |
66 | FAIR |
67 |
68 |
69 | 4 |
70 | POOR |
71 | INFERIOR |
72 |
73 |
74 | 5 |
75 | HAZARDOUS |
76 | POOR |
77 |
78 |
79 |
80 | iOS Awair app version 4.7.3 is required to view the updated scores.
81 |
82 | 3. With iOS16, the layout, icons and status were refined in the iOS/iPadOS/macOS Home apps. Temperature and humidity are grouped under a single "climate" status icon at the top of the HomeKit screen. If you select this icon a screen opens with all of the Climate devices in your HomeKit.
83 |
84 | 
85 |
86 | 4. For those with multiple Awair devices, you can optionally list the macAddress of the device (found on the back or bottom of the device) which you want to exclude from HomeKit.
87 |
88 | 5. For Awair Omni, battery charge level, charging status, low battery, light level and occupancy detection based on ambient sound level [experimental] are also provided using the Local Sensors capability which is configured in the Awair App.
89 |
90 | 
91 |
92 | 6. Battery Status does not appear as a separate tile in the HomeKit interface. Battery charge level and status will be found in the Status menu for each of the sensors. A low battery indication will be identified as an alert in the HomeKit status section.
93 |
94 |
95 | # Plugin Configuration
96 |
97 | Configuration sample: [config-sample.json](https://github.com/DMBlakeley/homebridge-awair2/blob/master/config-sample.json)
98 |
99 |
100 | # Descriptions
101 |
102 | Reference [Wiki Chapter 3](https://github.com/DMBlakeley/homebridge-awair2/wiki/3.-Awair2-Configuration-Options) for additional details.
103 |
104 | (*) Introduced with v5.9.0.
105 |
106 | Parameter | Optional? | Description
107 | :-- | :----: | :---
108 | `platform` | | The Homebridge Accessory (REQUIRED, must be exactly: `Awair2`)
109 | `token` | | Developer Token (REQUIRED, see [Installation](#installation)) above.
110 | `userType` | Y | The type of user account (Default = `users/self`, options: `users/self` or `orgs/###`, where ### is the Awair Organization `orgId`)
111 | `apiMethod` | Y | The type of API used. (Default = `cloudAPI`, option: `localAPI`).
112 | `airQualityMethod` | Y | Air quality calculation method used to define the Air Quality Chracteristic (Default = `awair-score`, options: `awair-aqi`, `awair-pm`, `awair-score` or `nowcast-aqi`)
113 | `endpoint` | Y | The `/air-data/` endpoint to use (Default = `15-min-avg`, options: `15-min-avg`, `5-min-avg`, `raw` or `latest`). Will default to `latest` when `localAPI` enabled.
114 | `limit` | Y | Number of consecutive data points returned per request, used for custom averaging of sensor values (Default = `1` i.e. one `15-min-avg`). Defaults to `1` for `latest`.
115 | `carbonDioxideThreshold` | Y | The level at which HomeKit will trigger an alert for the CO2 in ppm. (Default = `1000`)
116 | `carbonDioxideThresholdOff` | Y | The level at which HomeKit will turn off the trigger alert for the CO2 in ppm, to ensure that it doesn't trigger on/off too frequently. Choose a number less than `carbonDioxideThreshold`. (Default = `800`)
117 | `enableTvocPm25` | Y | Whether to enable Total VOC and PM2.5 threshold binary sensors.
118 | `tvocThreshold`(*) | Y | Total VOC level at which HomeKit will trigger an alert in µg/m³. (Default = `1000`)
119 | `tvocThresholdOff`(*) | Y | Total VOC level at which HomeKit will turn off the trigger alert in µg/m³ to ensure that it doesn't trigger on/off too frequently. Choose a number less than `tvocThreshold`. (Default = `800`)
120 | `pm25Threshold`(*) | Y | The level at which HomeKit will trigger an alert for PM2.5 in µg/m³. (Default = `35`)
121 | `pm25ThresholdOff`(*) | Y | The level at which HomeKit will turn off the trigger alert for pm2.5 in µg/m³ to ensure that it doesn't trigger on/off too frequently. Choose a number less than `pm25Threshold`. (Default = `20`)
122 | `vocMw` | Y | The Molecular Weight (g/mol) of a reference gas or mixture that you use to convert from ppb to µg/m³. (Default = `72.66578273019740`)
123 | `occupancyDetection` | Y | Omni Only - Enables Omni occupancy detection based on minimum environmental sound level detected. (Default = `false`)
124 | `occupancyOffset` | Y | Omni Only - Used when `occupancy detection` enabled. Offset value in dBA above background sound level to set `not occupied` level, `occupied` is 0.5dBA higher. (Default = `2`)
125 | `occupancyRestart` | Y | Omni only - Reinitialize Occupancy detection measurement to determine unoccupied sound level on Homebridge reboot. (Default = `false`, use historical data)
126 | `enableModes` | Y | Applies to Omni, Awair-r2 & Element - Enables creation of Display Mode and LED Mode accessories. (Default = `false`)
127 | `logging` | Y | Whether to output logs to the Homebridge logs. (Default = `false`)
128 | `verbose` | Y | Whether to log results from API data calls. Requires `logging` to be `true`. (Default = `false`)
129 | `development` | Y | Enables Development mode to allow use of `test` Awair devices lacking `end user/Awair OUI` formatted Serial numbers. (Default = `false`)
130 | `ignoredDevices` | Y | Array of Awair device macAddresses (12 characters in length) to be excluded from HomeKit (OPTIONAL). `End user` devices with begin with Awair OUI '70886B', `test` devices are concatnation of right 12 characters of '00000000000' + deviceId.
131 |
132 | Reference Wiki for detailed description of [Configurion Options](https://github.com/DMBlakeley/homebridge-awair2/wiki/3.-Awair2-Configuration-Options).
133 |
134 |
135 |
136 | # Changelog
137 |
138 | Changelog is available [here](https://github.com/DMBlakeley/homebridge-awair2/blob/master/CHANGELOG.md).
139 |
140 |
141 |
142 | # Resources
143 |
144 | Reference Wiki for complete list of [Resources](https://github.com/DMBlakeley/homebridge-awair2/wiki/6.-Resources).
145 |
--------------------------------------------------------------------------------
/config-sample.json:
--------------------------------------------------------------------------------
1 | {
2 | "bridge": {
3 | "name": "Homebridge",
4 | "username": "AA:BB:CC:DD:EE:FF",
5 | "port": 51827,
6 | "pin": "031-45-154"
7 | },
8 |
9 | "description": "An example Homebridge config.json for Awair2.",
10 |
11 | "platforms": [
12 | {
13 | "platform": "Awair2",
14 | "token": "AAA.AAA.AAA",
15 | "userType": "users/self",
16 | "airQualityMethod": "awair-aqi",
17 | "endpoint": "15-min-avg",
18 | "limit": 1,
19 | "carbonDioxideThreshold": 1000,
20 | "carbonDioxideThresholdOff": 800,
21 | "enableTvocPm25": false,
22 | "tvocThreshold": 1000,
23 | "tvocThresholdOff": 800,
24 | "pm25Threshold": 35,
25 | "pm25ThresholdOff": 20,
26 | "vocMw": 72.66578273019740,
27 | "occupancyDetection": false,
28 | "occupancyOffset": 2,
29 | "occupancyRestart": false,
30 | "logging": false,
31 | "verbose": false,
32 | "enableModes": false,
33 | "development": false,
34 | "ignoredDevices": [
35 | "70886Bxxxxxx"
36 | ]
37 | }
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/config.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "pluginAlias": "Awair2",
3 | "pluginType": "platform",
4 | "singular": true,
5 | "headerDisplay": "Awair plug-in for [Homebridge](https://github.com/nfarina/homebridge) using the native Awair Cloud and Local APIs.",
6 | "footerDisplay": "Reference [Installation Instructions](https://github.com/DMBlakeley/homebridge-awair2#readme) for details on determining 'Developer Token'.",
7 | "schema": {
8 | "type": "object",
9 | "properties": {
10 | "token": {
11 | "title": "Developer Token (REQUIRED)",
12 | "type": "string",
13 | "placeholder": "AAA.AAA.AAA",
14 | "required": true,
15 | "description": "Reference Installation Instructions - link provided in footer."
16 | },
17 | "userType": {
18 | "title": "The type of User Account",
19 | "type": "string",
20 | "default": "users/self",
21 | "description": "The type of User Account. Default = 'users/self', Options: 'users/self' or 'orgs/###', where ### is the Awair Organization 'orgId'."
22 | },
23 | "apiMethod": {
24 | "title": "API used for data sampling",
25 | "type": "string",
26 | "default": "cloudAPI",
27 | "enum": ["cloudAPI", "localAPI"],
28 | "description": "The type of API used. Default = 'cloudAPI', Option: 'localAPI'."
29 | },
30 | "localPollingInterval": {
31 | "title": "Sampling interval used for local data sampling",
32 | "type": "integer",
33 | "default": "30",
34 | "description": "Sampling interval in seconds when 'localAPI' selected. 10 seconds minimum, 30 seconds recommended.",
35 | "condition": {
36 | "functionBody": "return model.apiMethod === 'localAPI';"
37 | }
38 | },
39 | "airQualityMethod": {
40 | "title": "Air quality calculation method",
41 | "type": "string",
42 | "default": "awair-score",
43 | "enum": ["awair-aqi", "awair-pm", "awair-score", "nowcast-aqi"],
44 | "description": "Air quality calculation method used to define the Air Quality Chracteristic. Default = 'awair-score', Options: 'awair-aqi', 'awair-pm', 'awair-score' and 'nowcast-aqi'.
- The 'awair-score' method maps the Awair Score to an Air Quality value.
- When 'awair-pm' is selected, the HomeKit Air Quality tile only reflects the particulates value, which is useful for automating air purifiers.
- When 'nowcast-api' is selected, 'endpoint' defaults to '15-min-avg' and 'data points returned' to '48'.
- NOTE: 'nowcast-aqi' not supported on localAPI."
45 | },
46 | "endpoint": {
47 | "title": "The 'air-data' endpoint",
48 | "type": "string",
49 | "default": "15-min-avg",
50 | "enum": ["15-min-avg", "5-min-avg", "raw", "latest"],
51 | "description": "The 'air-data' endpoint to use. Default = '15-min-avg', Options: '15-min-avg', '5-min-avg', 'raw', or 'latest'.",
52 | "condition": {
53 | "functionBody": "return model.airQualityMethod !== 'nowcast-aqi' && model.apiMethod === 'cloudAPI';"
54 | }
55 | },
56 | "limit": {
57 | "title": "Number of Data Points Returned",
58 | "type": "integer",
59 | "default": 1,
60 | "description": "Number of consecutive data points returned per request. Used for custom averaging of sensor values from 'raw' Endpoint. Endpoint of 'latest' defaults to '1'. Default = '1'.",
61 | "condition": {
62 | "functionBody": "return model.airQualityMethod !== 'nowcast-aqi' && model.apiMethod === 'cloudAPI' && model.endpoint === 'raw';"
63 | }
64 | },
65 | "carbonDioxideThreshold": {
66 | "title": "Carbon Dioxide Threshold - On",
67 | "type": "integer",
68 | "placeholder": 1000,
69 | "description": "The CO2 level in ppm at which HomeKit will turn ON the trigger alert for the CO2. Default = '1000'."
70 | },
71 | "carbonDioxideThresholdOff": {
72 | "title": "Carbon Dioxide Threshold - Off",
73 | "type": "integer",
74 | "placeholder": 800,
75 | "description": "The CO2 level in ppm at which HomeKit will turn OFF the trigger alert for the CO2 to ensure that it doesn't trigger on/off too frequently. Must be a number lower than 'carbonDioxideThreshold'. Default = '800'."
76 | },
77 | "enableTvocPm25": {
78 | "title": "Total VOC and PM2.5 - Whether to enable binary limit switches.",
79 | "type": "boolean",
80 | "default": false
81 | },
82 | "tvocThreshold": {
83 | "title": "Total VOC Threshold - On",
84 | "type": "integer",
85 | "placeholder": 1000,
86 | "description": "Total VOC level in µg/m³ at which HomeKit will turn ON the trigger alert for Total VOC. Default = '1000'.",
87 | "condition": {
88 | "functionBody": "return model.enableTvocPm25 === true;"
89 | }
90 | },
91 | "tvocThresholdOff": {
92 | "title": "Total VOC Threshold - Off",
93 | "type": "integer",
94 | "placeholder": 800,
95 | "description": "Total VOC level in µg/m³ at which HomeKit will turn OFF the trigger alert for the Total VOC to ensure that it doesn't trigger on/off too frequently. Must be a number lower than 'tvocThreshold'. Default = '800'.",
96 | "condition": {
97 | "functionBody": "return model.enableTvocPm25 === true;"
98 | }
99 | },
100 | "pm25Threshold": {
101 | "title": "PM2.5 Threshold - On",
102 | "type": "integer",
103 | "placeholder": 35,
104 | "description": "The PM2.5 level in µg/m³ at which HomeKit will turn ON the trigger alert for the PM2.5. Default = '35'.",
105 | "condition": {
106 | "functionBody": "return model.enableTvocPm25 === true;"
107 | }
108 | },
109 | "pm25ThresholdOff": {
110 | "title": "PM2.5 Threshold - Off",
111 | "type": "integer",
112 | "placeholder": 20,
113 | "description": "The PM2.5 level in µg/m³ at which HomeKit will turn OFF the trigger alert for the PM2.5 to ensure that it doesn't trigger on/off too frequently. Must be a number lower than 'pm25Threshold'. Default = '20'.",
114 | "condition": {
115 | "functionBody": "return model.enableTvocPm25 === true;"
116 | }
117 | },
118 | "vocMw": {
119 | "title": "Reference Gas Molecular Weight",
120 | "type": "number",
121 | "placeholder": 72.66578273019740,
122 | "description": "The Molecular Weight (g/mol) of a reference gas or mixture that you use to convert from ppb to µg/m³."
123 | },
124 | "occupancyDetection": {
125 | "title": "Omni Occupancy Detection - Whether to enable Occupancy detection based on minimum sound level.",
126 | "type": "boolean",
127 | "default": false,
128 | "description": "Omni only - enables occupancy detection based on detected background sound level + occupancyOffset value."
129 | },
130 | "occupancyOffset": {
131 | "title": "Omni Occupancy decibels Offset - used when occupancyDetection enabled.",
132 | "type": "number",
133 | "placeholder": 2.0,
134 | "multipleOf": 0.5,
135 | "description": "Omni only - used when `occupancy detection` enabled. Offset value in dBA above detected background sound level to set upper level for `not occupied`. The lower level for `occupied` is an additional 0.5dBA higher. See Wiki for further explanation. Default = '2'.",
136 | "condition": {
137 | "functionBody": "return model.occupancyDetection === true;"
138 | }
139 | },
140 | "occupancyRestart": {
141 | "title": "Omni Reset Occupancy Status - reinitialize Occupancy detection measurement on Homebridge reboot.",
142 | "type": "boolean",
143 | "default": false,
144 | "description": "Omni only - reinitialize Occupancy detection measurement to determine unoccupied sound level on Homebridge boot or restart.",
145 | "condition": {
146 | "functionBody": "return model.occupancyDetection === true;"
147 | }
148 | },
149 | "logging": {
150 | "title": "Logging - Whether to output logs to the Homebridge logs.",
151 | "type": "boolean",
152 | "default": false
153 | },
154 | "verbose": {
155 | "title": "Verbose Logging - Whether to include API data call results when logging is enabled.",
156 | "type": "boolean",
157 | "default": false,
158 | "condition": {
159 | "functionBody": "return model.logging === true;"
160 | }
161 | },
162 | "enableModes": {
163 | "title": "Display Mode Switches - Whether to enable Display Mode and LED Mode Accessories for Awair-Omni, Awair-R2 and Awair-Element.",
164 | "type": "boolean",
165 | "default": false
166 | },
167 | "modeTemp": {
168 | "title": "Select to display 'F' for Display Mode temperature (Default = 'C')",
169 | "type": "boolean",
170 | "default": false,
171 | "condition": {
172 | "functionBody": "return model.enableModes === true;"
173 | }
174 | },
175 | "modeTime": {
176 | "title": "Select to display '24hr' for Display Mode time (Default = '12hr')",
177 | "type": "boolean",
178 | "default": false,
179 | "condition": {
180 | "functionBody": "return model.enableModes === true;"
181 | }
182 | },
183 | "development": {
184 | "title": "Development Mode - Whether to enable Development mode to allow use of 'test' Awair devices lacking production/Awair OUI formatted Serial numbers.",
185 | "type": "boolean",
186 | "default": false
187 | },
188 | "ignoredDevices": {
189 | "title": "Ignored Devices",
190 | "description": "Awair account devices you wish to hide in Homekit.",
191 | "type": "array",
192 | "maxItems": 0,
193 | "items": {
194 | "title": "macAddress from back or bottom of Awair device:",
195 | "type": "string",
196 | "minLength": 12,
197 | "maxLength": 12,
198 | "placeholder": "70886Bxxxxxx (Example)"
199 | }
200 | }
201 | }
202 | },
203 | "layout": [
204 | {
205 | "type": "flex",
206 | "flex-flow": "row wrap",
207 | "items": ["token"]
208 | },
209 | {
210 | "type": "fieldset",
211 | "title": "Optional Configuration Settings",
212 | "expandable": true,
213 | "expanded": false,
214 | "items": [
215 | "userType",
216 | "apiMethod",
217 | "localPollingInterval",
218 | "airQualityMethod",
219 | "endpoint",
220 | "limit",
221 | "carbonDioxideThreshold",
222 | "carbonDioxideThresholdOff",
223 | "enableTvocPm25",
224 | "tvocThreshold",
225 | "tvocThresholdOff",
226 | "pm25Threshold",
227 | "pm25ThresholdOff",
228 | "vocMw",
229 | "occupancyDetection",
230 | "occupancyOffset",
231 | "occupancyRestart",
232 | "logging",
233 | "verbose",
234 | "enableModes",
235 | "modeTemp",
236 | "modeTime",
237 | "development"
238 | ]
239 | },
240 | {
241 | "type": "array",
242 | "title": "Ignored Devices",
243 | "expandable": true,
244 | "expanded": false,
245 | "items": [
246 | "ignoredDevices[]"
247 | ]
248 | }
249 | ]
250 | }
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import tsParser from '@typescript-eslint/parser';
2 | import path from 'node:path';
3 | import { fileURLToPath } from 'node:url';
4 | import js from '@eslint/js';
5 | import { FlatCompat } from '@eslint/eslintrc';
6 |
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = path.dirname(__filename);
9 | const compat = new FlatCompat({
10 | baseDirectory: __dirname,
11 | recommendedConfig: js.configs.recommended,
12 | allConfig: js.configs.all,
13 | });
14 |
15 | export default [{
16 | ignores: ['**/dist'],
17 | }, ...compat.extends(
18 | 'eslint:recommended',
19 | 'plugin:@typescript-eslint/eslint-recommended',
20 | 'plugin:@typescript-eslint/recommended',
21 | ), {
22 | languageOptions: {
23 | parser: tsParser,
24 | ecmaVersion: 2018,
25 | sourceType: 'module',
26 | },
27 |
28 | rules: {
29 | quotes: ['warn', 'single'],
30 |
31 | indent: ['warn', 2, {
32 | SwitchCase: 1,
33 | }],
34 |
35 | 'linebreak-style': ['warn', 'unix'],
36 | semi: ['warn', 'always'],
37 | 'comma-dangle': ['warn', 'always-multiline'],
38 | 'dot-notation': 'off',
39 | eqeqeq: 'warn',
40 | curly: ['warn', 'all'],
41 | 'brace-style': ['warn'],
42 | 'prefer-arrow-callback': ['warn'],
43 | 'max-len': ['warn', 140],
44 | 'no-console': ['warn'],
45 | 'no-non-null-assertion': ['off'],
46 | 'comma-spacing': ['error'],
47 |
48 | 'no-multi-spaces': ['warn', {
49 | ignoreEOLComments: true,
50 | }],
51 |
52 | 'lines-between-class-members': ['warn', 'always', {
53 | exceptAfterSingleLine: true,
54 | }],
55 |
56 | '@typescript-eslint/explicit-function-return-type': 'off',
57 | '@typescript-eslint/no-non-null-assertion': 'off',
58 | '@typescript-eslint/explicit-module-boundary-types': 'off',
59 | '@typescript-eslint/no-explicit-any': 'off',
60 | '@typescript-eslint/no-unused-expressions': 'off',
61 | 'no-case-declarations': 'off',
62 | 'no-mixed-spaces-and-tabs': 'off',
63 | },
64 | }];
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": [
3 | "src"
4 | ],
5 | "ext": "ts",
6 | "ignore": [],
7 | "exec": "tsc && homebridge -I -D",
8 | "signal": "SIGTERM",
9 | "env": {
10 | "NODE_OPTIONS": "--trace-warnings"
11 | }
12 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": false,
3 | "displayName": "Homebridge Awair2",
4 | "name": "homebridge-awair2",
5 | "version": "5.13.1",
6 | "description": "HomeKit integration of Awair air quality monitor as Dynamic Platform.",
7 | "main": "dist/index.js",
8 | "scripts": {
9 | "lint": "eslint src/**.ts",
10 | "watch": "npm run build && npm link && nodemon",
11 | "build": "rimraf ./dist && tsc",
12 | "prepublishOnly": "npm run lint && npm run build",
13 | "clean": "rimraf ./dist",
14 | "postpublish": "npm run clean"
15 | },
16 | "keywords": [
17 | "homebridge",
18 | "homebridge-plugin",
19 | "air quality sensor",
20 | "temperature sensor",
21 | "humidity sensor",
22 | "carbon dioxide sensor",
23 | "awair mint",
24 | "awair omni",
25 | "awair 2nd edition",
26 | "awair element"
27 | ],
28 | "author": "Douglas M. Blakeley",
29 | "engines": {
30 | "node": "^18.20.4 || ^20.18.0 || ^22.10.0",
31 | "homebridge": "^1.8.0 || ^2.0.0-beta.0"
32 | },
33 | "license": "ISC",
34 | "files": [
35 | "LICENSE",
36 | "README.md",
37 | "CHANGELOG.md",
38 | "dist/**/*",
39 | "config.schema.json",
40 | "package.json"
41 | ],
42 | "repository": {
43 | "type": "git",
44 | "url": "git://github.com/DMBlakeley/homebridge-awair2.git"
45 | },
46 | "bugs": {
47 | "url": "https://github.com/DMBlakeley/homebridge-awair2/issues"
48 | },
49 | "homepage": "https://github.com/DMBlakeley/homebridge-awair2#readme",
50 | "devDependencies": {
51 | "@types/node": "^22.0.0",
52 | "@typescript-eslint/eslint-plugin": "^8.0.0",
53 | "@typescript-eslint/parser": "^8.0.1",
54 | "eslint": "^9.0.0",
55 | "follow-redirects": "^1.14.7",
56 | "homebridge": "^1.1.3",
57 | "minimist": "^1.2.7",
58 | "nodemon": "^3.0.1",
59 | "rimraf": "^6.0.1",
60 | "ts-node": "^10.0.0",
61 | "typescript": "^5.0.2"
62 | },
63 | "dependencies": {
64 | "@eslint/eslintrc": "^3.1.0",
65 | "@eslint/js": "^9.9.0",
66 | "axios": "^1.7.3",
67 | "minimist": "^1.2.7",
68 | "optimist": "^0.5.2"
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/screenshots/Awair_Characteristics_v2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/Awair_Characteristics_v2.png
--------------------------------------------------------------------------------
/screenshots/IMG_1929.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/IMG_1929.jpeg
--------------------------------------------------------------------------------
/screenshots/IMG_1930.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/IMG_1930.jpeg
--------------------------------------------------------------------------------
/screenshots/IMG_1931.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/IMG_1931.jpeg
--------------------------------------------------------------------------------
/screenshots/IMG_1932.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/IMG_1932.jpeg
--------------------------------------------------------------------------------
/screenshots/Image5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/Image5.png
--------------------------------------------------------------------------------
/screenshots/balenaEtcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/balenaEtcher.png
--------------------------------------------------------------------------------
/screenshots/canakit_contents.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/canakit_contents.jpeg
--------------------------------------------------------------------------------
/screenshots/canakit_youtube.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/canakit_youtube.png
--------------------------------------------------------------------------------
/screenshots/homebridge1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/homebridge1.png
--------------------------------------------------------------------------------
/screenshots/homebridge2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/homebridge2.png
--------------------------------------------------------------------------------
/screenshots/homebridge3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/homebridge3.png
--------------------------------------------------------------------------------
/screenshots/homebridge4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/homebridge4.png
--------------------------------------------------------------------------------
/screenshots/homebridge5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/homebridge5.png
--------------------------------------------------------------------------------
/screenshots/homebridge6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/homebridge6.png
--------------------------------------------------------------------------------
/screenshots/homebridge7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/homebridge7.png
--------------------------------------------------------------------------------
/screenshots/homebridge8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/homebridge8.png
--------------------------------------------------------------------------------
/screenshots/ios16_automation.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/ios16_automation.gif
--------------------------------------------------------------------------------
/screenshots/ios16_awair_mode_names.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/ios16_awair_mode_names.gif
--------------------------------------------------------------------------------
/screenshots/ios16_awair_modes.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/ios16_awair_modes.gif
--------------------------------------------------------------------------------
/screenshots/ios16_carbon_dioxide.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/ios16_carbon_dioxide.gif
--------------------------------------------------------------------------------
/screenshots/ios16_climate.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/ios16_climate.gif
--------------------------------------------------------------------------------
/screenshots/ios16_climate_battery.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/ios16_climate_battery.gif
--------------------------------------------------------------------------------
/screenshots/ios16_developer_token.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/ios16_developer_token.gif
--------------------------------------------------------------------------------
/screenshots/ios16_local_api.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/ios16_local_api.gif
--------------------------------------------------------------------------------
/screenshots/ios16_modes.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/ios16_modes.gif
--------------------------------------------------------------------------------
/screenshots/restart.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DMBlakeley/homebridge-awair2/cc90b32ad13d529c7b92cd3bf32a4bbdadf25289/screenshots/restart.png
--------------------------------------------------------------------------------
/src/configTypes.ts:
--------------------------------------------------------------------------------
1 | export type AwairPlatformConfig = {
2 | platform: string;
3 | token: string;
4 | userType: string;
5 | apiMethod: string;
6 | localPollingInterval: number;
7 | airQualityMethod: string;
8 | endpoint: string;
9 | polling_interval: number;
10 | limit: number;
11 | carbonDioxideThreshold: number;
12 | carbonDioxideThresholdOff: number;
13 | enableTvocPm25: boolean;
14 | tvocThreshold: number;
15 | tvocThresholdOff: number;
16 | pm25Threshold: number;
17 | pm25ThresholdOff: number;
18 | vocMw: number;
19 | occupancyDetection: boolean;
20 | occupancyOffset: number;
21 | occupancyRestart: false;
22 | occDetectedLevel: number;
23 | occNotDetectedLevel: number;
24 | enableModes: boolean;
25 | logging: boolean;
26 | verbose: boolean;
27 | development: boolean;
28 | modeTemp: boolean;
29 | ignoredDevices: [string];
30 | };
31 |
32 | export type DeviceConfig = {
33 | name: string;
34 | macAddress: string;
35 | latitude: number;
36 | preference: string;
37 | timezone: string;
38 | roomType: string;
39 | deviceType: string;
40 | longitude: number;
41 | spaceType: string;
42 | deviceUUID: string;
43 | deviceId: number;
44 | locationName: string;
45 | accType: string;
46 | };
47 |
48 | export type UserConfig = {
49 | userTier: string;
50 | fifteenMin: number;
51 | fiveMin: number;
52 | raw: number;
53 | latest: number;
54 | getPowerStatus: number;
55 | getTimeZone: number;
56 | };
57 |
58 | export type DisplayConfig = {
59 | mode: string; // score, temp, humid, co2, voc, pm25, clock
60 | clock_mode: string; // 12hr, 24hr (default = 12hr)
61 | temp_unit: string; // c, f (default = c)
62 | };
63 |
64 | export type LEDConfig = {
65 | mode: string; // auto, manual, sleep
66 | brightness: number; // 0 -> 100 in %
67 | };
68 |
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 |
2 | // node_modules/hap-nodejs/dist/lib/Advertiser.d.ts:5:29 - error TS7016: Could not find a declaration file for module
3 | // '@homebridge/dbus-native'. '…/node_modules/@homebridge/dbus-native/index.js' implicitly has an 'any' type.
4 |
5 | declare module '@homebridge/dbus-native' {
6 |
7 | }
8 |
9 | // node_modules/hap-nodejs/dist/lib/Accessory.d.ts:1:34 - error TS7016: Could not find a declaration file for module 'bonjour-hap'.
10 | // '.../node_modules/bonjour-hap/index.js' implicitly has an 'any' type.
11 |
12 | declare module 'bonjour-hap' {
13 |
14 | }
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | // Description: This is a Homebridge Dynamic Platform plugin for the Awair family of indoor air quality (IAQ) monitors implemented
2 | // in TypeScript for Nfarina's [Homebridge project](https://github.com/nfarina/homebridge). The Awair2 plugin is based
3 | // on the [homebridge-awair](https://github.com/deanlyoung/homebridge-awair#readme) plugin developed by Dean L. Young.
4 | // Author: Douglas M. Blakeley
5 |
6 | import {
7 | API,
8 | APIEvent,
9 | CharacteristicEventTypes,
10 | CharacteristicSetCallback,
11 | CharacteristicValue,
12 | DynamicPlatformPlugin,
13 | HAP,
14 | Logger,
15 | PlatformAccessory,
16 | PlatformAccessoryEvent,
17 | PlatformConfig,
18 | } from 'homebridge';
19 |
20 | import { AwairPlatformConfig, DeviceConfig } from './configTypes';
21 | import axios from 'axios';
22 | import * as packageJSON from '../package.json';
23 |
24 | let hap: HAP;
25 | let Accessory: typeof PlatformAccessory;
26 |
27 | const PLUGIN_NAME = 'homebridge-awair2';
28 | const PLATFORM_NAME = 'Awair2';
29 |
30 | // Register Awair Platform
31 | export = (api: API): void => {
32 | hap = api.hap;
33 | Accessory = api.platformAccessory;
34 | api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, AwairPlatform);
35 | };
36 |
37 | class AwairPlatform implements DynamicPlatformPlugin {
38 | public readonly log: Logger;
39 | public readonly api: API;
40 | public config: AwairPlatformConfig;
41 |
42 | // PlaftformAccessory defaults
43 | private readonly manufacturer = 'Awair';
44 | private readonly accessories: PlatformAccessory[] = [];
45 | private devices: any [] = []; // array of Awair devices
46 | private ignoredDevices: string [] = []; // array of ignored Awair devices
47 |
48 | // default values when not defined in config.json
49 | private userType = 'users/self';
50 | private apiMethod = 'cloudAPI';
51 | private airQualityMethod = 'awair-score';
52 | private endpoint = '15-min-avg';
53 | private limit = 1;
54 | private polling_interval = 900; // default, will be adjusted by account type Tier Quota and endpoint
55 | private localPollingInterval = 30;
56 | private carbonDioxideThreshold = 1000;
57 | private carbonDioxideThresholdOff = 800;
58 | private enableTvocPm25 = false;
59 | private tvocThreshold = 1000;
60 | private tvocThresholdOff = 800;
61 | private pm25Threshold = 35;
62 | private pm25ThresholdOff = 20;
63 | private vocMw = 72.6657827301974; // Molecular Weight (g/mol) of a reference VOC gas or mixture
64 | private occupancyOffset = 2.0;
65 | private occDetectedNotLevel = 55; // min level is 50dBA +/- 3dBA due to dust sensor fan noise in Omni
66 | private occDetectedLevel = 60;
67 | private omniPresent = false; // flag that Awair account contains Omni device(s), enables occupancy detection loop
68 |
69 | //default User Info Hobbyist samples per 24 hours reference UTC 00:00:00
70 | private userTier = 'Hobbyist';
71 | private fifteenMin = 100;
72 | private fiveMin = 300;
73 | private raw = 500;
74 | private latest = 300;
75 | private secondsPerDay = 60 * 60 * 24;
76 |
77 | // displayModes and ledModes for Omni, Awair-r2 and Element
78 | private displayModes: string[] = ['Score', 'Temp', 'Humid', 'CO2', 'VOC', 'PM25', 'Clock'];
79 | private ledModes: string[] = ['Auto', 'Sleep', 'Manual'];
80 | private enableModes = false;
81 | private temperatureUnits = 'c'; // default
82 | private timeFormat = '12hr'; // default
83 |
84 | // HomeKit API score definitions and new Awair score definitions
85 | private homekitScore: string[] = ['Error', 'Excellent', 'Good', 'Fair', 'Inferior', 'Poor'];
86 | private awairScore: string[] = ['Error', 'Good', 'Acceptable', 'Moderate', 'Poor', 'Hazardous'];
87 |
88 | /**
89 | * The platform class constructor used when registering a plugin.
90 | *
91 | * @param log The platform's logging function.
92 | * @param config The platform's config.json section as object.
93 | * @param api The homebridge API.
94 | */
95 | constructor(log: Logger, config: PlatformConfig, api: API) {
96 | this.log = log;
97 | this.config = config as unknown as AwairPlatformConfig;
98 | this.api = api;
99 | this.accessories = []; // store restored cached accessories here
100 |
101 | // We need Developer token or we're not starting. Check if length of Developer token is xx characters long.
102 | if(!this.config.token) {
103 | this.log.error('Awair Developer token not specified. Reference installation instructions.');
104 | return;
105 | }
106 |
107 | // eslint-disable-next-line max-len
108 | if(!(this.config.token.startsWith('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9') || this.config.token.startsWith('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'))) {
109 | this.log.error('Awair Developer token is not valid. Please check that token is entered correctly with no leading spaces.');
110 | return;
111 | }
112 |
113 | this.log.info('Developer token is valid, loading Awair devices from account.');
114 |
115 | // check for Optional entries in config.json
116 | if ('userType' in this.config) {
117 | this.userType = this.config.userType;
118 | }
119 |
120 | if ('apiMethod' in this.config) {
121 | this.apiMethod = this.config.apiMethod;
122 | }
123 |
124 | if ('localPollingInterval' in this.config) {
125 | this.localPollingInterval = (this.config.localPollingInterval < 10) ? 10 : this.config.localPollingInterval; // 10 seconds minimum
126 | }
127 |
128 | if ('airQualityMethod' in this.config) {
129 | this.airQualityMethod = this.config.airQualityMethod;
130 | }
131 |
132 | if (this.airQualityMethod === 'nowcast-aqi') {
133 | this.endpoint = '15-min-avg'; // nowcast-aqi is calculated over 12 hours, 15-min-avg data will be used for calculation
134 | this.limit = 48; // nowcast-aqi is calculated over 12 hours
135 | } else if ('endpoint' in this.config) {
136 | this.endpoint = this.config.endpoint;
137 | }
138 |
139 | if (this.config.apiMethod === 'localAPI') {
140 | this.endpoint = 'latest';
141 | this.limit = 1;
142 | }
143 |
144 | /* config.limit used for averaging of 'raw', '5-min', and '15-min' data, most recent sample used for 'latest'
145 | * Useful for all endpoints in case you want to rely on a different averaging scheme, for example, a 24 hour average (often used
146 | * for IAQ calculation) would be easier with the '15-min'avg' endpoint.
147 | */
148 | if (('limit' in this.config) && (this.airQualityMethod !== 'nowcast-aqi')) {
149 | switch (this.endpoint) { // check that this.config.limit does not exceed limits
150 | case '15-min-avg':
151 | this.limit = (this.config.limit > 672) ? 672 : this.config.limit; // 672 samples max or ~7 days
152 | break;
153 | case '5-min-avg':
154 | this.limit = (this.config.limit > 288) ? 288 : this.config.limit; // 288 samples max or ~24 hours
155 | break;
156 | case 'raw':
157 | this.limit = (this.config.limit > 360) ? 360 : this.config.limit; // 360 samples max or ~1 hour
158 | break;
159 | case 'latest':
160 | this.limit = 1; // no 'limit' applied to 'latest' endpoint, produces exactly one value
161 | break;
162 | default:
163 | this.log.error('Error: Endpoint not defined in Awair account.');
164 | break;
165 | }
166 | }
167 |
168 | if ('carbonDioxideThreshold' in this.config) {
169 | this.carbonDioxideThreshold = Number(this.config.carbonDioxideThreshold);
170 | }
171 |
172 | if ('carbonDioxideThresholdOff' in this.config) {
173 | this.carbonDioxideThresholdOff = Number(this.config.carbonDioxideThresholdOff);
174 | }
175 |
176 | if (this.carbonDioxideThreshold < this.carbonDioxideThresholdOff) {
177 | this.log.warn ('"Carbon Dioxide Threshold Off" must be less than "Carbon Dioxide Threshold", using defaults.');
178 | this.carbonDioxideThreshold = 1000;
179 | this.carbonDioxideThresholdOff = 800;
180 | }
181 |
182 | if ('enableTvocPm25' in this.config) {
183 | this.enableTvocPm25 = this.config.enableTvocPm25;
184 | }
185 |
186 | if (this.enableTvocPm25) { // only check thresholds if TVOC and PM2.5 sensors are enabled
187 | if ('tvocThreshold' in this.config) {
188 | this.tvocThreshold = Number(this.config.tvocThreshold);
189 | }
190 |
191 | if ('tvocThresholdOff' in this.config) {
192 | this.tvocThresholdOff = Number(this.config.tvocThresholdOff);
193 | }
194 |
195 | if (this.tvocThreshold <= this.tvocThresholdOff) {
196 | this.log.warn ('"Total VOC Threshold Off" must be less than "Total VOC Threshold", using defaults.');
197 | this.tvocThreshold = 1000;
198 | this.tvocThresholdOff = 800;
199 | }
200 |
201 | if ('pm25Threshold' in this.config) {
202 | this.pm25Threshold = Number(this.config.pm25Threshold);
203 | }
204 |
205 | if ('pm25ThresholdOff' in this.config) {
206 | this.pm25ThresholdOff = Number(this.config.pm25ThresholdOff);
207 | }
208 |
209 | if (this.pm25Threshold <= this.pm25ThresholdOff) {
210 | this.log.warn ('"PM2.5 Threshold Off" must be less than "PM2.5 Threshold", using defaults.');
211 | this.pm25Threshold = 35;
212 | this.pm25ThresholdOff = 20;
213 | }
214 | }
215 |
216 | if ('vocMw' in this.config) {
217 | this.vocMw = this.config.vocMw;
218 | }
219 |
220 | if ('occupancyOffset' in this.config) {
221 | this.occupancyOffset = this.config.occupancyOffset;
222 | }
223 |
224 | if ('enableModes' in this.config) {
225 | this.enableModes = this.config.enableModes;
226 | }
227 |
228 | if ('modeTemp' in this.config) {
229 | if (this.config.modeTemp === true) {
230 | this.temperatureUnits = 'f';
231 | }
232 | }
233 |
234 | if ('modeTime' in this.config) {
235 | if (this.config.modeTime === true) {
236 | this.timeFormat = '24hr';
237 | }
238 | }
239 |
240 | if ('ignoredDevices' in this.config) {
241 | this.ignoredDevices = this.config.ignoredDevices;
242 | }
243 |
244 | /*
245 | * When this event is fired, homebridge restored all cached accessories from disk and did call their respective
246 | * `configureAccessory` method for all of them. Dynamic Platform plugins should only register new accessories
247 | * after this event was fired, in order to ensure they weren't added to homebridge already.
248 | * This event can also be used to start discovery of new accessories.
249 | */
250 | api.on(APIEvent.DID_FINISH_LAUNCHING, () => {
251 | this.log.info('homebridge-awair2 platform didFinishLaunching');
252 | this.didFinishLaunching();
253 | });
254 | }
255 |
256 | /**
257 | * REQUIRED: This function is invoked when homebridge restores EACH CACHED accessory (IAQ, Display, LED) from disk at startup.
258 | * It should be used to setup event handlers for characteristics and update respective values.
259 | *
260 | * @param {object} accessory The accessory in question.
261 | */
262 | configureAccessory(accessory: PlatformAccessory): void {
263 | if(this.config.logging){
264 | // eslint-disable-next-line max-len
265 | this.log.info(`Restoring cached accessory deviceUUID: ${accessory.context.deviceUUID}, ${accessory.context.accType}, UUID: ${accessory.UUID}`);
266 | }
267 |
268 | switch(accessory.context.accType) {
269 | case 'IAQ':
270 | // make sure VOC and PM2.5 alert services are added if enabled after initial plug-in configuration
271 | if (this.enableTvocPm25) {
272 | const vocService = accessory.getService(`${accessory.context.name}: TVOC Limit`);
273 | if (!vocService) {
274 | accessory.addService(hap.Service.OccupancySensor, `${accessory.context.name}: TVOC Limit`, '0');
275 | }
276 | const pm25Service = accessory.getService(`${accessory.context.name}: PM2.5 Limit`);
277 | if (!pm25Service) {
278 | accessory.addService(hap.Service.OccupancySensor, `${accessory.context.name}: PM2.5 Limit`, '1');
279 | }
280 | }
281 | break;
282 | case 'Display': // initialize Display Mode switch characteristics
283 | this.addDisplayModeServices(accessory);
284 | break;
285 | case 'LED': // initialize LED Mode switch characteristics
286 | this.addLEDModeServices(accessory);
287 | break;
288 | }
289 | this.accessories.push(accessory);
290 | }
291 |
292 | /**
293 | * When the homebridge api finally registers the plugin, homebridge fires the
294 | * didFinishLaunching event, which in turn, launches the following method
295 | */
296 | async didFinishLaunching(): Promise {
297 |
298 | // Get Developer User Info from your Awair account
299 | await this.getUserInfo();
300 |
301 | // Get registered Awair devices from your Awair account
302 | await this.getAwairDevices();
303 |
304 | // Create array to keep track of devices
305 | const serNums: string[] = [];
306 |
307 | // Add accessories for each Awair device (IAQ, Display Mode, LED Mode)
308 | this.devices.forEach(async (device): Promise => {
309 |
310 | // determine if device supports Display and LED modes - Omni, R2 and Element, not available on Mint
311 | // eslint-disable-next-line max-len
312 | const modeDevice: boolean = (device.deviceType === 'awair-omni') || (device.deviceType === 'awair-r2') || (device.deviceType === 'awair-element');
313 |
314 | // 'end user' device must NOT be on ignored list AND must contain the Awair OUI "70886B", the NIC can be any hexadecimal string
315 | if (!this.ignoredDevices.includes(device.macAddress) && device.macAddress.includes('70886B')) {
316 | this.addAirQualityAccessory(device);
317 | // Add displayMode & ledMode Accessories for Omni, Awair-r2 and Element if Modes are enabled
318 | if (modeDevice && this.enableModes) {
319 | this.addDisplayModeAccessory(device);
320 | this.addLEDModeAccessory(device);
321 | }
322 | serNums.push(device.macAddress);
323 |
324 | // 'test' device must NOT be on ignored list AND will contain '000000' AND Development enabled to use
325 | } else if (!this.ignoredDevices.includes(device.macAddress) && device.macAddress.includes('000000') && this.config.development) {
326 | this.addAirQualityAccessory(device);
327 | // Add displayMode & ledMode Accessories for Omni, Awair-r2 and Element if Modes are enabled
328 | if (modeDevice && this.enableModes) {
329 | this.addDisplayModeAccessory(device);
330 | this.addLEDModeAccessory(device);
331 | }
332 | serNums.push(device.macAddress);
333 |
334 | } else {
335 | if (this.config.logging) {
336 | // conditions above _should_ be satisfied, unless the MAC is missing (contact Awair), incorrect, or a testing device
337 | this.log.warn(`Error with Serial ${device.macAddress} on ignore list, does not match Awair OUI "70886B" or is `
338 | + 'test device (requires development mode enabled to use).');
339 | }
340 | }
341 | });
342 |
343 | const badAccessories: Array = [];
344 | this.accessories.forEach((cachedAccessory): void => {
345 | if (!serNums.includes(cachedAccessory.context.serial) ||
346 | // Remove old, no longer used or ignored devices.
347 | this.ignoredDevices.includes(cachedAccessory.context.serial) ||
348 | // Remove Device and LED modes if disabled after adding.
349 | ((cachedAccessory.context.accType === 'Display') && !this.enableModes) ||
350 | ((cachedAccessory.context.accType === 'LED') && !this.enableModes) ||
351 | // Remove Awair, Glow and Glow-C as these devices 'sunsetted' by Awair as of 30 November 2022
352 | (cachedAccessory.context.deviceType === 'awair') ||
353 | (cachedAccessory.context.deviceType === 'awair-glow') ||
354 | (cachedAccessory.context.deviceType === 'awair-glow-c')) {
355 | badAccessories.push(cachedAccessory);
356 | }
357 | });
358 | this.removeAwairAccessories(badAccessories);
359 |
360 | // Get initial Air and Local data for all devices. Initialize displayMode & ledMode for Omni, Awair-r2 and Element if enabled.
361 | if(this.config.logging){
362 | this.log.info('--- Initializing IAQ data, Display mode and LED mode for Awair devices ---');
363 | }
364 | this.accessories.forEach(async (accessory): Promise => {
365 | switch (accessory.context.accType) {
366 | case 'IAQ':
367 | if (this.config.logging) {
368 | this.log.info(`[${accessory.context.serial}] Getting initial IAQ status for ${accessory.context.deviceUUID}`);
369 | }
370 | await this.updateAirQualityData(accessory);
371 |
372 | if (accessory.context.deviceType === 'awair-omni') {
373 | await this.getBatteryStatus(accessory);
374 | }
375 |
376 | if ((accessory.context.deviceType === 'awair-omni') && this.config.occupancyDetection) {
377 | await this.getOccupancyStatus(accessory);
378 | }
379 |
380 | if ((accessory.context.deviceType === 'awair-omni') || (accessory.context.deviceType === 'awair-mint')) {
381 | await this.getLightLevel(accessory);
382 | }
383 | break;
384 | case 'Display': // applies to Omni, Awair-r2 and Element
385 | if (this.config.logging) {
386 | // eslint-disable-next-line max-len
387 | this.log.info(`[${accessory.context.serial}] Setting Display Mode for ${accessory.context.deviceUUID} to ${accessory.context.displayMode}`);
388 | }
389 |
390 | // initialize Display Mode switch array: 'Score' if new accessory (addDisplayModeAccessory), cached value if existing
391 | this.displayModes.forEach(async (displayMode): Promise => {
392 | if (accessory.context.displayMode === displayMode) { // set switch 'on'
393 | await this.putDisplayMode(accessory, accessory.context.displayMode); // update device mode to match
394 | const activeSwitch = accessory.getService(`${accessory.context.name}: ${displayMode}`);
395 | if (activeSwitch) {
396 | activeSwitch
397 | .updateCharacteristic(hap.Characteristic.On, true);
398 | }
399 | } else { // set switch to 'off'
400 | const inactiveSwitch = accessory.getService(`${accessory.context.name}: ${displayMode}`);
401 | if (inactiveSwitch) {
402 | inactiveSwitch
403 | .updateCharacteristic(hap.Characteristic.On, false);
404 | }
405 | }
406 | });
407 | break;
408 | case 'LED': // applies to Omni, Awair-r2 and Element
409 | if (this.config.logging) {
410 | // eslint-disable-next-line max-len
411 | this.log.info(`[${accessory.context.serial}] Setting LED Mode for ${accessory.context.deviceUUID} to ${accessory.context.ledMode}, brightness ${accessory.context.ledBrightness}`);
412 | }
413 |
414 | // initialize LED Mode switch array: 'Auto' if new accessory (addLEDModeAccessory), cached value if existing
415 | this.ledModes.forEach(async (ledMode): Promise => {
416 | if (accessory.context.ledMode === ledMode) { // set switch 'on'
417 | await this.putLEDMode(accessory, accessory.context.ledMode, accessory.context.ledBrightness); // update device LED mode
418 | const activeSwitch = accessory.getService(`${accessory.context.name}: ${ledMode}`);
419 | if (activeSwitch && ledMode !== 'Manual"') { // 'Auto' or 'Sleep'
420 | activeSwitch
421 | .updateCharacteristic(hap.Characteristic.On, true);
422 | }
423 | if (activeSwitch && ledMode === 'Manual') { // if 'Manual' also set 'Brightness'
424 | activeSwitch
425 | .updateCharacteristic(hap.Characteristic.On, true);
426 | activeSwitch // only set brightness on active switch
427 | .updateCharacteristic(hap.Characteristic.Brightness, accessory.context.ledBrightness);
428 | }
429 | } else { // set switch to 'off
430 | const inactiveSwitch = accessory.getService(`${accessory.context.name}: ${ledMode}`);
431 | if (inactiveSwitch) {
432 | inactiveSwitch
433 | .updateCharacteristic(hap.Characteristic.On, false);
434 | }
435 | }
436 | });
437 | break;
438 | default:
439 | this.log.error('Error: Accessory not of type IAQ, Display or LED.');
440 | break;
441 | }
442 | });
443 |
444 | // start Device Air and Local data collection according to 'polling_interval' settings
445 | if(this.config.logging){
446 | this.log.info('--- Starting Cloud and Local data collection ---');
447 | }
448 |
449 | setInterval(() => {
450 | this.accessories.forEach(async (accessory): Promise => { // only applies to IAQ accessory type
451 | if (accessory.context.accType === 'IAQ') {
452 | if (this.config.logging) {
453 | this.log.info(`[${accessory.context.serial}] Updating status...${accessory.context.deviceUUID}`);
454 | }
455 | await this.updateAirQualityData(accessory);
456 | if (accessory.context.deviceType === 'awair-omni') {
457 | await this.getBatteryStatus(accessory);
458 | }
459 | if ((accessory.context.deviceType === 'awair-omni') || (accessory.context.deviceType === 'awair-mint')) {
460 | await this.getLightLevel(accessory); // fetch averaged 'lux' value (Omni/Mint updates value every 10 seconds)
461 | }
462 | }
463 | });
464 | }, this.polling_interval * 1000);
465 |
466 | // if Omni device exists in account & detection enabled, start 30 second loop to test for Omni occupancy status
467 | if(this.omniPresent && this.config.occupancyDetection) {
468 | setInterval(() => {
469 | this.accessories.forEach(async (accessory): Promise => { // only applies to IAQ accessory type
470 | if ((accessory.context.deviceType === 'awair-omni') && (accessory.context.accType === 'IAQ')) {
471 | await this.getOccupancyStatus(accessory);
472 | }
473 | });
474 | }, 30000); // check every 30 seconds, 10 seconds is updata interval for LocalAPI data, spl_a is 'smoothed' value
475 | }
476 | }
477 |
478 | /**
479 | * Method to retrieve user info/profile from your Awair development account
480 | */
481 | async getUserInfo(): Promise {
482 | const url = `https://developer-apis.awair.is/v1/${this.userType}`;
483 | const options = {
484 | headers: {
485 | 'Authorization': `Bearer ${this.config.token}`,
486 | },
487 | validateStatus: (status: number) => status < 500, // Resolve only if the status code is less than 500
488 | };
489 |
490 | await axios.get(url, options)
491 | .then(response => {
492 | if(this.config.logging && this.config.verbose) {
493 | this.log.info(`userInfo: ${JSON.stringify(response.data)}`);
494 | }
495 | this.userTier = response.data.tier;
496 | const permissions: any[] = response.data.permissions;
497 |
498 | permissions.forEach((permission): void => {
499 | switch (permission.scope){
500 | case 'FIFTEEN_MIN':
501 | this.fifteenMin = parseFloat(permission.quota);
502 | break;
503 | case 'FIVE_MIN':
504 | this.fiveMin = parseFloat(permission.quota);
505 | break;
506 | case 'RAW':
507 | this.raw = parseFloat(permission.quota);
508 | break;
509 | case 'LATEST':
510 | this.latest = parseFloat(permission.quota);
511 | break;
512 | default:
513 | break;
514 | }
515 | });
516 |
517 | switch (this.endpoint) {
518 | case '15-min-avg': // practical minimum is 15-min or 900 seconds
519 | this.polling_interval = Math.round(this.secondsPerDay / this.fifteenMin);
520 | this.polling_interval = (this.polling_interval < 900) ? 900 : this.polling_interval;
521 | break;
522 |
523 | case '5-min-avg': // practical minimum is 5-min or 300 seconds
524 | this.polling_interval = Math.round(this.secondsPerDay / this.fiveMin);
525 | this.polling_interval = (this.polling_interval < 300) ? 300 : this.polling_interval;
526 | break;
527 |
528 | case 'raw': // minimum is (this.limit * 10 seconds) to have non repeating data
529 | this.polling_interval = Math.round(this.secondsPerDay / this.raw);
530 | if (this.userTier === 'Hobbyist') {
531 | this.polling_interval = ((this.limit * 10) < 200) ? 200 : (this.limit * 10); // 200 seconds min for 'Hobbyist'
532 | } else {
533 | this.polling_interval = ((this.limit * 10) < 60) ? 60 : (this.limit * 10); // 60 seconds min for other tiers
534 | }
535 | break;
536 |
537 | case 'latest': // latest is updated every 10 seconds on device, 300 min for "Hobbyist"
538 | this.polling_interval = Math.round(this.secondsPerDay / this.latest);
539 | if (this.userTier === 'Hobbyist') {
540 | this.polling_interval = (this.polling_interval < 300) ? 300 : this.polling_interval; // 300 seconds min for 'Hobbyist'
541 | } else {
542 | this.polling_interval = (this.polling_interval < 60) ? 60 : this.polling_interval; // 60 seconds min for other tiers
543 | }
544 | if (this.apiMethod === 'localAPI') {
545 | this.polling_interval = this.localPollingInterval;
546 | }
547 | break;
548 |
549 | default:
550 | this.log.error('getUserInfo error: Endpoint not defined.');
551 | break;
552 | }
553 | if(this.config.logging){
554 | this.log.info('getUserInfo: Completed');
555 | }
556 |
557 | })
558 | .catch(error => {
559 | if(this.config.logging){
560 | this.log.error(`getUserInfo error: ${error.toJson}`);
561 | }
562 | });
563 | return;
564 | }
565 |
566 | /**
567 | * Method to retrieve registered devices from your Awair development account
568 | */
569 | async getAwairDevices(): Promise {
570 | const url = `https://developer-apis.awair.is/v1/${this.userType}/devices`;
571 | const options = {
572 | headers: {
573 | 'Authorization': `Bearer ${this.config.token}`,
574 | },
575 | validateStatus: (status: any) => status < 500, // Resolve only if the status code is less than 500
576 | };
577 |
578 | await axios.get(url, options)
579 | .then(response => {
580 | this.devices = response.data.devices;
581 | if(this.config.logging){
582 | this.log.warn(`getAwairDevices: ${this.devices.length} devices discovered, will only add Omni, Awair-r2, Element and Mint`);
583 | }
584 | for (let i = 0; i < this.devices.length; i++) {
585 | if(!this.devices[i].macAddress.includes('70886B')) { // check if 'end user' or 'test' device
586 | const devMac = '000000000000' + this.devices[i].deviceId; // if 'test' device, create MAC based on deviceId
587 | this.devices[i].macAddress = devMac.substring(devMac.length - 12); // get last 12 characters
588 | }
589 | if(this.config.logging && this.config.verbose){
590 | this.log.info(`getAwairDevices: discovered device: [${i}] ${JSON.stringify(this.devices[i])}`);
591 | }
592 | }
593 | })
594 | .catch(error => {
595 | if(this.config.logging){
596 | this.log.error(`getAwairDevices error: ${error.toJson}`);
597 | }
598 | });
599 | return;
600 | }
601 |
602 | /**
603 | * Method to add Awair Indoor Air Quality accessory (IAQ) to Platform
604 | *
605 | * @param {object} device - Air Quality device to be added to Platform
606 | */
607 | addAirQualityAccessory(device: DeviceConfig): void {
608 | // Do not add Awair, Glow and Glow-C if present in account as these devices 'sunsetted' by Awair effective 30 November 2022
609 | if ((device.deviceType === 'awair') || (device.deviceType === 'awair-glow') || (device.deviceType === 'awair-glow-c')) {
610 | return;
611 | }
612 |
613 | if (this.config.logging) {
614 | this.log.info(`[${device.macAddress}] Initializing platform accessory ${device.name}...`);
615 | }
616 |
617 | // check if IAQ accessory exists in cache
618 | let accessory = this.accessories.find(cachedAccessory => {
619 | return ((cachedAccessory.context.deviceUUID === device.deviceUUID) && (cachedAccessory.context.accType === 'IAQ'));
620 | });
621 |
622 | // if IAQ accessory does NOT exist in cache, initialze as new
623 | if (!accessory) {
624 | const uuid = hap.uuid.generate(device.deviceUUID);
625 | if(this.config.logging){
626 | this.log.info(`Adding deviceUUID: ${device.deviceUUID}, IAQ, UUID: ${uuid}`);
627 | }
628 | accessory = new Accessory(device.name, uuid);
629 |
630 | // Using 'context' property of PlatformAccessory saves information to accessory cache
631 | accessory.context.name = device.name;
632 | accessory.context.serial = device.macAddress;
633 | accessory.context.deviceType = device.deviceType;
634 | accessory.context.deviceUUID = device.deviceUUID;
635 | accessory.context.deviceId = device.deviceId;
636 | accessory.context.accType = 'IAQ'; // Indoor Air Quality
637 |
638 | accessory.addService(hap.Service.AirQualitySensor, `${device.name} IAQ`);
639 | accessory.addService(hap.Service.TemperatureSensor, `${device.name} Temp`);
640 | accessory.addService(hap.Service.HumiditySensor, `${device.name} Humidity`);
641 |
642 | if (device.deviceType !== 'awair-mint') { // CO2 not available on Awair Mint
643 | accessory.addService(hap.Service.CarbonDioxideSensor, `${device.name} CO2`);
644 | }
645 |
646 | // If you are adding more than one service of the same type to an accessory, you need to give the service a "name" and "subtype".
647 | if (this.enableTvocPm25) {
648 | // if enabled, add VOC alert service as dummy occupancy sensor
649 | accessory.addService(hap.Service.OccupancySensor, `${device.name}: TVOC Limit`, '0');
650 |
651 | // if enabled, add PM2.5 alert service as dummy occupancy sensor
652 | accessory.addService(hap.Service.OccupancySensor, `${device.name}: PM2.5 Limit`, '1');
653 | }
654 |
655 | // Add Omni Battery and Occupancy service
656 | if (device.deviceType === 'awair-omni') {
657 | accessory.addService(hap.Service.Battery, `${device.name} Battery`);
658 | accessory.addService(hap.Service.OccupancySensor, `${device.name} Occupancy`, '2');
659 | this.omniPresent = true; // set flag for Occupancy detected loop
660 | accessory.context.occDetectedLevel = this.occDetectedLevel;
661 | accessory.context.occDetectedNotLevel = this.occDetectedNotLevel;
662 | accessory.context.minSoundLevel = this.occDetectedNotLevel;
663 | }
664 |
665 | // Add Omni and Mint Light Sensor service
666 | if (device.deviceType === 'awair-omni' || device.deviceType === 'awair-mint') {
667 | accessory.addService(hap.Service.LightSensor, `${device.name} Light`);
668 | }
669 |
670 | this.addAirQualityServices(accessory);
671 |
672 | this.addAccessoryInfo(accessory);
673 |
674 | // register the accessory
675 | this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
676 |
677 | this.accessories.push(accessory);
678 |
679 | } else { // acessory exists, using data from cache
680 | if (this.config.logging) {
681 | this.log.info(`[${device.macAddress}] ${accessory.context.deviceUUID} IAQ accessory exists, using data from cache`);
682 | }
683 | if (accessory.context.deviceType === 'awair-omni') {
684 | this.omniPresent = true; // set flag for Occupancy detected loop
685 | }
686 | // use Omni cache data unless 'occupancyRestart' enabled
687 | if ((accessory.context.deviceType === 'awair-omni') && this.config.occupancyRestart) {
688 | accessory.context.occDetectedLevel = this.occDetectedLevel;
689 | accessory.context.occDetectedNotLevel = this.occDetectedNotLevel;
690 | accessory.context.minSoundLevel = this.occDetectedNotLevel;
691 | }
692 | // make sure VOC and PM2.5 alert services are removed if disabled after previous enable
693 | if (!this.enableTvocPm25) {
694 | const vocService = accessory.getService(`${accessory.context.name}: TVOC Limit`);
695 | if (vocService) {
696 | accessory.removeService(vocService);
697 | }
698 | const pm25Service = accessory.getService(`${accessory.context.name}: PM2.5 Limit`);
699 | if (pm25Service) {
700 | accessory.removeService(pm25Service);
701 | }
702 | }
703 | }
704 | return;
705 | }
706 |
707 | /**
708 | * Method to remove no longer used Accessories (IAQ, Device or LED) from Platform
709 | *
710 | * @param {array} - array of Accessories to be removed from Platform
711 | */
712 | removeAwairAccessories(accessories: Array): void {
713 | accessories.forEach((accessory): void => {
714 | this.log.warn(`${accessory.context.name} ${accessory.context.accType} is removed from HomeBridge.`);
715 | this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
716 | this.accessories.splice(this.accessories.indexOf(accessory), 1);
717 | });
718 | return;
719 | }
720 |
721 | /**
722 | * Method to add and initialize Characteristics for each IAQ Service
723 | *
724 | * @param {object} accessory - accessory to add IAQ service based on accessory type
725 | */
726 | addAirQualityServices(accessory: PlatformAccessory): void {
727 | if (this.config.logging) {
728 | this.log.info(`[${accessory.context.serial}] Configuring IAQ Services for ${accessory.displayName}`);
729 | }
730 | accessory.on(PlatformAccessoryEvent.IDENTIFY, () => {
731 | this.log.info(`${accessory.context.name} identify requested!`);
732 | });
733 |
734 | // Air Quality Service
735 | const airQualityService = accessory.getService(`${accessory.context.name} IAQ`);
736 | if (airQualityService) {
737 | if ((accessory.context.devType === 'awair-mint') || (accessory.context.devType === 'awair-omni') ||
738 | (accessory.context.devType === 'awair-r2') || (accessory.context.devType === 'awair-element')) {
739 | airQualityService
740 | .setCharacteristic(hap.Characteristic.AirQuality, 100)
741 | .setCharacteristic(hap.Characteristic.VOCDensity, 0)
742 | .setCharacteristic(hap.Characteristic.PM2_5Density, 0);
743 | }
744 | airQualityService
745 | .getCharacteristic(hap.Characteristic.VOCDensity)
746 | .setProps({
747 | minValue: 0,
748 | maxValue: 100000,
749 | });
750 | }
751 |
752 | // Temperature Service
753 | const temperatureService = accessory.getService(`${accessory.context.name} Temp`);
754 | if (temperatureService) {
755 | temperatureService
756 | .setCharacteristic(hap.Characteristic.CurrentTemperature, 0);
757 | temperatureService
758 | .getCharacteristic(hap.Characteristic.CurrentTemperature)
759 | .setProps({
760 | minValue: -100,
761 | maxValue: 100,
762 | });
763 | }
764 |
765 | // Humidity Service
766 | const humidityService = accessory.getService(`${accessory.context.name} Humidity`);
767 | if (humidityService) {
768 | humidityService
769 | .setCharacteristic(hap.Characteristic.CurrentRelativeHumidity, 0);
770 | }
771 |
772 | // Carbon Dioxide Service
773 | if (accessory.context.devType !== 'awair-mint') {
774 | const carbonDioxideService = accessory.getService(`${accessory.context.name} CO2`);
775 | if (carbonDioxideService) {
776 | carbonDioxideService
777 | .setCharacteristic(hap.Characteristic.CarbonDioxideLevel, 0);
778 | }
779 | }
780 |
781 | // If enabled, add Total VOC and PM2.5 threshold services
782 | if (this.enableTvocPm25) {
783 | // Total VOC Threshold Service
784 | const vocService = accessory.getService(`${accessory.context.name}: TVOC Limit`);
785 | if (vocService) {
786 | vocService
787 | .setCharacteristic(hap.Characteristic.OccupancyDetected, 0); // VOC level not exceeded
788 | }
789 |
790 | // PM2.5 Threshold Service
791 | const pm25Service = accessory.getService(`${accessory.context.name}: PM2.5 Limit`);
792 | if (pm25Service) {
793 | pm25Service
794 | .setCharacteristic(hap.Characteristic.OccupancyDetected, 0); // PM2.5 level not exceeded
795 | }
796 | }
797 |
798 | // Omni & Mint Ambient Light Service
799 | if ((accessory.context.devType === 'awair-omni') || (accessory.context.devType === 'awair-mint')) {
800 | const lightLevelSensor = accessory.getService(`${accessory.context.name} Light`);
801 | if (lightLevelSensor) {
802 | lightLevelSensor
803 | .setCharacteristic(hap.Characteristic.CurrentAmbientLightLevel, 0.0001);
804 | lightLevelSensor
805 | .getCharacteristic(hap.Characteristic.CurrentAmbientLightLevel)
806 | .setProps({
807 | minValue: 0.0001, // now checked by Homebridge v1.3.x
808 | maxValue: 64000,
809 | });
810 | }
811 | }
812 |
813 | // Omni Battery Service
814 | if (accessory.context.devType === 'awair-omni') {
815 | const batteryService = accessory.getService(`${accessory.context.name} Battery`);
816 | if (batteryService) {
817 | batteryService
818 | .setCharacteristic(hap.Characteristic.BatteryLevel, 100); // 0 -> 100%
819 | batteryService
820 | .setCharacteristic(hap.Characteristic.ChargingState, 0); // NOT_CHARGING = 0, CHARGING = 1, NOT_CHARGEABLE = 2
821 | batteryService
822 | .setCharacteristic(hap.Characteristic.StatusLowBattery, 0); // Normal = 0, Low = 1
823 | }
824 | }
825 |
826 | // Omni Occupancy Sensor Service
827 | if (accessory.context.devType === 'awair-omni') {
828 | const occupancyService = accessory.getService(`${accessory.context.name} Occupancy`);
829 | if (occupancyService) {
830 | occupancyService
831 | .setCharacteristic(hap.Characteristic.OccupancyDetected, 0); // Not occupied
832 | }
833 | }
834 |
835 | if(this.config.logging) {
836 | this.log.info(`[${accessory.context.serial}] addAirQualityServices completed`);
837 | }
838 | return;
839 | }
840 |
841 | /**
842 | * Method to add Accessory Information to Accessory (IAQ, Display, LED)
843 | *
844 | * @param {object} accessory - accessory to which accessory information is added to
845 | */
846 | addAccessoryInfo(accessory: PlatformAccessory): void {
847 | const accInfo = accessory.getService(hap.Service.AccessoryInformation);
848 | if (accInfo) {
849 | accInfo
850 | .updateCharacteristic(hap.Characteristic.Manufacturer, this.manufacturer);
851 | accInfo
852 | .updateCharacteristic(hap.Characteristic.Model, accessory.context.deviceType);
853 | accInfo
854 | .updateCharacteristic(hap.Characteristic.SerialNumber, accessory.context.serial);
855 | accInfo
856 | .updateCharacteristic(hap.Characteristic.FirmwareRevision, packageJSON.version);
857 | }
858 | return;
859 | }
860 |
861 | async updateAirQualityData(accessory: PlatformAccessory): Promise {
862 | if (this.apiMethod === 'cloudAPI') {
863 | await this.updateCloudAirQualityData(accessory);
864 | } else {
865 | await this.updateLocalAirQualityData(accessory);
866 | }
867 | }
868 |
869 | /**
870 | * Method to update Awair IAQ data using CloudAPI
871 | *
872 | * @param {object} accessory - accessory to be updated
873 | */
874 | async updateCloudAirQualityData(accessory: PlatformAccessory): Promise {
875 | // Update air quality data for accessory of deviceId
876 | // eslint-disable-next-line max-len
877 | const url = `https://developer-apis.awair.is/v1/${this.userType}/devices/${accessory.context.deviceType}/${accessory.context.deviceId}/air-data/${this.endpoint}?limit=${this.limit}&desc=true`;
878 | const options = {
879 | headers: {
880 | 'Authorization': `Bearer ${this.config.token}`,
881 | },
882 | validateStatus: (status: any) => status < 500, // Resolve only if the status code is less than 500
883 | };
884 |
885 | await axios.get(url, options)
886 | .then(response => {
887 | const data: any[] = response.data.data;
888 | if(this.config.logging && this.config.verbose){
889 | this.log.info(`[${accessory.context.serial}] updateAirQualityData: ${JSON.stringify(response.data.data)}`);
890 | }
891 |
892 | // compute time weighted average for each sensor's data
893 | const sensors: any = data
894 | .map(sensor => sensor.sensors) // create sensors data array of length 'this.limit'
895 | .reduce((a, b) => a.concat(b)) // flatten array of sensors (which is an array) to single-level array
896 | .reduce((a: any, b: any) => {
897 | a[b.comp] = a[b.comp] ? 0.5*(a[b.comp] + b.value) : b.value;
898 | return a; // return time weighted average
899 | }, []); // pass empty array as initial value
900 |
901 | // determine average Awair score over data samples
902 | const score = data.reduce((a, b) => a + b.score, 0) / data.length;
903 |
904 | const airQualityService = accessory.getService(`${accessory.context.name} IAQ`);
905 | if (airQualityService) {
906 | if (this.airQualityMethod === 'awair-aqi') {
907 | airQualityService
908 | .updateCharacteristic(hap.Characteristic.AirQuality, this.convertAwairAqi(accessory, sensors));
909 | } else if (this.airQualityMethod === 'awair-pm') {
910 | airQualityService
911 | .updateCharacteristic(hap.Characteristic.AirQuality, this.convertAwairPm(accessory, sensors)); // pass response data
912 | } else if ((this.airQualityMethod === 'nowcast-aqi')) {
913 | airQualityService
914 | .updateCharacteristic(hap.Characteristic.AirQuality, this.convertNowcastAqi(accessory, data)); // pass response data
915 | } else if (this.airQualityMethod === 'awair-score') {
916 | airQualityService
917 | .updateCharacteristic(hap.Characteristic.AirQuality, this.convertScore(accessory, score));
918 | } else {
919 | airQualityService
920 | .updateCharacteristic(hap.Characteristic.AirQuality, this.convertScore(accessory, score));
921 | }
922 |
923 | // Add new Awair descriptor to Homebridge tile as part of device name.
924 | // eslint-disable-next-line max-len
925 | if (this.airQualityMethod === 'awair-score' && ((accessory.context.deviceType === 'awair-element') || (accessory.context.deviceType === 'awair-omni'))) {
926 | airQualityService
927 | // eslint-disable-next-line max-len
928 | .updateCharacteristic(hap.Characteristic.Name, accessory.context.name + ' ' + this.awairScore[this.convertScore(accessory, score)] );
929 | }
930 |
931 | const temp: number = sensors.temp;
932 | const atmos = 1;
933 |
934 | for (const sensor in sensors) {
935 | switch (sensor) {
936 | case 'temp': // Temperature (C)
937 | const temperatureService = accessory.getService(`${accessory.context.name} Temp`);
938 | if (temperatureService) {
939 | temperatureService
940 | .updateCharacteristic(hap.Characteristic.CurrentTemperature, parseFloat(sensors[sensor]));
941 | }
942 | break;
943 |
944 | case 'humid': // Humidity (%)
945 | const humidityService = accessory.getService(`${accessory.context.name} Humidity`);
946 | if (humidityService) {
947 | humidityService
948 | .updateCharacteristic(hap.Characteristic.CurrentRelativeHumidity, parseFloat(sensors[sensor]));
949 | }
950 | break;
951 |
952 | case 'co2': // Carbon Dioxide (ppm)
953 | const carbonDioxideService = accessory.getService(`${accessory.context.name} CO2`);
954 | const co2 = sensors[sensor];
955 | let co2Detected: any;
956 |
957 | if (carbonDioxideService) {
958 | const co2Before = carbonDioxideService.getCharacteristic(hap.Characteristic.CarbonDioxideDetected).value;
959 |
960 | // Logic to determine if Carbon Dioxide should change in Detected state
961 | carbonDioxideService
962 | .updateCharacteristic(hap.Characteristic.CarbonDioxideLevel, parseFloat(co2));
963 | if (co2 >= this.carbonDioxideThreshold) {
964 | // CO2 HIGH
965 | co2Detected = 1;
966 | if(this.config.logging){
967 | this.log.warn(`[${accessory.context.serial}] CO2 HIGH: ${co2} > ${this.carbonDioxideThreshold}`);
968 | }
969 | } else if (co2 <= this.carbonDioxideThresholdOff) {
970 | // CO2 LOW
971 | co2Detected = 0;
972 | if(this.config.logging){
973 | this.log.warn(`[${accessory.context.serial}] CO2 NORMAL: ${co2} < ${this.carbonDioxideThresholdOff}`);
974 | }
975 | } else if ((co2 > this.carbonDioxideThresholdOff) && (co2 < this.carbonDioxideThreshold)) {
976 | // CO2 inbetween, no change
977 | if(this.config.logging){
978 | // eslint-disable-next-line max-len
979 | this.log.warn(`[${accessory.context.serial}] CO2 INBETWEEN: ${this.carbonDioxideThreshold} > ${co2} > ${this.carbonDioxideThresholdOff}`);
980 | }
981 | co2Detected = co2Before;
982 | } else {
983 | // threshold NOT set
984 | co2Detected = 0;
985 | if(this.config.logging){
986 | this.log.info(`[${accessory.context.serial}] CO2: ${co2}`);
987 | }
988 | }
989 |
990 | // Prevent sending a Carbon Dioxide detected update if one has not occured
991 | if ((co2Before === 0) && (co2Detected === 0)) {
992 | // CO2 low already, don't update
993 | } else if ((co2Before === 0) && (co2Detected === 1)) {
994 | // CO2 low to high, update
995 | carbonDioxideService
996 | .updateCharacteristic(hap.Characteristic.CarbonDioxideDetected, co2Detected);
997 | if(this.config.logging){
998 | this.log.warn(`[${accessory.context.serial}] Carbon Dioxide low to high.`);
999 | }
1000 | } else if ((co2Before === 1) && (co2Detected === 1)) {
1001 | // CO2 already high, don't update
1002 | } else if ((co2Before === 1) && (co2Detected === 0)) {
1003 | // CO2 high to low, update
1004 | carbonDioxideService
1005 | .updateCharacteristic(hap.Characteristic.CarbonDioxideDetected, co2Detected);
1006 | if(this.config.logging){
1007 | this.log.warn(`[${accessory.context.serial}] Carbon Dioxide high to low.`);
1008 | } else {
1009 | // CO2 unknown...
1010 | if(this.config.logging){
1011 | this.log.warn(`[${accessory.context.serial}] Carbon Dioxide state unknown.`);
1012 | }
1013 | }
1014 | }
1015 | }
1016 | break;
1017 |
1018 | case 'voc':
1019 | const voc = parseFloat(sensors[sensor]);
1020 | let tvoc = this.convertChemicals( accessory, voc, atmos, temp );
1021 |
1022 | if (tvoc > 100000) {
1023 | tvoc = 100000;
1024 | this.log.warn(`[${accessory.context.serial}] tvoc > 100000, setting to 100000`);
1025 | }
1026 |
1027 | if(this.config.logging){
1028 | this.log.info(`[${accessory.context.serial}] VOC: (${voc} ppb) => TVOC: (${tvoc} ug/m^3)`);
1029 | }
1030 | airQualityService
1031 | .updateCharacteristic(hap.Characteristic.VOCDensity, tvoc);
1032 |
1033 | // If enabled, set or clear TVOC Limit flag based on Threshold levels
1034 | if (this.enableTvocPm25) {
1035 | const vocService = accessory.getService(`${accessory.context.name}: TVOC Limit`);
1036 | if (vocService) {
1037 | // get current tvocLimit state
1038 | let tvocLimit: any = vocService.getCharacteristic(hap.Characteristic.OccupancyDetected).value;
1039 |
1040 | if ((tvoc >= this.tvocThreshold) && (tvocLimit !== 1)) { // low -> high
1041 | tvocLimit = 1;
1042 | } else if ((tvoc <= this.tvocThresholdOff) && (tvocLimit !== 0)) { // high -> low
1043 | tvocLimit = 0;
1044 | } else if ((tvoc > this.tvocThresholdOff) && (tvoc < this.tvocThreshold)){
1045 | // TVOC inbetween, no change
1046 | }
1047 |
1048 | if (this.config.logging) {
1049 | this.log.info(`[${accessory.context.serial}] tvocLimit: ${tvocLimit}`);
1050 | }
1051 | vocService
1052 | .updateCharacteristic(hap.Characteristic.OccupancyDetected, tvocLimit);
1053 | }
1054 | }
1055 | break;
1056 |
1057 | case 'pm25': // PM2.5 (ug/m^3)
1058 | const pm25 = parseFloat(sensors[sensor]);
1059 | if(this.config.logging){
1060 | this.log.info(`[${accessory.context.serial}] PM2.5: ${pm25} ug/m^3)`);
1061 | }
1062 | airQualityService
1063 | .updateCharacteristic(hap.Characteristic.PM2_5Density, pm25);
1064 |
1065 | // If enabled, set or clear PM2.5 limit flag based on Threshold levels
1066 | if (this.enableTvocPm25) {
1067 | const pm25Service = accessory.getService(`${accessory.context.name}: PM2.5 Limit`);
1068 | if (pm25Service) {
1069 | // get current pm25Limit state
1070 | let pm25Limit: any = pm25Service.getCharacteristic(hap.Characteristic.OccupancyDetected).value;
1071 |
1072 | if ((pm25 >= this.pm25Threshold) && (pm25Limit !== 1)) { // low -> high
1073 | pm25Limit = 1;
1074 | } else if ((pm25 <= this.pm25ThresholdOff) && (pm25Limit !== 0)) { // high -> low
1075 | pm25Limit = 0;
1076 | } else if ((pm25 > this.pm25ThresholdOff) && (pm25 < this.pm25Threshold)){
1077 | // PM2.5 inbetween, no change
1078 | }
1079 |
1080 | if (this.config.logging) {
1081 | this.log.info(`[${accessory.context.serial}] pm25Limit: ${pm25Limit}`);
1082 | }
1083 | pm25Service
1084 | .updateCharacteristic(hap.Characteristic.OccupancyDetected, pm25Limit);
1085 | }
1086 | }
1087 | break;
1088 |
1089 | case 'pm10': // PM10 (ug/m^3)
1090 | airQualityService
1091 | .updateCharacteristic(hap.Characteristic.PM10Density, parseFloat(sensors[sensor]));
1092 | break;
1093 |
1094 | case 'dust': // Dust (ug/m^3)
1095 | airQualityService
1096 | .updateCharacteristic(hap.Characteristic.PM10Density, parseFloat(sensors[sensor]));
1097 | break;
1098 |
1099 | default:
1100 | if(this.config.logging){
1101 | // eslint-disable-next-line max-len
1102 | this.log.info(`[${accessory.context.serial}] updateAirQualityData ignoring ${JSON.stringify(sensor)}: ${parseFloat(sensors[sensor])}`);
1103 | }
1104 | break;
1105 | }
1106 | }
1107 | }
1108 | })
1109 | .catch(error => {
1110 | if(this.config.logging){
1111 | this.log.error(`[${accessory.context.serial}] updateAirQualityData error: ${error.toJson}`);
1112 | }
1113 | });
1114 | return;
1115 | }
1116 |
1117 | /**
1118 | * Method to get air quality data using LocalAPI
1119 | *
1120 | * * @param {object} accessory - accessory to be updated
1121 | */
1122 |
1123 | async updateLocalAirQualityData(accessory: PlatformAccessory): Promise {
1124 | // Update air quality data for accessory of deviceId
1125 | const url = `http://${accessory.context.deviceType.substr(0, 10)}-${accessory.context.serial.substr(6)}.local/air-data/latest`;
1126 |
1127 | await axios.get(url)
1128 | .then(response => {
1129 | const data: any = response.data;
1130 | const score: number = response.data.score;
1131 |
1132 | const airQualityService = accessory.getService(`${accessory.context.name} IAQ`);
1133 | if (airQualityService) {
1134 | if (this.airQualityMethod === 'awair-aqi') {
1135 | airQualityService
1136 | .updateCharacteristic(hap.Characteristic.AirQuality, this.convertAwairAqi(accessory, data));
1137 | } else if (this.airQualityMethod === 'awair-pm') {
1138 | airQualityService
1139 | .updateCharacteristic(hap.Characteristic.AirQuality, this.convertAwairPm(accessory, data)); // pass response data
1140 | } else if (this.airQualityMethod === 'awair-score') {
1141 | airQualityService
1142 | .updateCharacteristic(hap.Characteristic.AirQuality, this.convertScore(accessory, score));
1143 | } else {
1144 | airQualityService
1145 | .updateCharacteristic(hap.Characteristic.AirQuality, this.convertScore(accessory, score));
1146 | }
1147 |
1148 | // Add new Awair descriptor to Homebridge tile as part of device name.
1149 | // eslint-disable-next-line max-len
1150 | if (this.airQualityMethod === 'awair-score' && ((accessory.context.deviceType === 'awair-element') || (accessory.context.deviceType === 'awair-omni'))) {
1151 | airQualityService
1152 | // eslint-disable-next-line max-len
1153 | .updateCharacteristic(hap.Characteristic.Name, accessory.context.name + ' ' + this.awairScore[this.convertScore(accessory, score)] );
1154 | }
1155 |
1156 | const temp: number = data.temp;
1157 | const atmos = 1;
1158 |
1159 | for (const sensor in data) {
1160 | switch (sensor) {
1161 | case 'temp': // Temperature (C)
1162 | const temperatureService = accessory.getService(`${accessory.context.name} Temp`);
1163 | if (temperatureService) {
1164 | temperatureService
1165 | .updateCharacteristic(hap.Characteristic.CurrentTemperature, parseFloat(data[sensor]));
1166 | }
1167 | break;
1168 |
1169 | case 'humid': // Humidity (%)
1170 | const humidityService = accessory.getService(`${accessory.context.name} Humidity`);
1171 | if (humidityService) {
1172 | humidityService
1173 | .updateCharacteristic(hap.Characteristic.CurrentRelativeHumidity, parseFloat(data[sensor]));
1174 | }
1175 | break;
1176 |
1177 | case 'co2': // Carbon Dioxide (ppm)
1178 | const carbonDioxideService = accessory.getService(`${accessory.context.name} CO2`);
1179 | const co2 = data[sensor];
1180 | let co2Detected: any;
1181 |
1182 | if (carbonDioxideService) {
1183 | const co2Before = carbonDioxideService.getCharacteristic(hap.Characteristic.CarbonDioxideDetected).value;
1184 |
1185 | // Logic to determine if Carbon Dioxide should change in Detected state
1186 | carbonDioxideService
1187 | .updateCharacteristic(hap.Characteristic.CarbonDioxideLevel, parseFloat(co2));
1188 | if (co2 >= this.carbonDioxideThreshold) {
1189 | // CO2 HIGH
1190 | co2Detected = 1;
1191 | if(this.config.logging){
1192 | this.log.warn(`[${accessory.context.serial}] CO2 HIGH: ${co2} > ${this.carbonDioxideThreshold}`);
1193 | }
1194 | } else if (co2 <= this.carbonDioxideThresholdOff) {
1195 | // CO2 LOW
1196 | co2Detected = 0;
1197 | if(this.config.logging){
1198 | this.log.warn(`[${accessory.context.serial}] CO2 NORMAL: ${co2} < ${this.carbonDioxideThresholdOff}`);
1199 | }
1200 | } else if ((co2 > this.carbonDioxideThresholdOff) && (co2 < this.carbonDioxideThreshold)) {
1201 | // CO2 inbetween, no change
1202 | if(this.config.logging){
1203 | // eslint-disable-next-line max-len
1204 | this.log.warn(`[${accessory.context.serial}] CO2 INBETWEEN: ${this.carbonDioxideThreshold} > ${co2} > ${this.carbonDioxideThresholdOff}`);
1205 | }
1206 | co2Detected = co2Before;
1207 | } else {
1208 | // threshold NOT set
1209 | co2Detected = 0;
1210 | if(this.config.logging){
1211 | this.log.info(`[${accessory.context.serial}] CO2: ${co2}`);
1212 | }
1213 | }
1214 |
1215 | // Prevent sending a Carbon Dioxide detected update if one has not occured
1216 | if ((co2Before === 0) && (co2Detected === 0)) {
1217 | // CO2 low already, don't update
1218 | } else if ((co2Before === 0) && (co2Detected === 1)) {
1219 | // CO2 low to high, update
1220 | carbonDioxideService
1221 | .updateCharacteristic(hap.Characteristic.CarbonDioxideDetected, co2Detected);
1222 | if(this.config.logging){
1223 | this.log.warn(`[${accessory.context.serial}] Carbon Dioxide low to high.`);
1224 | }
1225 | } else if ((co2Before === 1) && (co2Detected === 1)) {
1226 | // CO2 already high, don't update
1227 | } else if ((co2Before === 1) && (co2Detected === 0)) {
1228 | // CO2 high to low, update
1229 | carbonDioxideService
1230 | .updateCharacteristic(hap.Characteristic.CarbonDioxideDetected, co2Detected);
1231 | if(this.config.logging){
1232 | this.log.warn(`[${accessory.context.serial}] Carbon Dioxide high to low.`);
1233 | } else {
1234 | // CO2 unknown...
1235 | if(this.config.logging){
1236 | this.log.warn(`[${accessory.context.serial}] Carbon Dioxide state unknown.`);
1237 | }
1238 | }
1239 | }
1240 | }
1241 | break;
1242 |
1243 | case 'voc':
1244 | const voc = parseFloat(data[sensor]);
1245 | let tvoc = this.convertChemicals( accessory, voc, atmos, temp );
1246 |
1247 | if (tvoc > 100000) {
1248 | tvoc = 100000;
1249 | this.log.warn(`[${accessory.context.serial}] tvoc > 100000, setting to 100000`);
1250 | }
1251 |
1252 | if(this.config.logging){
1253 | this.log.info(`[${accessory.context.serial}] VOC: (${voc} ppb) => TVOC: (${tvoc} ug/m^3)`);
1254 | }
1255 | airQualityService
1256 | .updateCharacteristic(hap.Characteristic.VOCDensity, tvoc);
1257 |
1258 | // If enabled, set or clear TVOC Limit flag based on Threshold levels
1259 | if (this.enableTvocPm25) {
1260 | const vocService = accessory.getService(`${accessory.context.name}: TVOC Limit`);
1261 | if (vocService) {
1262 | // get current tvocLimit state
1263 | let tvocLimit: any = vocService.getCharacteristic(hap.Characteristic.OccupancyDetected).value;
1264 |
1265 | if ((tvoc >= this.tvocThreshold) && (tvocLimit !== 1)) { // low -> high
1266 | tvocLimit = 1;
1267 | } else if ((tvoc <= this.tvocThresholdOff) && (tvocLimit !== 0)) { // high -> low
1268 | tvocLimit = 0;
1269 | } else if ((tvoc > this.tvocThresholdOff) && (tvoc < this.tvocThreshold)){
1270 | // TVOC inbetween, no change
1271 | }
1272 |
1273 | if (this.config.logging) {
1274 | this.log.info(`[${accessory.context.serial}] tvocLimit: ${tvocLimit}`);
1275 | }
1276 | vocService
1277 | .updateCharacteristic(hap.Characteristic.OccupancyDetected, tvocLimit);
1278 | }
1279 | }
1280 | break;
1281 |
1282 | case 'pm25': // PM2.5 (ug/m^3)
1283 | const pm25 = parseFloat(data[sensor]);
1284 | if(this.config.logging){
1285 | this.log.info(`[${accessory.context.serial}] PM2.5: ${pm25} ug/m^3)`);
1286 | }
1287 | airQualityService
1288 | .updateCharacteristic(hap.Characteristic.PM2_5Density, pm25);
1289 |
1290 | // If enabled, set or clear PM2.5 limit flag based on Threshold levels
1291 | if (this.enableTvocPm25) {
1292 | const pm25Service = accessory.getService(`${accessory.context.name}: PM2.5 Limit`);
1293 | if (pm25Service) {
1294 | // get current pm25Limit state
1295 | let pm25Limit: any = pm25Service.getCharacteristic(hap.Characteristic.OccupancyDetected).value;
1296 |
1297 | if ((pm25 >= this.pm25Threshold) && (pm25Limit !== 1)) { // low -> high
1298 | pm25Limit = 1;
1299 | } else if ((pm25 <= this.pm25ThresholdOff) && (pm25Limit !== 0)) { // high -> low
1300 | pm25Limit = 0;
1301 | } else if ((pm25 > this.pm25ThresholdOff) && (pm25 < this.pm25Threshold)){
1302 | // PM2.5 inbetween, no change
1303 | }
1304 |
1305 | if (this.config.logging) {
1306 | this.log.info(`[${accessory.context.serial}] pm25Limit: ${pm25Limit}`);
1307 | }
1308 | pm25Service
1309 | .updateCharacteristic(hap.Characteristic.OccupancyDetected, pm25Limit);
1310 | }
1311 | }
1312 | break;
1313 |
1314 | case 'pm10_est': // PM10 (ug/m^3), Element and R2
1315 | airQualityService
1316 | .updateCharacteristic(hap.Characteristic.PM10Density, parseFloat(data[sensor]));
1317 | break;
1318 |
1319 | default:
1320 | if(this.config.logging){
1321 | // eslint-disable-next-line max-len
1322 | this.log.info(`[${accessory.context.serial}] updateAirQualityData ignoring ${JSON.stringify(sensor)}: ${parseFloat(data[sensor])}`);
1323 | }
1324 | break;
1325 | }
1326 | }
1327 | }
1328 | })
1329 | .catch(error => {
1330 | if(this.config.logging){
1331 | this.log.error(`[${accessory.context.serial}] getLocalAirQualityData error: ${error}`);
1332 | }
1333 | });
1334 | return;
1335 | }
1336 |
1337 | /**
1338 | * Method to get Omni battery level and charging status using LocalAPI (must enable in Awair App, firmware v1.3.0 and below)
1339 | *
1340 | * @param {object} accessory - accessory to obtain battery status
1341 | */
1342 | async getBatteryStatus(accessory: PlatformAccessory): Promise {
1343 | const url = `http://${accessory.context.deviceType}-${accessory.context.serial.substr(6)}.local/settings/config/data`;
1344 |
1345 | await axios.get(url)
1346 | .then(response => {
1347 | // eslint-disable-next-line quotes
1348 | const powerStatus = response.data["power-status"];
1349 | const batteryLevel: number = powerStatus.battery;
1350 | const batteryPlugged: boolean = powerStatus.plugged;
1351 | const lowBattery: boolean = (batteryLevel < 30) ? true : false;
1352 |
1353 | if(this.config.logging && this.config.verbose) {
1354 | // eslint-disable-next-line max-len
1355 | this.log.info(`[${accessory.context.serial}] batteryLevel: ${batteryLevel} batteryPlugged: ${batteryPlugged} lowBattery: ${lowBattery}`);
1356 | }
1357 |
1358 | const batteryService = accessory.getService(`${accessory.context.name} Battery`);
1359 |
1360 | if (batteryService) {
1361 | batteryService
1362 | .updateCharacteristic(hap.Characteristic.BatteryLevel, batteryLevel); // 0 -> 100%
1363 | batteryService
1364 | .updateCharacteristic(hap.Characteristic.ChargingState, batteryPlugged); // NOT_CHARGING=0, CHARGING=1
1365 | batteryService
1366 | .updateCharacteristic(hap.Characteristic.StatusLowBattery, lowBattery); // <30%
1367 | }
1368 | })
1369 | .catch(error => {
1370 | if(this.config.logging){
1371 | this.log.error(`[${accessory.context.serial}] getBatteryStatus error: ${error}`);
1372 | }
1373 | });
1374 | return;
1375 | }
1376 |
1377 | /**
1378 | * Method to get Omni Occupancy Status using spl_a level via LocalAPI
1379 | *
1380 | * @param {object} accessory - accessory to obtain occupancy status
1381 | */
1382 | async getOccupancyStatus(accessory: PlatformAccessory): Promise {
1383 | const url = `http://${accessory.context.deviceType}-${accessory.context.serial.substr(6)}.local/air-data/latest`;
1384 |
1385 | await axios.get(url)
1386 | .then(response => {
1387 | const omniSpl_a: number = response.data.spl_a;
1388 | if(this.config.logging && this.config.verbose) {
1389 | this.log.info(`[${accessory.context.serial}] spl_a: ${omniSpl_a}`);
1390 | }
1391 |
1392 | if(omniSpl_a > 48.0 && omniSpl_a < accessory.context.minSoundLevel) { // Omni ambient sound level range 48 - 90dBA
1393 | accessory.context.minSoundLevel = omniSpl_a;
1394 | accessory.context.occDetectedLevel = accessory.context.minSoundLevel + this.occupancyOffset + 0.5; // dBA
1395 | accessory.context.occDetectedNotLevel = accessory.context.minSoundLevel + this.occupancyOffset; // dBA
1396 | if(this.config.logging && this.config.verbose) {
1397 | // eslint-disable-next-line max-len
1398 | this.log.info(`[${accessory.context.serial}] min spl_a: ${omniSpl_a}dBA -> notDetectedLevel: ${accessory.context.occDetectedNotLevel}dBA, DetectedLevel: ${accessory.context.occDetectedLevel}dBA`);
1399 | }
1400 | }
1401 |
1402 | const occupancyService = accessory.getService(`${accessory.context.name} Occupancy`);
1403 |
1404 | if (occupancyService) {
1405 | // get current Occupancy state
1406 | let occupancyStatus: any = occupancyService.getCharacteristic(hap.Characteristic.OccupancyDetected).value;
1407 |
1408 | if (omniSpl_a >= accessory.context.occDetectedLevel) {
1409 | // occupancy detected
1410 | occupancyStatus = 1;
1411 | if(this.config.logging){
1412 | this.log.info(`[${accessory.context.serial}] Occupied: ${omniSpl_a}dBA > ${accessory.context.occDetectedLevel}dBA`);
1413 | }
1414 | } else if (omniSpl_a <= accessory.context.occDetectedNotLevel) {
1415 | // unoccupied
1416 | occupancyStatus = 0;
1417 | if(this.config.logging){
1418 | this.log.info(`[${accessory.context.serial}] Not Occupied: ${omniSpl_a}dBA < ${accessory.context.occDetectedNotLevel}dBA`);
1419 | }
1420 | } else if ((omniSpl_a > accessory.context.occDetectedNotLevel) && (omniSpl_a < accessory.context.occDetectedLevel)) {
1421 | // inbetween ... no change, use current state
1422 | if(this.config.logging){
1423 | // eslint-disable-next-line max-len
1424 | this.log.info(`[${accessory.context.serial}] Occupancy Inbetween: ${accessory.context.occDetectedNotLevel}dBA < ${omniSpl_a} < ${accessory.context.occDetectedLevel}dBA`);
1425 | }
1426 | }
1427 | occupancyService
1428 | .updateCharacteristic(hap.Characteristic.OccupancyDetected, occupancyStatus);
1429 | }
1430 | })
1431 | .catch(error => {
1432 | if(this.config.logging){
1433 | this.log.error(`[${accessory.context.serial}] getOccupancyStatus error: ${error}`);
1434 | }
1435 | });
1436 | return;
1437 | }
1438 |
1439 | /**
1440 | * Method to get Omni & Mint light level in lux using LocalAPI
1441 | *
1442 | * * @param {object} accessory - accessory to obtain light level status
1443 | */
1444 | async getLightLevel(accessory: PlatformAccessory): Promise {
1445 | const url = `http://${accessory.context.deviceType}-${accessory.context.serial.substr(6)}.local/air-data/latest`;
1446 |
1447 | await axios.get(url)
1448 | .then(response => {
1449 | const omniLux = (response.data.lux < 0.0001) ? 0.0001 : response.data.lux; // lux is 'latest' value averaged over 10 seconds
1450 | if(this.config.logging && this.config.verbose) {
1451 | this.log.info(`[${accessory.context.serial}] lux: ${omniLux}`);
1452 | }
1453 |
1454 | const lightLevelSensor = accessory.getService(hap.Service.LightSensor);
1455 | if (lightLevelSensor) {
1456 | lightLevelSensor
1457 | .updateCharacteristic(hap.Characteristic.CurrentAmbientLightLevel, omniLux);
1458 | }
1459 | })
1460 | .catch(error => {
1461 | if(this.config.logging){
1462 | this.log.error(`[${accessory.context.serial}] getLightLevel error: ${error}`);
1463 | }
1464 | });
1465 | return;
1466 | }
1467 |
1468 | /**
1469 | * Method to add Display Mode Accessory for Omni, Awair-r2 and Element
1470 | *
1471 | * @param {object} accessory - accessory to obtain occupancy status
1472 | */
1473 | addDisplayModeAccessory(device: DeviceConfig): void {
1474 | if (this.config.logging) {
1475 | this.log.info(`[${device.macAddress}] Initializing Display Mode accessory for ${device.deviceUUID}...`);
1476 | }
1477 |
1478 | // check if Awair device 'displayMode' accessory exists
1479 | let accessory = this.accessories.find(cachedAccessory => {
1480 | return ((cachedAccessory.context.deviceUUID === device.deviceUUID) && (cachedAccessory.context.accType === 'Display'));
1481 | });
1482 |
1483 | // if displayMode accessory does not exist in cache, initialze as new
1484 | if (!accessory) {
1485 | const uuid = hap.uuid.generate(`${device.deviceUUID}_Display`); // secondary UUID for Display Mode control
1486 | accessory = new Accessory(`${device.name} Display Mode`, uuid);
1487 |
1488 | // Using 'context' property of PlatformAccessory saves information to accessory cache
1489 | accessory.context.name = device.name;
1490 | accessory.context.serial = device.macAddress;
1491 | accessory.context.deviceType = device.deviceType;
1492 | accessory.context.deviceUUID = device.deviceUUID;
1493 | accessory.context.deviceId = device.deviceId;
1494 | accessory.context.accType = 'Display'; // Display Mode accessory type
1495 | accessory.context.displayMode = 'Score'; // default for new accessory, initialize Display Mode to 'Score'
1496 |
1497 | // If you are adding more than one service of the same type to an accessory, you need to give the service a "name" and "subtype".
1498 | accessory.addService(hap.Service.Switch, `${device.name}: Score`, '0'); // displays in HomeKit at first switch position
1499 | accessory.addService(hap.Service.Switch, `${device.name}: Temp`, '1'); // remaining switches displayed alphabetically
1500 | accessory.addService(hap.Service.Switch, `${device.name}: Humid`, '2');
1501 | accessory.addService(hap.Service.Switch, `${device.name}: CO2`, '3');
1502 | accessory.addService(hap.Service.Switch, `${device.name}: VOC`, '4');
1503 | accessory.addService(hap.Service.Switch, `${device.name}: PM25`, '5');
1504 | accessory.addService(hap.Service.Switch, `${device.name}: Clock`, '6');
1505 |
1506 | this.addDisplayModeServices(accessory);
1507 |
1508 | // register the accessory
1509 | this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
1510 |
1511 | this.accessories.push(accessory);
1512 |
1513 | } else { // acessory exists, use data from cache
1514 | if (this.config.logging) {
1515 | this.log.warn(`[${device.macAddress}] ${accessory.context.deviceUUID} Display Mode accessory exists, using data from cache`);
1516 | }
1517 | }
1518 | return;
1519 | }
1520 |
1521 | /**
1522 | * Method to add Characteristics to each Device Mode Service for Omni, Awair-r2 and Element
1523 | *
1524 | * @param {object} accessory - accessory to add display mode services
1525 | */
1526 | addDisplayModeServices(accessory: PlatformAccessory): void {
1527 | if (this.config.logging) {
1528 | this.log.info(`[${accessory.context.serial}] Configuring Display Mode Services for ${accessory.context.deviceUUID}`);
1529 | }
1530 | accessory.on(PlatformAccessoryEvent.IDENTIFY, () => {
1531 | this.log.info(`${accessory.context.name} identify requested!`);
1532 | });
1533 |
1534 | this.displayModes.forEach((displayMode): void => {
1535 | accessory.getService(`${accessory.context.name}: ${displayMode}`)!.getCharacteristic(hap.Characteristic.On)
1536 | .on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
1537 | this.changeDisplayMode(accessory, displayMode);
1538 | callback();
1539 | });
1540 | });
1541 |
1542 | if(this.config.logging) {
1543 | this.log.info(`[${accessory.context.serial}] addDisplayModeServices completed for ${accessory.context.deviceUUID}`);
1544 | }
1545 | return;
1546 | }
1547 |
1548 | /**
1549 | * Method to change Display Mode for Omni, Awair-r2 and Element
1550 | */
1551 | async changeDisplayMode(accessory: PlatformAccessory, newDisplayMode: string): Promise {
1552 | const oldDisplayMode = accessory.context.displayMode; // context.displayMode is in Mixed case
1553 |
1554 | // displayMode HAS NOT changed
1555 | if (newDisplayMode === oldDisplayMode) {
1556 | const currentSwitch = accessory.getService(`${accessory.context.name}: ${oldDisplayMode}`);
1557 | setTimeout(() => { // need short delay before your can reset the switch
1558 | if (currentSwitch) {
1559 | currentSwitch
1560 | .updateCharacteristic(hap.Characteristic.On, true);
1561 | }
1562 | }, 50);
1563 | return;
1564 | }
1565 |
1566 | // displayMode HAS changed
1567 | if (newDisplayMode !== oldDisplayMode) {
1568 | if (this.config.logging) {
1569 | // eslint-disable-next-line max-len
1570 | this.log.info(`[${accessory.context.serial}] Changing Display Mode for ${accessory.context.deviceUUID} from ${oldDisplayMode} to ${newDisplayMode}`);
1571 | }
1572 |
1573 | // turn OFF old switch
1574 | const oldSwitch = accessory.getService(`${accessory.context.name}: ${oldDisplayMode}`);
1575 | if (oldSwitch) {
1576 | oldSwitch
1577 | .updateCharacteristic(hap.Characteristic.On, false);
1578 | }
1579 |
1580 | // set new Display Mode -> UPDATES accessory.context.displayMode
1581 | await this.putDisplayMode(accessory, newDisplayMode); // Mixed case
1582 |
1583 | // turn ON new switch
1584 | const newSwitch = accessory.getService(`${accessory.context.name}: ${newDisplayMode}`);
1585 | if (newSwitch) {
1586 | newSwitch
1587 | .updateCharacteristic(hap.Characteristic.On, true);
1588 | }
1589 | return;
1590 | }
1591 | }
1592 |
1593 | /**
1594 | * Method to get Display Mode for Omni, Awair-r2 and Element
1595 | */
1596 | async getDisplayMode(accessory: PlatformAccessory): Promise {
1597 | const url = `https://developer-apis.awair.is/v1/devices/${accessory.context.deviceType}/${accessory.context.deviceId}/display`;
1598 | const options = {
1599 | headers: {
1600 | 'Authorization': `Bearer ${this.config.token}`,
1601 | },
1602 | validateStatus: (status: any) => status < 500, // Resolve only if the status code is less than 500
1603 | };
1604 |
1605 | await axios.get(url, options)
1606 | .then(response => {
1607 | if (this.config.logging && this.config.verbose) {
1608 | this.log.info(`[${accessory.context.serial}] getDisplayMode ${accessory.context.deviceUUID} response: ${response.data.mode}`);
1609 | }
1610 | this.displayModes.forEach(mode => {
1611 | if (mode.toLowerCase() === response.data.mode) {
1612 | accessory.context.displayMode = mode; // 'context.displayMode' is Mixed case
1613 | }
1614 | });
1615 | })
1616 |
1617 | .catch(error => {
1618 | if(this.config.logging){
1619 | this.log.error(`[${accessory.context.serial}] getDisplayMode ${accessory.context.deviceUUID} error: ${error.toJson}`);
1620 | }
1621 | });
1622 | return;
1623 | }
1624 |
1625 | /**
1626 | * Method to set Display Mode for Omni, Awair-r2 and Element
1627 | */
1628 | async putDisplayMode(accessory: PlatformAccessory, mode: string): Promise {
1629 | const url = `https://developer-apis.awair.is/v1/devices/${accessory.context.deviceType}/${accessory.context.deviceId}/display`;
1630 | const body = {'mode': mode.toLowerCase(), 'temp_unit': this.temperatureUnits, 'clock_mode': this.timeFormat};
1631 | const options = {
1632 | headers: {
1633 | 'Authorization': `Bearer ${this.config.token}`,
1634 | },
1635 | validateStatus: (status: any) => status < 500, // Resolve only if the status code is less than 500
1636 | };
1637 |
1638 | await axios.put(url, body, options)
1639 | .then(response => {
1640 | if(this.config.logging){
1641 | // eslint-disable-next-line max-len
1642 | this.log.info(`[${accessory.context.serial}] putDisplayMode response: ${response.data.message} for ${accessory.context.deviceUUID}`);
1643 | }
1644 | })
1645 | .catch(error => {
1646 | if(this.config.logging){
1647 | this.log.error(`[${accessory.context.serial}] putDisplayMode error: ${error.toJson} for ${accessory.context.deviceUUID}`);
1648 | }
1649 | });
1650 | accessory.context.displayMode = mode; // 'context.displayMode' is Mixed case
1651 | return;
1652 | }
1653 |
1654 | /**
1655 | * Method to add LED Mode Accessory for Omni, Awair-r2 and Element
1656 | */
1657 | addLEDModeAccessory(device: DeviceConfig): void {
1658 | if (this.config.logging) {
1659 | this.log.info(`[${device.macAddress}] Initializing LED Mode accessory for ${device.deviceUUID}...`);
1660 | }
1661 |
1662 | // check if Awair device 'ledMode' accessory exists
1663 | let accessory = this.accessories.find(cachedAccessory => {
1664 | return ((cachedAccessory.context.deviceUUID === device.deviceUUID) && (cachedAccessory.context.accType === 'LED'));
1665 | });
1666 |
1667 | // if ledMode accessory does not exist in cache, initialze as new
1668 | if (!accessory) {
1669 | const uuid = hap.uuid.generate(`${device.deviceUUID}_LED`); // secondary UUID for LED Mode control
1670 | accessory = new Accessory(`${device.name} LED Mode`, uuid);
1671 |
1672 | // Using 'context' property of PlatformAccessory saves information to accessory cache
1673 | accessory.context.name = device.name;
1674 | accessory.context.serial = device.macAddress;
1675 | accessory.context.deviceType = device.deviceType;
1676 | accessory.context.deviceUUID = device.deviceUUID;
1677 | accessory.context.deviceId = device.deviceId;
1678 | accessory.context.accType = 'LED'; // LED Mode accessory type
1679 | accessory.context.ledMode = 'Auto'; // default for new accessory, initialize LED Mode to 'Auto'
1680 | accessory.context.ledBrightness = 0; // and Brightness to '0'
1681 |
1682 | // If you are adding more than one service of the same type to an accessory, you need to give the service a "name" and "subtype".
1683 | accessory.addService(hap.Service.Switch, `${device.name}: Auto`, '0'); // displays in HomeKit at first switch position
1684 | accessory.addService(hap.Service.Switch, `${device.name}: Sleep`, '1'); // remaining switches displayed alphabetically
1685 | accessory.addService(hap.Service.Lightbulb, `${device.name}: Manual`);
1686 |
1687 | this.addLEDModeServices(accessory);
1688 |
1689 | // register the accessory
1690 | this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
1691 |
1692 | this.accessories.push(accessory);
1693 |
1694 | } else { // acessory exists, use data from cache
1695 | if (this.config.logging) {
1696 | this.log.warn(`[${device.macAddress}] ${accessory.context.deviceUUID} LED Mode accessory exists, using data from cache`);
1697 | }
1698 | }
1699 | return;
1700 | }
1701 |
1702 | /**
1703 | * Method to add Characteristic to each LED Mode Accessory for for Omni, Awair-r2 and Element
1704 | */
1705 | addLEDModeServices(accessory: PlatformAccessory): void {
1706 | if (this.config.logging) {
1707 | this.log.info(`[${accessory.context.serial}] Configuring LED Mode Services for ${accessory.context.deviceUUID}`);
1708 | }
1709 | accessory.on(PlatformAccessoryEvent.IDENTIFY, () => {
1710 | this.log.info(`${accessory.context.name} identify requested!`);
1711 | });
1712 |
1713 | // Auto
1714 | accessory.getService(`${accessory.context.name}: Auto`)!.getCharacteristic(hap.Characteristic.On)
1715 | .on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
1716 | this.changeLEDMode(accessory, 'Auto', 0); // 0 is dummy brightness for Auto and Sleep
1717 | callback();
1718 | });
1719 |
1720 | // Sleep
1721 | accessory.getService(`${accessory.context.name}: Sleep`)!.getCharacteristic(hap.Characteristic.On)
1722 | .on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
1723 | this.changeLEDMode(accessory, 'Sleep', 0); // 0 is dummy brightness for Auto and Sleep
1724 | callback();
1725 | });
1726 |
1727 | // Manual
1728 | accessory.getService(`${accessory.context.name}: Manual`)!.getCharacteristic(hap.Characteristic.Brightness)
1729 | .on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
1730 | const brightness = parseInt(JSON.stringify(value));
1731 | if (this.config.logging) {
1732 | this.log.info(`[${accessory.context.serial}] LED brightness for ${accessory.context.deviceUUID} was set to: ${value}`);
1733 | }
1734 | this.changeLEDMode(accessory, 'Manual', brightness);
1735 | callback();
1736 | });
1737 |
1738 | if(this.config.logging) {
1739 | this.log.info(`[${accessory.context.serial}] addLEDModeServices completed for ${accessory.context.deviceUUID}`);
1740 | }
1741 | return;
1742 | }
1743 |
1744 | /**
1745 | * Method to change LED Mode for Omni, Awair-r2 and Element
1746 | */
1747 | async changeLEDMode(accessory: PlatformAccessory, newLEDMode: string, newBrightness: number): Promise {
1748 | const oldLEDMode = accessory.context.ledMode; // this is in mixed case
1749 |
1750 | // Auto or Sleep mode active and reselected which changes mode to OFF -> reset switch to ON, return
1751 | if (((newLEDMode === 'Auto') && (oldLEDMode === 'Auto')) || ((newLEDMode === 'Sleep') && (oldLEDMode === 'Sleep'))) {
1752 | if (this.config.logging) {
1753 | this.log.info(`[${accessory.context.serial}] No change, resetting ${oldLEDMode} switch`);
1754 | }
1755 | const currentSwitch = accessory.getService(`${accessory.context.name}: ${oldLEDMode}`);
1756 | setTimeout(() => { // need short delay before your can reset the switch
1757 | if (currentSwitch) {
1758 | currentSwitch
1759 | .updateCharacteristic(hap.Characteristic.On, true);
1760 | }
1761 | }, 50);
1762 | return;
1763 | }
1764 |
1765 | // Manual mode already active and reselected -> assume Brightness change, return
1766 | if ((newLEDMode === 'Manual') && (oldLEDMode === 'Manual')) {
1767 | if (this.config.logging) {
1768 | this.log.info(`[${accessory.context.serial}] Updating brightness for ${accessory.context.deviceUUID} to ${newBrightness}`);
1769 | }
1770 | const oldSwitch = accessory.getService(`${accessory.context.name}: ${oldLEDMode}`);
1771 | if (oldSwitch) {
1772 | oldSwitch
1773 | .updateCharacteristic(hap.Characteristic.On, true);
1774 | oldSwitch
1775 | .updateCharacteristic(hap.Characteristic.Brightness, newBrightness);
1776 | }
1777 |
1778 | // set new LED Mode -> putLEDMode updates accessory.context.ledMode & accessory.context.brightness
1779 | await this.putLEDMode(accessory, newLEDMode, newBrightness);
1780 |
1781 | return;
1782 | }
1783 |
1784 | // mode change -> update mode switches, update Awair device, return
1785 | if (newLEDMode !== oldLEDMode) {
1786 | if (this.config.logging) {
1787 | // eslint-disable-next-line max-len
1788 | this.log.info(`[${accessory.context.serial}] Changing LED Mode for ${accessory.context.deviceUUID} to ${newLEDMode}, brightness ${newBrightness}`);
1789 | }
1790 |
1791 | // turn OFF old switch
1792 | const oldSwitch = accessory.getService(`${accessory.context.name}: ${oldLEDMode}`);
1793 | if (oldSwitch) { // Auto or Sleep
1794 | oldSwitch
1795 | .updateCharacteristic(hap.Characteristic.On, false);
1796 | oldSwitch
1797 | .updateCharacteristic(hap.Characteristic.Brightness, 0);
1798 | }
1799 |
1800 | // set new LED Mode -> putLEDMode updates accessory.context.ledMode & accessory.context.brightness
1801 | await this.putLEDMode(accessory, newLEDMode, newBrightness);
1802 |
1803 | // turn ON new switch
1804 | const newSwitch = accessory.getService(`${accessory.context.name}: ${newLEDMode}`);
1805 | if (newSwitch && newLEDMode !== 'Manual') { // Auto or Sleep
1806 | newSwitch
1807 | .updateCharacteristic(hap.Characteristic.On, true);
1808 | }
1809 | if (newSwitch && newLEDMode === 'Manual') {
1810 | newSwitch
1811 | .updateCharacteristic(hap.Characteristic.On, true);
1812 | newSwitch
1813 | .updateCharacteristic(hap.Characteristic.Brightness, newBrightness);
1814 | }
1815 | return;
1816 | }
1817 | }
1818 |
1819 | /**
1820 | * Method to get LED Mode for Omni, Awair-r2 and Element
1821 | */
1822 | async getLEDMode(accessory: PlatformAccessory): Promise {
1823 | const url = `https://developer-apis.awair.is/v1/devices/${accessory.context.deviceType}/${accessory.context.deviceId}/led`;
1824 | const options = {
1825 | headers: {
1826 | 'Authorization': `Bearer ${this.config.token}`,
1827 | },
1828 | validateStatus: (status: any) => status < 500, // Resolve only if the status code is less than 500
1829 | };
1830 |
1831 | await axios.get(url, options)
1832 | .then(response => {
1833 | if (this.config.logging && this.config.verbose) {
1834 | // eslint-disable-next-line max-len
1835 | this.log.info(`[${accessory.context.serial}] getLEDMode ${accessory.context.deviceUUID} response: ${response.data.mode}, brightness: ${response.data.brightness}`);
1836 | }
1837 |
1838 | this.ledModes.forEach(mode => {
1839 | if (mode.toLowerCase() === response.data.mode.toLowerCase()) { // response.data.mode is in all UPPER case
1840 | accessory.context.ledMode = mode;
1841 | }
1842 | });
1843 | accessory.context.ledBrightness = response.data.brightness;
1844 | })
1845 | .catch(error => {
1846 | if(this.config.logging){
1847 | this.log.error(`[${accessory.context.serial}] getLEDMode ${accessory.context.deviceUUID} error: ${error.toJson}`);
1848 | }
1849 | });
1850 | return;
1851 | }
1852 |
1853 | /**
1854 | * Method to set LED Mode for Omni, Awair-r2 and Element
1855 | */
1856 | async putLEDMode(accessory: PlatformAccessory, mode: string, brightness: number): Promise {
1857 | const url = `https://developer-apis.awair.is/v1/devices/${accessory.context.deviceType}/${accessory.context.deviceId}/led`;
1858 | let body: any = {'mode': mode.toLowerCase()};
1859 | if (mode === 'Manual'){
1860 | body = {'mode': mode.toLowerCase(), 'brightness': brightness};
1861 | }
1862 | const options = {
1863 | headers: {
1864 | 'Authorization': `Bearer ${this.config.token}`,
1865 | },
1866 | validateStatus: (status: any) => status < 500, // Resolve only if the status code is less than 500
1867 | };
1868 |
1869 | await axios.put(url, body, options)
1870 | .then(response => {
1871 | if(this.config.logging){
1872 | // eslint-disable-next-line max-len
1873 | this.log.info(`[${accessory.context.serial}] putLEDMode response: ${response.data.message} for ${accessory.context.deviceUUID}`);
1874 | }
1875 | })
1876 | .catch(error => {
1877 | if(this.config.logging){
1878 | this.log.error(`[${accessory.context.serial}] putLEDMode error: ${error.toJson} for ${accessory.context.deviceUUID}`);
1879 | }
1880 | });
1881 |
1882 | accessory.context.ledMode = mode; // 'context.ledMode' is Mixed case
1883 | accessory.context.ledBrightness = brightness;
1884 | return;
1885 | }
1886 |
1887 | // Conversion functions
1888 | convertChemicals(accessory: PlatformAccessory, voc: number, atmos: number, temp: number): number {
1889 | const vocString = '(' + voc + ' * ' + this.vocMw + ' * ' + atmos + ' * 101.32) / ((273.15 + ' + temp + ') * 8.3144)';
1890 | const tvoc = (voc * this.vocMw * atmos * 101.32) / ((273.15 + temp) * 8.3144);
1891 | if(this.config.logging && this.config.verbose){
1892 | this.log.info(`[${accessory.context.serial}] ppb => ug/m^3 equation: ${vocString}`);
1893 | }
1894 | return tvoc;
1895 | }
1896 |
1897 | convertScore(accessory: PlatformAccessory, score: number): number {
1898 | // new Score for Awair Element as of Dec 2024
1899 | if ((accessory.context.deviceType === 'awair-element') || (accessory.context.deviceType === 'awair-omni')) {
1900 | if (score >= 81) {
1901 | return 1; // GOOD but displayed as EXCELLENT in HomeKit
1902 | } else if (score >= 61 && score < 80) {
1903 | return 2; // ACCEPTABLE but displayed as GOOD in HomeKit
1904 | } else if (score >= 41 && score < 60) {
1905 | return 3; // MODERATE but displayed as FAIR in HomeKit
1906 | } else if (score >= 21 && score < 40) {
1907 | return 4; // POOR but displayed as INFERIOR in HomeKit
1908 | } else if (score < 20) {
1909 | return 5; // HAZADAROUS but displayed as POOR in HomeKit
1910 | } else {
1911 | return 0; // Error
1912 | }
1913 | } else { // no change for Awair-r2
1914 | if (score >= 90) {
1915 | return 1; // EXCELLENT
1916 | } else if (score >= 80 && score < 90) {
1917 | return 2; // GOOD
1918 | } else if (score >= 60 && score < 80) {
1919 | return 3; // FAIR
1920 | } else if (score >= 50 && score < 60) {
1921 | return 4; // INFERIOR
1922 | } else if (score < 50) {
1923 | return 5; // POOR
1924 | } else {
1925 | return 0; // Error
1926 | }
1927 | }
1928 | }
1929 |
1930 | convertAwairAqi(accessory: PlatformAccessory, sensors: any[]): number {
1931 | const aqiArray = [];
1932 | for (const sensor in sensors) {
1933 | switch (sensor) {
1934 | case 'voc':
1935 | let aqiVoc = parseFloat(sensors[sensor]);
1936 | if (aqiVoc >= 0 && aqiVoc < 333) {
1937 | aqiVoc = 1; // EXCELLENT
1938 | } else if (aqiVoc >= 333 && aqiVoc < 1000) {
1939 | aqiVoc = 2; // GOOD
1940 | } else if (aqiVoc >= 1000 && aqiVoc < 3333) {
1941 | aqiVoc = 3; // FAIR
1942 | } else if (aqiVoc >= 3333 && aqiVoc < 8332) {
1943 | aqiVoc = 4; // INFERIOR
1944 | } else if (aqiVoc >= 8332) {
1945 | aqiVoc = 5; // POOR
1946 | } else {
1947 | aqiVoc = 0; // Error
1948 | }
1949 | aqiArray.push(aqiVoc);
1950 | break;
1951 | case 'pm25':
1952 | let aqiPm25 = parseFloat(sensors[sensor]);
1953 | if (aqiPm25 >= 0 && aqiPm25 < 15) {
1954 | aqiPm25 = 1; // EXCELLENT
1955 | } else if (aqiPm25 >= 15 && aqiPm25 < 35) {
1956 | aqiPm25 = 2; // GOOD
1957 | } else if (aqiPm25 >= 35 && aqiPm25 < 55) {
1958 | aqiPm25 = 3; // FAIR
1959 | } else if (aqiPm25 >= 55 && aqiPm25 < 75) {
1960 | aqiPm25 = 4; // INFERIOR
1961 | } else if (aqiPm25 >= 75) {
1962 | aqiPm25 = 5; // POOR
1963 | } else {
1964 | aqiPm25 = 0; // Error
1965 | }
1966 | aqiArray.push(aqiPm25);
1967 | break;
1968 | case 'dust':
1969 | let aqiDust = parseFloat(sensors[sensor]);
1970 | if (aqiDust >= 0 && aqiDust < 50) {
1971 | aqiDust = 1; // EXCELLENT
1972 | } else if (aqiDust >= 50 && aqiDust < 100) {
1973 | aqiDust = 2; // GOOD
1974 | } else if (aqiDust >= 100 && aqiDust < 150) {
1975 | aqiDust = 3; // FAIR
1976 | } else if (aqiDust >= 150 && aqiDust < 250) {
1977 | aqiDust = 4; // INFERIOR
1978 | } else if (aqiDust >= 250) {
1979 | aqiDust = 5; // POOR
1980 | } else {
1981 | aqiDust = 0; // Error
1982 | }
1983 | aqiArray.push(aqiDust);
1984 | break;
1985 | default:
1986 | if(this.config.logging && this.config.verbose){
1987 | // eslint-disable-next-line max-len
1988 | this.log.info(`[${accessory.context.serial}] convertAwairAqi ignoring ${JSON.stringify(sensor)}: ${parseFloat(sensors[sensor])}`);
1989 | }
1990 | aqiArray.push(0);
1991 | break;
1992 | }
1993 | }
1994 | if(this.config.logging && this.config.verbose){
1995 | this.log.info(`[${accessory.context.serial}] aqi array: ${JSON.stringify(aqiArray)}`);
1996 | }
1997 | return Math.max(...aqiArray); // aqi is maximum value of voc, pm25 and dust
1998 | }
1999 |
2000 | convertAwairPm(accessory: PlatformAccessory, sensors: any[]): number {
2001 | const aqiArray = [];
2002 | for (const sensor in sensors) {
2003 | switch (sensor) {
2004 | case 'pm25':
2005 | let aqiPm25 = parseFloat(sensors[sensor]);
2006 | if (aqiPm25 >= 0 && aqiPm25 < 15) {
2007 | aqiPm25 = 1; // EXCELLENT
2008 | } else if (aqiPm25 >= 15 && aqiPm25 < 35) {
2009 | aqiPm25 = 2; // GOOD
2010 | } else if (aqiPm25 >= 35 && aqiPm25 < 55) {
2011 | aqiPm25 = 3; // FAIR
2012 | } else if (aqiPm25 >= 55 && aqiPm25 < 75) {
2013 | aqiPm25 = 4; // INFERIOR
2014 | } else if (aqiPm25 >= 75) {
2015 | aqiPm25 = 5; // POOR
2016 | } else {
2017 | aqiPm25 = 0; // Error
2018 | }
2019 | aqiArray.push(aqiPm25);
2020 | break;
2021 | case 'dust':
2022 | let aqiDust = parseFloat(sensors[sensor]);
2023 | if (aqiDust >= 0 && aqiDust < 50) {
2024 | aqiDust = 1; // EXCELLENT
2025 | } else if (aqiDust >= 50 && aqiDust < 100) {
2026 | aqiDust = 2; // GOOD
2027 | } else if (aqiDust >= 100 && aqiDust < 150) {
2028 | aqiDust = 3; // FAIR
2029 | } else if (aqiDust >= 150 && aqiDust < 250) {
2030 | aqiDust = 4; // INFERIOR
2031 | } else if (aqiDust >= 250) {
2032 | aqiDust = 5; // POOR
2033 | } else {
2034 | aqiDust = 0; // Error
2035 | }
2036 | aqiArray.push(aqiDust);
2037 | break;
2038 | default:
2039 | if(this.config.logging && this.config.verbose){
2040 | // eslint-disable-next-line max-len
2041 | this.log.info(`[${accessory.context.serial}] convertAwairAqi ignoring ${JSON.stringify(sensor)}: ${parseFloat(sensors[sensor])}`);
2042 | }
2043 | aqiArray.push(0);
2044 | break;
2045 | }
2046 | }
2047 | if(this.config.logging && this.config.verbose){
2048 | this.log.info(`[${accessory.context.serial}] aqi array: ${JSON.stringify(aqiArray)}`);
2049 | }
2050 | // aqi is maximum value of pm25 and dust, leaving the implementation flexible for additional PM parameters
2051 | return Math.max(...aqiArray);
2052 | }
2053 |
2054 | convertNowcastAqi(accessory: PlatformAccessory, data: any[]): number {
2055 | const pmRawData: number[] = data
2056 | .map(sensor => sensor.sensors) // create sensor array of sensors with length 'this.limit'
2057 | .reduce((a, b) => a.concat(b)) // flatten array of sensors (which is an array) to single-level array
2058 | .filter((pmEntry: { comp: string; }) => (pmEntry.comp === 'pm25') || (pmEntry.comp === 'dust')) // get just pm25 & dust entries
2059 | .map((pmValue: { value: number; }) => pmValue.value); // return just pm value
2060 |
2061 | if(this.config.logging && this.config.verbose){
2062 | this.log.info(`[${accessory.context.serial}] pmRawData`, pmRawData);
2063 | }
2064 |
2065 | // calculate weightFactor of full 48 points
2066 | const pmMax = Math.max(...pmRawData);
2067 | const pmMin = Math.min(...pmRawData);
2068 | const scaledRateChange = (pmMax - pmMin)/pmMax;
2069 | const weightFactor = ((1 - scaledRateChange) > 0.5) ? (1 - scaledRateChange) : 0.5;
2070 |
2071 | // reduce data from 48 points to 12 of 4 averaged points
2072 | const pmData: number[] = [];
2073 | for (let i = 0; i < 12; i++) {
2074 | pmData[i] = 0;
2075 | for (let j = 0; j < 4; j++) {
2076 | pmData[i] += pmRawData[(i * 4) + j];
2077 | }
2078 | pmData[i] = pmData[i] / 4;
2079 | }
2080 |
2081 | if(this.config.logging && this.config.verbose){
2082 | this.log.info(`[${accessory.context.serial}] pmData`, pmData);
2083 | }
2084 |
2085 | // calculate NowCast value
2086 | let nowCastNumerator = 0;
2087 | for (let i = 0; i < pmData.length; i++) {
2088 | nowCastNumerator += pmData[i] * Math.pow(weightFactor, i);
2089 | }
2090 | let nowCastDenominator = 0;
2091 | for (let i = 0; i < pmData.length; i++) {
2092 | nowCastDenominator += Math.pow(weightFactor, i);
2093 | }
2094 | const nowCast: number = nowCastNumerator / nowCastDenominator; // in ug/m3
2095 | if(this.config.logging){
2096 | this.log.info(`[${accessory.context.serial}] pmMax: ${pmMax}, pmMin: ${pmMin}, weightFactor: ${weightFactor}, nowCast: ${nowCast}`);
2097 | }
2098 |
2099 | // determine nowCast level
2100 | if (nowCast < 50) {
2101 | return 1; // GOOD
2102 | } else if (nowCast >= 50 && nowCast < 100) {
2103 | return 2; // MODERATE
2104 | } else if (nowCast >= 100 && nowCast < 150) {
2105 | return 3; // UNHEALTHY for SENSITIVE GROUPS
2106 | } else if (nowCast >= 150 && nowCast < 300) {
2107 | return 4; // UNHEALTHY
2108 | } else if (nowCast >= 300) {
2109 | return 5; // HAZARDOUS
2110 | } else {
2111 | return 0; // Error
2112 | }
2113 | }
2114 | }
2115 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2018",
4 | "module": "commonjs",
5 | "lib": [
6 | "ES2015",
7 | "ES2016",
8 | "ES2017",
9 | "ES2018",
10 | "dom"
11 | ],
12 | "baseUrl": ".",
13 | "outDir": "dist",
14 | "sourceMap": true,
15 | "allowJs": true,
16 | "resolveJsonModule": true,
17 | "strict": true,
18 | "esModuleInterop": true,
19 | "forceConsistentCasingInFileNames": true,
20 | "skipLibCheck": true,
21 | "paths": {
22 | "*": ["types/*"]
23 | }
24 | },
25 | "include": [
26 | "src/**/*"
27 | ]
28 | }
--------------------------------------------------------------------------------