From 5522b37170436d1fc7b21a2d2ee2986889710bfc Mon Sep 17 00:00:00 2001 From: HJD Date: Sun, 4 Aug 2024 18:34:14 -0500 Subject: [PATCH] v1.4.0. --- .eslintrc.json | 46 - .github/workflows/ci.yml | 16 +- .gitignore | 128 +- CODE-OF-CONDUCT.md | 18 + docs/Changelog.md | 4 + eslint.config.mjs | 99 + .../public/access-featureoptions.mjs | 754 ------- homebridge-ui/public/index.html | 81 +- homebridge-ui/public/lib/featureoptions.mjs | 201 -- homebridge-ui/public/ui.mjs | 351 +-- homebridge-ui/server.js | 70 +- package-lock.json | 1897 ++++++++--------- package.json | 35 +- src/access-controller.ts | 99 +- src/access-device.ts | 22 +- src/access-events.ts | 9 +- src/access-hub.ts | 38 +- src/access-mqtt.ts | 247 --- src/access-options.ts | 228 +- src/access-platform.ts | 48 +- tsconfig.json | 23 +- 21 files changed, 1502 insertions(+), 2912 deletions(-) delete mode 100644 .eslintrc.json create mode 100644 CODE-OF-CONDUCT.md create mode 100644 eslint.config.mjs delete mode 100644 homebridge-ui/public/access-featureoptions.mjs delete mode 100644 homebridge-ui/public/lib/featureoptions.mjs delete mode 100644 src/access-mqtt.ts diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 2bce81d..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking" - ], - "ignorePatterns": [ "dist" ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 2020, - "project": "tsconfig.json", - "sourceType": "module" - }, - "plugins": [ "@stylistic" ], - "root": true, - "rules": { - "camelcase": [ "warn" ], - "curly": [ "warn", "all" ], - "dot-notation": "warn", - "eqeqeq": "warn", - "no-await-in-loop": [ "warn" ], - "no-console": [ "warn" ], - "prefer-arrow-callback": [ "warn" ], - "quotes": [ "warn", "double", { "avoidEscape": true } ], - "sort-imports": [ "warn" ], - "sort-keys": [ "warn" ], - "sort-vars": [ "warn" ], - "@stylistic/brace-style": [ "error" ], - "@stylistic/comma-dangle": [ "error" ], - "@stylistic/indent": [ "warn", 2, { "SwitchCase": 1 } ], - "@stylistic/linebreak-style": [ "warn", "unix" ], - "@stylistic/lines-between-class-members": [ "warn", "always", { "exceptAfterSingleLine": true } ], - "@stylistic/max-len": [ "warn", 170 ], - "@stylistic/no-tabs": [ "error" ], - "@stylistic/no-trailing-spaces": [ "error" ], - "@stylistic/semi": [ "warn", "always" ], - "@stylistic/space-before-function-paren": ["error", { "anonymous": "never", "asyncArrow": "always", "named": "never" } ], - "@typescript-eslint/explicit-function-return-type": [ "warn" ], - "@typescript-eslint/explicit-module-boundary-types": [ "warn" ], - "@typescript-eslint/no-explicit-any": [ "warn" ], - "@typescript-eslint/no-floating-promises": [ "warn", { "ignoreIIFE": true }], - "@typescript-eslint/no-non-null-assertion": [ "warn" ], - "@typescript-eslint/no-this-alias": [ "warn" ] - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3302886..2203700 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,6 @@ jobs: build: name: 'Build package' - # Build only if we've received a push, manual workflow dispatch, or release event with a release tag (aka v1.2.3). - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && startsWith(github.ref, 'refs/tags/v')) - # Create the build matrix for all the environments we're validating against. strategy: matrix: @@ -39,11 +36,12 @@ jobs: node-version: ${{ matrix.node-version }} - name: Build and install the package with a clean slate. - run: | - npm ci - npm run build --if-present env: CI: true + ESLINT_MAX_WARNINGS: 0 + run: | + npm ci + npm run prepublishOnly # Publish the release to the NPM registry. publish-npm: @@ -56,6 +54,10 @@ jobs: # Specify the environment we're going to build in. runs-on: ubuntu-latest + # Ensure we have permissions to provide our provenance attestation. + permissions: + id-token: write + # Execute the build and publish activities. steps: - name: Checkout the repository. @@ -75,6 +77,6 @@ jobs: run: npm ci - name: Publish the package to NPM. - run: npm publish --access public + run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.npm_token }} diff --git a/.gitignore b/.gitignore index 86ea22f..e0d0f88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,129 @@ +# Ignore compiled code +dist + +# Ignore npmrc. .npmrc + +# Ignore macOS attribute files. .DS_Store + +# Ignore generated files. +homebridge-ui/public/lib + +# ------------- Defaults ------------- # + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories node_modules/ -package-lock.json -dist/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.pnp.* diff --git a/CODE-OF-CONDUCT.md b/CODE-OF-CONDUCT.md new file mode 100644 index 0000000..ae173b9 --- /dev/null +++ b/CODE-OF-CONDUCT.md @@ -0,0 +1,18 @@ +# Code of Conduct + +By interacting with this GitHub repository, you agree that you'll follow this code of conduct. + +### In short: Be nice. Be respectful. No harassment, trolling, or spamming. + +Always be mindful that in the free / open source community, people are contributing their time away from friends and families to work on these projects. No one is being compensated for their work here. While feedback is useful, coming to this repository to make demands isn’t respectful. + +* Harassment includes sexual language and imagery, deliberate intimidation, stalking, name-calling, unwelcome attention, libel, and any malicious hacking or social engineering. This repository should be a harassment-free experience for everyone, regardless of your background, identity, or experience level. + +* Trolling includes posting inflammatory comments to provoke an emotional response or disrupt discussions. + +* Spamming includes posting off-topic messages to disrupt discussions, promote a product, solicit donations, advertise a job / internship / gig, or flooding discussions with files or text. + +#### The maintainers of this GitHub repository will take any action we deem appropriate, up to and including banning the offender from this repository. + +##### Attribution +This code of conduct was inspired by and adapted from the [freeCodeCamp](https://www.freecodecamp.org/news/code-of-conduct/) code of conduct. diff --git a/docs/Changelog.md b/docs/Changelog.md index 30ed0c0..c2010cc 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. This project uses [semantic versioning](https://semver.org/). +## 1.4.0 (2024-08-04) + * Improvement: now fully supporting UniFi Access v2, including all Access hubs. + * Housekeeping. + ## 1.3.0 (2024-04-20) * New feature: add support for the latest generation of UniFi Access hubs. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..7484fb7 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,99 @@ +/* Copyright(C) 2017-2024, HJD (https://github.com/hjdhjd). All rights reserved. + * + * eslint.config.mjs: Linting defaults for Homebridge plugins. + */ +import eslintJs from "@eslint/js"; +import hbPluginUtils from "homebridge-plugin-utils/build/eslint-rules.mjs"; +import ts from "typescript-eslint"; +import tsParser from "@typescript-eslint/parser"; + +export default ts.config( + + eslintJs.configs.recommended, + + { + + files: [ "src/**.ts" ], + rules: { + + ...hbPluginUtils.rules.ts + } + }, + + { + + files: [ "homebridge-ui/public/**/*.@(js|mjs)", "homebridge-ui/server.js", "eslint.config.mjs" ], + rules: { + + ...hbPluginUtils.rules.js + } + }, + + { + + files: [ "src/**.ts", "homebridge-ui/*.@(js|mjs)", "homebridge-ui/public/**/*.@(js|mjs)", "eslint.config.mjs" ], + + ignores: [ "dist" ], + + languageOptions: { + + ecmaVersion: "latest", + parser: tsParser, + parserOptions: { + + ecmaVersion: "latest", + project: "./tsconfig.json", + + projectService: { + + allowDefaultProject: [ "eslint.config.mjs", "homebridge-ui/*.@(js|mjs)", "homebridge-ui/public/lib/*.@(js|mjs)" ], + defaultProject: "./tsconfig.json" + } + }, + + sourceType: "module" + }, + + linterOptions: { + + reportUnusedDisableDirectives: "error" + }, + + plugins: { + + ...hbPluginUtils.plugins + }, + + rules: { + + ...hbPluginUtils.rules.common + } + }, + + { + + files: [ "homebridge-ui/public/lib/webUi.mjs", "homebridge-ui/public/lib/webUi-featureoptions.mjs" ], + + languageOptions: { + + globals: { + + ...hbPluginUtils.globals.ui + } + } + }, + + { + + files: [ "homebridge-ui/server.js" ], + + languageOptions: { + + globals: { + + console: "readonly", + fetch: "readonly" + } + } + } +); diff --git a/homebridge-ui/public/access-featureoptions.mjs b/homebridge-ui/public/access-featureoptions.mjs deleted file mode 100644 index 057f526..0000000 --- a/homebridge-ui/public/access-featureoptions.mjs +++ /dev/null @@ -1,754 +0,0 @@ -/* Copyright(C) 2017-2024, HJD (https://github.com/hjdhjd). All rights reserved. - * - * access-featureoptions.mjs: Access feature option webUI. - */ -"use strict"; - -// Access-specific customizations for my feature option framework. We import in this odd way to dodge browser caches. -export class AccessFeatureOptions extends (await import("./lib/featureoptions.mjs")).FeatureOptions { - - // The current plugin configuration. - currentConfig; - - // Current configuration options selected in the webUI for a given device. - #configOptions; - - // Table containing the currently displayed feature options. - #configTable; - - // Our list of Access controllers. - #controllerList; - - // Current list of devices on a given controller, for webUI elements. - #deviceList; - - // Current list of Access devices from the Access controller. - #udaDevices; - - constructor() { - - super(); - - this.configOptions = []; - this.configTable = document.getElementById("configTable"); - this.controllerList = []; - this.currentConfig = []; - this.deviceList = []; - this.udaDevices = []; - } - - // Render the feature option webUI. - async showUI() { - - // Show the beachball while we setup. - homebridge.showSpinner(); - homebridge.hideSchemaForm(); - - // Make sure we have the refreshed configuration. - this.currentConfig = await homebridge.getPluginConfig(); - - // Create our custom UI. - document.getElementById("menuHome").classList.remove("btn-elegant"); - document.getElementById("menuHome").classList.add("btn-primary"); - document.getElementById("menuFeatureOptions").classList.add("btn-elegant"); - document.getElementById("menuFeatureOptions").classList.remove("btn-primary"); - document.getElementById("menuSettings").classList.remove("btn-elegant"); - document.getElementById("menuSettings").classList.add("btn-primary"); - - // Hide the legacy UI. - document.getElementById("pageSupport").style.display = "none"; - document.getElementById("pageFeatureOptions").style.display = "block"; - - // What we're going to do is display our global options, followed by the list of controllers the user has configured. - // We pre-select the first controller by default for the user as a starting point. - - // Create the table for the our list of controllers and global options. - const controllersTable = document.getElementById("controllersTable"); - - // Start with a clean slate. - controllersTable.innerHTML = ""; - document.getElementById("devicesTable").innerHTML = ""; - this.configTable.innerHTML = ""; - this.deviceList = []; - - // Hide the UI until we're ready. - document.getElementById("sidebar").style.display = "none"; - document.getElementById("headerInfo").style.display = "none"; - document.getElementById("deviceStatsTable").style.display = "none"; - - // We haven't configured anything yet - we're done. - if(!this.currentConfig[0]?.controllers?.length) { - - document.getElementById("headerInfo").innerHTML = "Please configure a UniFi Access controller to access in the main settings tab before configuring feature options." - document.getElementById("headerInfo").style.display = ""; - homebridge.hideSpinner(); - return; - } - - // Enumerate our global options. - const trGlobal = document.createElement("tr"); - - // Create the cell for our global options. - const tdGlobal = document.createElement("td"); - tdGlobal.classList.add("m-0", "p-0"); - - // Create our label target. - const globalLabel = document.createElement("label"); - - globalLabel.name = "Global Options"; - globalLabel.appendChild(document.createTextNode("Global Options")); - globalLabel.style.cursor = "pointer"; - globalLabel.classList.add("mx-0", "my-2", "p-0", "w-100"); - - globalLabel.addEventListener("click", event => this.#showDevices(null)); - - // Add the global options label. - tdGlobal.appendChild(globalLabel); - tdGlobal.style.fontWeight = "bold"; - - // Add the global cell to the table. - trGlobal.appendChild(tdGlobal); - - // Now add it to the overall controllers table. - controllersTable.appendChild(trGlobal); - - // Add it as another controller, for UI purposes. - this.controllerList.push(globalLabel); - - // Create a row for our controllers. - const trController = document.createElement("tr"); - - // Create the cell for our controller category row. - const tdController = document.createElement("td"); - tdController.classList.add("m-0", "p-0"); - - // Add the category name, with appropriate casing. - tdController.appendChild(document.createTextNode("Access Controller" + (this.currentConfig[0].controllers.length > 1 ? "s" : ""))); - tdController.style.fontWeight = "bold"; - - // Add the cell to the table row. - trController.appendChild(tdController); - - // Add the table row to the table. - controllersTable.appendChild(trController); - - for(const controller of this.currentConfig[0].controllers) { - - // Create a row for this controller. - const trDevice = document.createElement("tr"); - trDevice.classList.add("m-0", "p-0"); - - // Create a cell for our controller. - const tdDevice = document.createElement("td"); - tdDevice.classList.add("m-0", "p-0", "w-100"); - - const label = document.createElement("label"); - - label.name = controller.address; - label.appendChild(document.createTextNode(controller.address)); - label.style.cursor = "pointer"; - label.classList.add("mx-2", "my-0", "p-0", "w-100"); - - label.addEventListener("click", event => this.#showDevices(controller)); - - // Add the controller label to our cell. - tdDevice.appendChild(label); - - // Add the cell to the table row. - trDevice.appendChild(tdDevice); - - // Add the table row to the table. - controllersTable.appendChild(trDevice); - - this.controllerList.push(label); - } - - // All done. Let the user interact with us. - homebridge.hideSpinner(); - - // Default the user on the first controller. - this.#showDevices(this.currentConfig[0].controllers[0]); - } - - // Show the devices attached to a controller. - async #showDevices(controller) { - - // Show the beachball while we setup. - homebridge.showSpinner(); - - // Make sure we highlight the selected controller so the user knows where we are. - this.controllerList.map(x => (x.name === (controller ? controller.address : "Global Options")) ? - x.parentElement.classList.add("bg-info", "text-white") : x.parentElement.classList.remove("bg-info", "text-white")); - - const devicesTable = document.getElementById("devicesTable"); - this.udaDevices = []; - - // If we're not accessing global options, pull a list of devices attached to this controller. - if(controller) { - - this.udaDevices = await homebridge.request("/getDevices", { address: controller.address, username: controller.username, password: controller.password }); - - // Since the controller JSON doesn't have the same properties as the device JSON, let's make the controller JSON emulate the properties we care about. - if(this.udaDevices?.length) { - - this.udaDevices[0].display_model = "controller"; - this.udaDevices[0].ip = this.udaDevices[0].host.ip; - this.udaDevices[0].is_online = true; - this.udaDevices[0].mac = this.udaDevices[0].host.mac; - this.udaDevices[0].model = this.udaDevices[0].host.device_type; - this.udaDevices[0].unique_id = this.udaDevices[0].host.mac; - } - } - - // Couldn't connect to the Access controller for some reason. - if(controller && !this.udaDevices?.length) { - - devicesTable.innerHTML = ""; - this.configTable.innerHTML = ""; - - document.getElementById("headerInfo").innerHTML = "Unable to connect to the Access controller.
Check your settings for this controller in the settings tab to verify they are correct.
" + (await homebridge.request("/getErrorMessage")) + ""; - document.getElementById("headerInfo").style.display = ""; - document.getElementById("deviceStatsTable").style.display = "none"; - - homebridge.hideSpinner(); - return; - } - - // Initialize our informational header. - document.getElementById("headerInfo").innerHTML = "Feature options are applied in prioritized order, from global to device-specific options:
Global options (lowest priority) → Access controller optionsAccess device options (highest priority)" - - // Make the UI visible. - document.getElementById("headerInfo").style.display = ""; - document.getElementById("sidebar").style.display = ""; - document.getElementById("deviceStatsTable").style.display = ""; - - // Workaround for the time being to reduce the number of models we see to just the currently supported ones. - // const modelKeys = [...new Set(this.udaDevices.map(x => x.display_model))]; - const modelKeys = this.udaDevices.length ? [ "controller", "UA Hub", "UA Hub Door" ] : []; - this.deviceList = []; - - // The first entry returned by getDevices is always the controller. - this.controller = this.udaDevices[0]?.host.mac ?? ""; - - // Start with a clean slate. - devicesTable.innerHTML = ""; - - for(const key of modelKeys) { - - // Get all the devices associated with this device category. - const devices = this.udaDevices.filter(x => x.display_model === key); - - // Nothing in this category, let's keep going. - if(!devices.length) { - - continue; - } - - // If it's a controller, we handle that case differently. - if(key === "controller") { - - // Change the name of the controller that we show users once we've connected with the controller. - this.controllerList.map(x => (x.name === controller.address) ? x.childNodes[0].nodeValue = devices[0].host.hostname : true); - continue; - } - - // Create a row for this device category. - const trCategory = document.createElement("tr"); - - // Create the cell for our device category row. - const tdCategory = document.createElement("td"); - tdCategory.classList.add("m-0", "p-0"); - - // Add the category name, with appropriate casing. - tdCategory.appendChild(document.createTextNode((key === "controller") ? "Access Controllers" : (key.charAt(0).toUpperCase() + key.slice(1) + "s"))); - tdCategory.style.fontWeight = "bold"; - - // Add the cell to the table row. - trCategory.appendChild(tdCategory); - - // Add the table row to the table. - devicesTable.appendChild(trCategory); - - for(const device of devices) { - - // Create a row for this device. - const trDevice = document.createElement("tr"); - trDevice.classList.add("m-0", "p-0"); - - // Create a cell for our device. - const tdDevice = document.createElement("td"); - tdDevice.classList.add("m-0", "p-0" , "w-100"); - - const label = document.createElement("label"); - - label.name = device.unique_id; - label.appendChild(document.createTextNode(device.alias ?? device.display_model)); - label.style.cursor = "pointer"; - label.classList.add("mx-2", "my-0", "p-0", "w-100"); - - label.addEventListener("click", event => this.#showDeviceInfo(device.unique_id)); - - // Add the device label to our cell. - tdDevice.appendChild(label); - - // Add the cell to the table row. - trDevice.appendChild(tdDevice); - - // Add the table row to the table. - devicesTable.appendChild(trDevice); - - this.deviceList.push(label); - } - } - - this.configOptions = []; - - // Initialize our feature option configuration. - this.#updateConfigOptions(this.currentConfig[0].options ?? []); - - // Display the feature options to the user. - this.#showDeviceInfo(controller ? this.udaDevices[0].unique_id : null); - - // All done. Let the user interact with us. - homebridge.hideSpinner(); - } - - // Show feature option information for a specific device, controller, or globally. - async #showDeviceInfo(deviceId) { - - homebridge.showSpinner(); - - // Update the selected device for visibility. - this.deviceList.map(x => (x.name === deviceId) ? x.parentElement.classList.add("bg-info", "text-white") : x.parentElement.classList.remove("bg-info", "text-white")); - - // Populate the device information info pane. - const udaDevice = this.udaDevices.find(x => x.unique_id === deviceId); - - // Ensure we have a controller or device. The only time this won't be the case is when we're looking at global options. - if(udaDevice) { - - document.getElementById("device_model").classList.remove("text-center"); - document.getElementById("device_model").colSpan = 1; - document.getElementById("device_model").style.fontWeight = "normal"; - document.getElementById("device_model").innerHTML = udaDevice.model ?? udaDevice.display_model; - document.getElementById("device_mac").innerHTML = udaDevice.mac.replace(/:/g, "").toUpperCase(); - document.getElementById("device_address").innerHTML = udaDevice.ip; - document.getElementById("device_online").innerHTML = udaDevice.is_online ? "Connected" : "Disconnected"; - document.getElementById("deviceStatsTable").style.display = ""; - } else { - - document.getElementById("deviceStatsTable").style.display = "none"; - document.getElementById("device_model").classList.remove("text-center"); - document.getElementById("device_model").colSpan = 1; - document.getElementById("device_model").style.fontWeight = "normal"; - document.getElementById("device_model").innerHTML = "N/A" - document.getElementById("device_mac").innerHTML = "N/A"; - document.getElementById("device_address").innerHTML = "N/A"; - document.getElementById("device_online").innerHTML = "N/A"; - } - - // Populate the feature options selected for this device. - const udaFeatures = await homebridge.request("/getOptions", { configOptions: this.configOptions, controllerUda: this.udaDevices[0], deviceUda: udaDevice }); - const optionsDevice = udaFeatures.options; - - // Start with a clean slate. - let newConfigTableHtml = ""; - this.configTable.innerHTML = ""; - - // Initialize the full list of options. - this.featureOptionList = {}; - this.featureOptionGroups = {}; - - for(const category of udaFeatures.categories) { - - // Now enumerate all the feature options for a given device and add then to the full list. - for(const option of optionsDevice[category.name]) { - - const featureOption = category.name + (option.name.length ? ("." + option.name): ""); - - // Add it to our full list. - this.featureOptionList[featureOption] = option; - - // Cross reference the feature option group it belongs to, if any. - if(option.group !== undefined) { - - const expandedGroup = category.name + (option.group.length ? ("." + option.group): ""); - - // Initialize the group entry if needed. - if(!this.featureOptionGroups[expandedGroup]) { - - this.featureOptionGroups[expandedGroup] = []; - } - - this.featureOptionGroups[expandedGroup].push(featureOption); - } - } - } - - for(const category of udaFeatures.categories) { - - // Only show feature option categories that are valid for this context. - if(udaDevice && (udaDevice.display_model !== "controller") && !category.modelKey.some(x => (x === udaDevice.display_model) || x === "all")) { - - continue; - } - - const optionTable = document.createElement("table"); - const thead = document.createElement("thead"); - const tbody = document.createElement("tbody"); - const trFirst = document.createElement("tr"); - const th = document.createElement("th"); - - // Set our table options. - optionTable.classList.add("table", "table-borderless", "table-sm", "table-hover"); - th.classList.add("p-0"); - th.style.fontWeight = "bold"; - th.colSpan = 3; - tbody.classList.add("table-bordered"); - - // Add the feature option category description. - th.appendChild(document.createTextNode(category.description + - (!udaDevice ? " (Global)" : (udaDevice.display_model === "controller" ? " (Controller-wide)" : " (Device-specific)")))); - - // Add the table header to the row. - trFirst.appendChild(th); - - // Add the table row to the table head. - thead.appendChild(trFirst); - - // Finally, add the table head to the table. - optionTable.appendChild(thead); - - // Keep track of the number of options we have made available in a given category. - let optionsVisibleCount = 0; - - // Now enumerate all the feature options for a given device. - for(const option of optionsDevice[category.name]) { - - // Only show feature options that are valid for this device. - if(udaDevice && (udaDevice.display_model !== "controller") && ( - (option.hasFeature && (!udaDevice.capabilities || !option.hasFeature.some(x => udaDevice.capabilities.includes(x)))) || - (option.hasProperty && !option.hasProperty.some(x => x in udaDevice)) || - (option.modelKey && (option.modelKey !== "all") && !option.modelKey.includes(udaDevice.display_model)))) { - - continue; - } - - // Expand the full feature option. - const featureOption = category.name + (option.name.length ? ("." + option.name): ""); - - // Create the next table row. - const trX = document.createElement("tr"); - trX.classList.add("align-top"); - trX.id = "row-" + featureOption; - - // Create a checkbox for the option. - const tdCheckbox = document.createElement("td"); - - // Create the actual checkbox for the option. - const checkbox = document.createElement("input"); - - checkbox.type = "checkbox"; - checkbox.readOnly = false; - checkbox.id = featureOption; - checkbox.name = featureOption; - checkbox.value = featureOption + (!udaDevice ? "" : ("." + udaDevice.mac.replace(/:/g, "").toUpperCase())); - - let initialValue = undefined; - let initialScope; - - // Determine our initial option scope to show the user what's been set. - switch(initialScope = this.optionScope(featureOption, udaDevice?.mac.replace(/:/g, ""), option.default, ("defaultValue" in option))) { - - case "global": - case "controller": - - // If we're looking at the global scope, show the option value. Otherwise, we show that we're inheriting a value from the scope above. - if(!udaDevice) { - - if("defaultValue" in option) { - - checkbox.checked = this.isOptionValueSet(featureOption); - initialValue = this.getOptionValue(checkbox.id); - } else { - - checkbox.checked = this.isGlobalOptionEnabled(featureOption, option.default); - } - - if(checkbox.checked) { - - checkbox.indeterminate = false; - } - - } else { - - if("defaultValue" in option) { - - initialValue = this.getOptionValue(checkbox.id, (initialScope === "controller") ? this.controller : undefined); - } - - checkbox.readOnly = checkbox.indeterminate = true; - } - - break; - - case "device": - case "none": - default: - - if("defaultValue" in option) { - - checkbox.checked = this.isOptionValueSet(featureOption, udaDevice?.mac.replace(/:/g, "")); - initialValue = this.getOptionValue(checkbox.id, udaDevice?.mac.replace(/:/g, "")); - } else { - - checkbox.checked = this.isDeviceOptionEnabled(featureOption, udaDevice?.mac.replace(/:/g, ""), option.default); - } - - break; - } - - checkbox.defaultChecked = option.default; - checkbox.classList.add("mx-2"); - - // Add the checkbox to the table cell. - tdCheckbox.appendChild(checkbox); - - // Add the checkbox to the table row. - trX.appendChild(tdCheckbox); - - const tdLabel = document.createElement("td"); - tdLabel.classList.add("w-100"); - tdLabel.colSpan = 2; - - let inputValue = null; - - // Add an input field if we have a value-centric feature option. - if(("defaultValue" in option)) { - - const tdInput = document.createElement("td"); - tdInput.classList.add("mr-2"); - tdInput.style.width = "10%"; - - inputValue = document.createElement("input"); - inputValue.type = "text"; - inputValue.value = initialValue ?? option.defaultValue; - inputValue.size = 5; - inputValue.readOnly = !checkbox.checked; - - // Add or remove the setting from our configuration when we've changed our state. - inputValue.addEventListener("change", async () => { - - // Find the option in our list and delete it if it exists. - const optionRegex = new RegExp("^(?:Enable|Disable)\\." + checkbox.id + (!udaDevice ? "" : ("\\." + udaDevice.mac.replace(/:/g, ""))) + "\\.[^\\.]+$", "gi"); - const newOptions = this.configOptions.filter(x => !optionRegex.test(x)); - - if(checkbox.checked) { - - if(inputValue.value.length) { - - newOptions.push("Enable." + checkbox.value + "." + inputValue.value); - } - } else if(checkbox.indeterminate) { - - // If we're in an indeterminate state, we need to traverse the tree to get the upstream value we're inheriting. - inputValue.value = (udaDevice?.mac !== this.controller) ? (this.getOptionValue(checkbox.id, this.controller) ?? this.getOptionValue(checkbox.id)) : (this.getOptionValue(checkbox.id) ?? option.defaultValue); - } else { - - inputValue.value = option.defaultValue; - } - - // Update our configuration in Homebridge. - this.currentConfig[0].options = newOptions; - this.#updateConfigOptions(newOptions); - await homebridge.updatePluginConfig(this.currentConfig); - }); - - tdInput.appendChild(inputValue); - trX.appendChild(tdInput); - } - - // Create a label for the checkbox with our option description. - const labelDescription = document.createElement("label"); - labelDescription.for = checkbox.id; - labelDescription.style.cursor = "pointer"; - labelDescription.classList.add("user-select-none", "my-0", "py-0"); - - // Highlight options for the user that are different than our defaults. - const scopeColor = this.optionScopeColor(featureOption, udaDevice?.mac.replace(/:/g, ""), option.default, ("defaultValue" in option)); - - if(scopeColor) { - - labelDescription.classList.add(scopeColor); - } - - // Add or remove the setting from our configuration when we've changed our state. - checkbox.addEventListener("change", async () => { - - // Find the option in our list and delete it if it exists. - const optionRegex = new RegExp("^(?:Enable|Disable)\\." + checkbox.id + (!udaDevice ? "" : ("\\." + udaDevice.mac.replace(/:/g, ""))) + "$", "gi"); - const newOptions = this.configOptions.filter(x => !optionRegex.test(x)); - - // Figure out if we've got the option set upstream. - let upstreamOption = false; - - // We explicitly want to check for the scope of the feature option above where we are now, so we can appropriately determine what we should show. - switch(this.optionScope(checkbox.id, (udaDevice && (udaDevice.mac !== this.controller)) ? this.controller : null, option.default, ("defaultValue" in option))) { - - case "device": - case "controller": - - if(udaDevice.mac !== this.controller) { - - upstreamOption = true; - } - - break; - - case "global": - - if(udaDevice) { - - upstreamOption = true; - } - - break; - - default: - - break; - } - - // For value-centric feature options, if there's an upstream value assigned above us, we don't allow for an unchecked state as it makes no sense in that context. - if(checkbox.readOnly && (!("defaultValue" in option) || (("defaultValue" in option) && inputValue && !upstreamOption))) { - - // We're truly unchecked. We need this because a checkbox can be in both an unchecked and indeterminate simultaneously, - // so we use the readOnly property to let us know that we've just cycled from an indeterminate state. - checkbox.checked = checkbox.readOnly = false; - } else if(!checkbox.checked) { - - // If we have an upstream option configured, we reveal a third state to show inheritance of that option and allow the user to select it. - if(upstreamOption) { - - // We want to set the readOnly property as well, since it will survive a user interaction when they click the checkbox to clear out the - // indeterminate state. This allows us to effectively cycle between three states. - checkbox.readOnly = checkbox.indeterminate = true; - } - - if(("defaultValue" in option) && inputValue) { - - inputValue.readOnly = true; - } - } else if(checkbox.checked) { - - // We've explicitly checked this option. - checkbox.readOnly = checkbox.indeterminate = false; - - if(("defaultValue" in option) && inputValue) { - - inputValue.readOnly = false; - } - } - - // The setting is different from the default, highlight it for the user, accounting for upstream scope, and add it to our configuration. - if(!checkbox.indeterminate && ((checkbox.checked !== option.default) || upstreamOption)) { - - labelDescription.classList.add("text-info"); - newOptions.push((checkbox.checked ? "Enable." : "Disable.") + checkbox.value); - } else { - - // We've reset to the defaults, remove our highlighting. - labelDescription.classList.remove("text-info"); - } - - // Update our Homebridge configuration. - if(("defaultValue" in option) && inputValue) { - - // Inform our value-centric feature option to update Homebridge. - const changeEvent = new Event("change"); - - inputValue.dispatchEvent(changeEvent); - } else { - - // Update our configuration in Homebridge. - this.currentConfig[0].options = newOptions; - this.#updateConfigOptions(newOptions); - await homebridge.updatePluginConfig(this.currentConfig); - } - - // If we've reset to defaults, make sure our color coding for scope is reflected. - if((checkbox.checked === option.default) || checkbox.indeterminate) { - - const scopeColor = this.optionScopeColor(featureOption, udaDevice?.mac.replace(/:/g, ""), option.default, ("defaultValue" in option)); - - if(scopeColor) { - - labelDescription.classList.add(scopeColor); - } - } - - // Adjust visibility of other feature options that depend on us. - if(this.featureOptionGroups[checkbox.id]) { - - const entryVisibility = this.isOptionEnabled(featureOption, udaDevice?.mac.replace(/:/g, "")) ? "" : "none"; - - // Lookup each feature option setting and set the visibility accordingly. - for(const entry of this.featureOptionGroups[checkbox.id]) { - - document.getElementById("row-" + entry).style.display = entryVisibility; - } - } - }); - - // Add the actual description for the option after the checkbox. - labelDescription.appendChild(document.createTextNode(option.description)); - - // Add the label to the table cell. - tdLabel.appendChild(labelDescription); - - // Provide a cell-wide target to click on options. - tdLabel.addEventListener("click", () => checkbox.click()); - - // Add the label table cell to the table row. - trX.appendChild(tdLabel); - - // Adjust the visibility of the feature option, if it's logically grouped. - if((option.group !== undefined) && !this.isOptionEnabled(category.name + (option.group.length ? ("." + option.group): ""), udaDevice?.mac.replace(/:/g, ""))) { - - trX.style.display = "none"; - } else { - - // Increment the visible option count. - optionsVisibleCount++; - } - - // Add the table row to the table body. - tbody.appendChild(trX); - } - - // Add the table body to the table. - optionTable.appendChild(tbody); - - // If we have no options visible in a given category, then hide the entire category. - if(!optionsVisibleCount) { - - optionTable.style.display = "none"; - } - - // Add the table to the page. - this.configTable.appendChild(optionTable); - } - - homebridge.hideSpinner(); - } - - // Update our configuration options. - #updateConfigOptions(newConfig) { - - // Update our configuration. - this.configOptions = newConfig; - - // Show all the valid options configured by the user. - this.optionsList = this.configOptions.filter(x => x.match(/^(Enable|Disable)\.*/gi)).map(x => x.toUpperCase()); - } -} diff --git a/homebridge-ui/public/index.html b/homebridge-ui/public/index.html index 6a47dd6..21af1d9 100644 --- a/homebridge-ui/public/index.html +++ b/homebridge-ui/public/index.html @@ -1,10 +1,19 @@ +

homebridge-unifi-access logo