├── .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 |
HomebridgeGet Awair
7 | 8 | # homebridge-awair2 9 | [![verified-by-homebridge](https://badgen.net/badge/homebridge/verified/purple)](https://github.com/homebridge/homebridge/wiki/Verified-Plugins) ![npm-version](https://badgen.net/npm/v/homebridge-awair2?icon=npm&label) ![npm-downloads](https://badgen.net/npm/dt/homebridge-awair2?icon=npm&label) 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 | ![iOS16 Developer Token](https://github.com/DMBlakeley/homebridge-awair2/blob/master/screenshots/ios16_developer_token.gif) 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 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 |
Scorenew Awair levelHomeKit level
1GOODEXCELLENT
2ACCEPTABLEGOOD
3MODERATEFAIR
4POORINFERIOR
5HAZARDOUSPOOR
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 | ![iOS16 Climate](https://github.com/DMBlakeley/homebridge-awair2/blob/master/screenshots/ios16_climate.gif) 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 | ![iOS16 Local API](https://github.com/DMBlakeley/homebridge-awair2/blob/master/screenshots/ios16_local_api.gif) 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 | } --------------------------------------------------------------------------------