├── .vscode ├── extensions.json └── settings.json ├── demo.gif ├── homebridge-hue.png ├── src ├── settings.ts ├── index.ts ├── types.ts ├── utils.ts ├── hue-daylight-sync-accessory.ts ├── platform.ts ├── temperature-calculator.ts ├── auto-mode-service.ts ├── queue-processor.ts └── light-service.ts ├── nodemon.json ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.md │ ├── support-request.md │ └── bug-report.md └── workflows │ └── build.yml ├── .prettierrc.json ├── homebridge-ui ├── server.js └── public │ ├── index.html │ └── js │ └── modules │ └── jquery.min.js ├── tsconfig.json ├── LICENSE ├── eslint.config.js ├── package.json ├── .gitignore ├── config.schema.json ├── .npmignore └── README.md /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoshBello/homebridge-hue-daylight-sync/HEAD/demo.gif -------------------------------------------------------------------------------- /homebridge-hue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoshBello/homebridge-hue-daylight-sync/HEAD/homebridge-hue.png -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | export const PLATFORM_NAME = 'HueDaylightSync'; 2 | export const PLUGIN_NAME = 'homebridge-hue-daylight-sync'; 3 | -------------------------------------------------------------------------------- /.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 | } 9 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": [], 5 | "exec": "tsc && homebridge -I -D", 6 | "signal": "SIGTERM", 7 | "env": { 8 | "NODE_OPTIONS": "--trace-warnings" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.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 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "printWidth": 160, 8 | "arrowParens": "always", 9 | "bracketSpacing": true, 10 | "endOfLine": "lf" 11 | } 12 | -------------------------------------------------------------------------------- /homebridge-ui/server.js: -------------------------------------------------------------------------------- 1 | const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils'); 2 | 3 | class UiServer extends HomebridgePluginUiServer { 4 | constructor() { 5 | super(); 6 | this.ready(); 7 | } 8 | } 9 | 10 | (() => { 11 | return new UiServer(); 12 | })(); 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { API } from 'homebridge'; 2 | import { PLATFORM_NAME } from './settings'; 3 | import { HueDaylightSyncPlatform } from './platform'; 4 | 5 | export = (api: API) => { 6 | console.log('homebridge-hue-daylight-sync is loading!'); 7 | api.registerPlatform(PLATFORM_NAME, HueDaylightSyncPlatform); 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["ES2015", "ES2016", "ES2017", "ES2018"], 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "outDir": "./dist", 10 | "rootDir": "./src", 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "resolveJsonModule": true 14 | }, 15 | "include": [ 16 | "src" 17 | ], 18 | "exclude": [ 19 | "**/__tests__/*" 20 | ] 21 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { PlatformConfig } from 'homebridge'; 2 | 3 | export interface Config extends PlatformConfig { 4 | bridgeIp: string; 5 | apiToken: string; 6 | latitude: number; 7 | longitude: number; 8 | updateInterval?: number; 9 | warmTemp?: number; 10 | coolTemp?: number; 11 | inputDebounceDelay?: number; 12 | defaultAutoMode?: boolean; 13 | excludedLights?: string[]; 14 | curveExponent?: number; 15 | } 16 | 17 | export interface QueueItem { 18 | lightId: string; 19 | lightName: string; 20 | kelvin: number; 21 | retries: number; 22 | lastError?: string; 23 | } 24 | 25 | export interface LightState { 26 | isOn: boolean; 27 | temperature: number; 28 | } 29 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function kelvinToMired(kelvin: number): number { 2 | return Math.round(1000000 / kelvin); 3 | } 4 | 5 | export function miredToKelvin(mired: number): number { 6 | return Math.round(1000000 / mired); 7 | } 8 | 9 | export function kelvinToSliderPosition(kelvin: number, warmTemp: number, coolTemp: number): number { 10 | const position = ((kelvin - warmTemp) / (coolTemp - warmTemp)) * 99 + 1; // Maps to 1%-100% 11 | return Math.round(position); 12 | } 13 | 14 | export function sliderPositionToKelvin(position: number, warmTemp: number, coolTemp: number): number { 15 | const kelvin = warmTemp + ((position - 1) / 99) * (coolTemp - warmTemp); // Adjusted for 1%-100% 16 | return Math.round(kelvin); 17 | } 18 | -------------------------------------------------------------------------------- /.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 | **Is your feature request related to a problem? Please describe:** 10 | 11 | 12 | 13 | **Describe the solution you'd like:** 14 | 15 | 16 | 17 | **Describe alternatives you've considered:** 18 | 19 | 20 | 21 | **Additional context:** 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.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 | fail-fast: false 11 | matrix: 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 | 31 | - name: List, audit, fix outdated dependencies and build again 32 | run: | 33 | npm list --outdated 34 | npm audit || true # ignore failures 35 | npm audit fix || true 36 | npm list --outdated 37 | npm run build 38 | -------------------------------------------------------------------------------- /.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 | **Describe Your Problem:** 12 | 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 | 31 | **Environment:** 32 | 33 | - **Plugin Version**: 34 | - **Homebridge Version**: 35 | - **Node.js Version**: 36 | - **NPM Version**: 37 | - **Operating System**: 38 | 39 | 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Josh Bello 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.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 | **Describe The Bug:** 12 | 13 | 14 | 15 | **To Reproduce:** 16 | 17 | 18 | 19 | **Expected behavior:** 20 | 21 | 22 | 23 | **Logs:** 24 | 25 | ``` 26 | Show the Homebridge logs here, remove any sensitive information. 27 | ``` 28 | 29 | **Plugin Config:** 30 | 31 | ```json 32 | Show your Homebridge config.json here, remove any sensitive information. 33 | ``` 34 | 35 | **Screenshots:** 36 | 37 | 38 | 39 | **Environment:** 40 | 41 | - **Plugin Version**: 42 | - **Homebridge Version**: 43 | - **Node.js Version**: 44 | - **NPM Version**: 45 | - **Operating System**: 46 | 47 | 48 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | const js = require('@eslint/js'); 3 | // eslint-disable-next-line no-undef 4 | const typescript = require('@typescript-eslint/eslint-plugin'); 5 | // eslint-disable-next-line no-undef 6 | const typescriptParser = require('@typescript-eslint/parser'); 7 | 8 | // eslint-disable-next-line no-undef 9 | module.exports = [ 10 | js.configs.recommended, 11 | { 12 | files: ['src/**/*.ts'], 13 | ignores: ['**/node_modules/**', '**/dist/**'], 14 | languageOptions: { 15 | parser: typescriptParser, 16 | parserOptions: { 17 | ecmaVersion: 'latest', 18 | sourceType: 'module', 19 | project: './tsconfig.json', 20 | }, 21 | globals: { 22 | NodeJS: 'readonly', 23 | setInterval: 'readonly', 24 | clearInterval: 'readonly', 25 | setTimeout: 'readonly', 26 | clearTimeout: 'readonly', 27 | console: 'readonly', 28 | }, 29 | }, 30 | plugins: { 31 | '@typescript-eslint': typescript, 32 | }, 33 | rules: { 34 | ...typescript.configs['recommended'].rules, 35 | quotes: ['error', 'single'], 36 | indent: ['error', 2, { SwitchCase: 1 }], 37 | 'linebreak-style': ['error', 'unix'], 38 | semi: ['error', 'always'], 39 | 'comma-dangle': ['error', 'always-multiline'], 40 | 'dot-notation': 'error', 41 | eqeqeq: ['error', 'smart'], 42 | curly: ['error', 'all'], 43 | 'brace-style': ['error'], 44 | 'prefer-arrow-callback': 'warn', 45 | 'max-len': ['warn', 160], 46 | 'object-curly-spacing': ['error', 'always'], 47 | 'no-use-before-define': 'off', 48 | '@typescript-eslint/no-use-before-define': ['error', { classes: false, enums: false }], 49 | '@typescript-eslint/no-unused-vars': ['error', { caughtErrors: 'none' }], 50 | }, 51 | }, 52 | ]; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-hue-daylight-sync", 3 | "displayName": "Hue Daylight Sync", 4 | "version": "2.0.0", 5 | "description": "A Homebridge plugin for syncing Hue lights with daylight", 6 | "main": "dist/index.js", 7 | "configSchema": "config.schema.json", 8 | "private": false, 9 | "author": "Josh Bello", 10 | "license": "Apache-2.0", 11 | "homepage": "https://github.com/JoshBello/homebridge-hue-daylight-sync#readme", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/JoshBello/homebridge-hue-daylight-sync.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/JoshBello/homebridge-hue-daylight-sync/issues" 18 | }, 19 | "keywords": [ 20 | "homebridge-plugin", 21 | "homebridge", 22 | "hue", 23 | "daylight", 24 | "sync" 25 | ], 26 | "engines": { 27 | "node": "^18.20.4 || ^20.10.0 || ^20.16.0 || ^22.6.0", 28 | "homebridge": "^1.8.0 || ^2.0.0-beta.0" 29 | }, 30 | "scripts": { 31 | "build": "rimraf ./dist && tsc", 32 | "lint": "eslint src --max-warnings=0", 33 | "prepublishOnly": "npm run lint && npm run build", 34 | "watch": "npm run build && npm link && nodemon" 35 | }, 36 | "devDependencies": { 37 | "@eslint/js": "^9.9.0", 38 | "@types/eslint__js": "^8.42.3", 39 | "@types/node": "^22.2.0", 40 | "@types/suncalc": "^1.9.2", 41 | "@typescript-eslint/eslint-plugin": "^8.9.0", 42 | "@typescript-eslint/parser": "^8.9.0", 43 | "eslint": "^9.12.0", 44 | "homebridge": "^2.0.0-beta.0", 45 | "nodemon": "^3.1.4", 46 | "rimraf": "^6.0.1", 47 | "ts-node": "^10.9.2", 48 | "typescript": "^5.5.4", 49 | "typescript-eslint": "^8.0.1" 50 | }, 51 | "dependencies": { 52 | "@homebridge/plugin-ui-utils": "^1.0.3", 53 | "axios": "^1.7.7", 54 | "https": "^1.0.0", 55 | "suncalc": "^1.9.0" 56 | }, 57 | "files": [ 58 | "LICENSE", 59 | "dist", 60 | "config.schema.json", 61 | "package.json", 62 | "README.md", 63 | "homebridge-ui" 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /src/hue-daylight-sync-accessory.ts: -------------------------------------------------------------------------------- 1 | import { PlatformAccessory } from 'homebridge'; 2 | import { HueDaylightSyncPlatform } from './platform'; 3 | import { LightService } from './light-service'; 4 | import { AutoModeService } from './auto-mode-service'; 5 | import { TemperatureCalculator } from './temperature-calculator'; 6 | import { QueueProcessor } from './queue-processor'; 7 | import { Config } from './types'; 8 | import { author, displayName, version } from '../package.json'; 9 | 10 | export class HueDaylightSyncAccessory { 11 | private lightService: LightService; 12 | private autoModeService: AutoModeService; 13 | private temperatureCalculator: TemperatureCalculator; 14 | private queueProcessor: QueueProcessor; 15 | 16 | constructor(private readonly platform: HueDaylightSyncPlatform, private readonly accessory: PlatformAccessory, private readonly config: Config) { 17 | this.temperatureCalculator = new TemperatureCalculator(config, platform.log); 18 | this.queueProcessor = new QueueProcessor(config, platform.log); 19 | this.autoModeService = new AutoModeService(platform, accessory, this.temperatureCalculator, config); 20 | 21 | const { Service, Characteristic } = this.platform; 22 | const accessoryInfoService = this.accessory.getService(Service.AccessoryInformation) || this.accessory.addService(Service.AccessoryInformation); 23 | 24 | accessoryInfoService 25 | .setCharacteristic(Characteristic.Manufacturer, author) 26 | .setCharacteristic(Characteristic.Model, displayName) 27 | .setCharacteristic(Characteristic.SerialNumber, 'Hue') 28 | .setCharacteristic(Characteristic.FirmwareRevision, version); 29 | 30 | this.lightService = new LightService( 31 | platform, 32 | accessory, 33 | this.temperatureCalculator, 34 | this.queueProcessor, 35 | () => this.autoModeService.disableAutoModeDueToManualChange(), 36 | config.inputDebounceDelay, 37 | ); 38 | 39 | this.autoModeService.setLightService(this.lightService); 40 | setInterval(() => this.queueProcessor.processQueue(), 100); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore compiled code 2 | dist 3 | 4 | # ------------- Defaults ------------- # 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variables file 77 | .env 78 | .env.test 79 | 80 | # parcel-bundler cache (https://parceljs.org/) 81 | .cache 82 | .parcel-cache 83 | 84 | # Next.js build output 85 | .next 86 | 87 | # Nuxt.js build / generate output 88 | .nuxt 89 | dist 90 | 91 | # Gatsby files 92 | .cache/ 93 | # Comment in the public line in if your project uses Gatsby and not Next.js 94 | # https://nextjs.org/blog/next-9-1#public-directory-support 95 | # public 96 | 97 | # vuepress build output 98 | .vuepress/dist 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | # TernJS port file 110 | .tern-port 111 | 112 | # Stores VSCode versions used for testing VSCode extensions 113 | .vscode-test 114 | 115 | # yarn v2 116 | 117 | .yarn/cache 118 | .yarn/unplugged 119 | .yarn/build-state.yml 120 | .pnp.* 121 | 122 | # Webstorm 123 | .idea 124 | 125 | # Mac 126 | .DS_Store -------------------------------------------------------------------------------- /config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginAlias": "HueDaylightSync", 3 | "pluginType": "platform", 4 | "singular": true, 5 | "customUi": true, 6 | "schema": { 7 | "type": "object", 8 | "properties": { 9 | "name": { 10 | "title": "Name", 11 | "type": "string", 12 | "required": true, 13 | "default": "Daylight Sync" 14 | }, 15 | "bridgeIp": { 16 | "title": "Hue Bridge IP", 17 | "type": "string", 18 | "required": true, 19 | "format": "ipv4" 20 | }, 21 | "apiToken": { 22 | "title": "Hue API Token", 23 | "type": "string", 24 | "required": true 25 | }, 26 | "latitude": { 27 | "title": "Latitude", 28 | "type": "number", 29 | "required": true, 30 | "minimum": -90, 31 | "maximum": 90 32 | }, 33 | "longitude": { 34 | "title": "Longitude", 35 | "type": "number", 36 | "required": true, 37 | "minimum": -180, 38 | "maximum": 180 39 | }, 40 | "warmTemp": { 41 | "title": "Warm Temperature (K)", 42 | "type": "integer", 43 | "default": 2400, 44 | "minimum": 2000, 45 | "maximum": 6500 46 | }, 47 | "coolTemp": { 48 | "title": "Cool Temperature (K)", 49 | "type": "integer", 50 | "default": 2800, 51 | "minimum": 2000, 52 | "maximum": 6500 53 | }, 54 | "curveExponent": { 55 | "title": "Curve Exponent", 56 | "type": "number", 57 | "minimum": 0.1, 58 | "maximum": 10, 59 | "default": 3, 60 | "description": "Adjusts the steepness of the transition curve." 61 | }, 62 | "updateInterval": { 63 | "title": "Update Interval (ms)", 64 | "type": "integer", 65 | "default": 300000, 66 | "minimum": 1000 67 | }, 68 | "inputDebounceDelay": { 69 | "title": "Input Debounce Delay (ms)", 70 | "type": "integer", 71 | "default": 750, 72 | "minimum": 0 73 | }, 74 | "defaultAutoMode": { 75 | "title": "Default Auto Mode", 76 | "type": "boolean", 77 | "default": true, 78 | "description": "Set to true to enable Auto Mode by default, false to disable." 79 | }, 80 | "excludedLights": { 81 | "title": "Excluded Lights", 82 | "type": "array", 83 | "items": { 84 | "type": "string", 85 | "title": "Light ID" 86 | }, 87 | "description": "List of light IDs to exclude from automatic updates", 88 | "default": [] 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Ignore source code 2 | src 3 | 4 | # ------------- Defaults ------------- # 5 | 6 | # gitHub actions 7 | .github 8 | 9 | # eslint 10 | eslint.config.js 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 | 105 | # Gatsby files 106 | .cache/ 107 | # Comment in the public line in if your project uses Gatsby and not Next.js 108 | # https://nextjs.org/blog/next-9-1#public-directory-support 109 | # public 110 | 111 | # vuepress build output 112 | .vuepress/dist 113 | 114 | # Serverless directories 115 | .serverless/ 116 | 117 | # FuseBox cache 118 | .fusebox/ 119 | 120 | # DynamoDB Local files 121 | .dynamodb/ 122 | 123 | # TernJS port file 124 | .tern-port 125 | 126 | # Stores VSCode versions used for testing VSCode extensions 127 | .vscode-test 128 | 129 | # yarn v2 130 | 131 | .yarn/cache 132 | .yarn/unplugged 133 | .yarn/build-state.yml 134 | .pnp.* 135 | -------------------------------------------------------------------------------- /src/platform.ts: -------------------------------------------------------------------------------- 1 | import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge'; 2 | import { PLATFORM_NAME, PLUGIN_NAME } from './settings'; 3 | import { HueDaylightSyncAccessory } from './hue-daylight-sync-accessory'; 4 | import { Config } from './types'; 5 | 6 | export class HueDaylightSyncPlatform implements DynamicPlatformPlugin { 7 | public readonly Service: typeof Service = this.api.hap.Service; 8 | public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic; 9 | 10 | public readonly accessories: PlatformAccessory[] = []; 11 | 12 | constructor(public readonly log: Logger, public readonly config: PlatformConfig, public readonly api: API) { 13 | this.log.debug('Finished initializing platform:', this.config.name); 14 | 15 | this.api.on('didFinishLaunching', () => { 16 | log.debug('Executed didFinishLaunching callback'); 17 | this.discoverDevices(); 18 | }); 19 | } 20 | 21 | configureAccessory(accessory: PlatformAccessory) { 22 | this.log.info('Loading accessory from cache:', accessory.displayName); 23 | this.accessories.push(accessory); 24 | } 25 | 26 | discoverDevices() { 27 | const uuid = this.api.hap.uuid.generate(PLUGIN_NAME); 28 | 29 | const existingAccessory = this.accessories.find((accessory) => accessory.UUID === uuid); 30 | 31 | const validatedConfig = this.validateConfig(this.config); 32 | if (!validatedConfig) { 33 | this.log.error('Invalid Configuration. Please Check Your Config.json'); 34 | return; 35 | } 36 | 37 | if (existingAccessory) { 38 | this.log.info('Restoring Existing Accessory from Cache:', existingAccessory.displayName); 39 | new HueDaylightSyncAccessory(this, existingAccessory, validatedConfig); 40 | } else { 41 | this.log.info('Adding New Accessory'); 42 | const accessory = new this.api.platformAccessory('Daylight Sync', uuid); 43 | new HueDaylightSyncAccessory(this, accessory, validatedConfig); 44 | this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); 45 | } 46 | } 47 | 48 | private validateConfig(config: PlatformConfig): Config | null { 49 | if ( 50 | typeof config.bridgeIp !== 'string' || 51 | typeof config.apiToken !== 'string' || 52 | typeof config.latitude !== 'number' || 53 | typeof config.longitude !== 'number' 54 | ) { 55 | return null; 56 | } 57 | 58 | return { 59 | ...config, 60 | bridgeIp: config.bridgeIp, 61 | apiToken: config.apiToken, 62 | latitude: config.latitude, 63 | longitude: config.longitude, 64 | updateInterval: config.updateInterval || 300000, 65 | warmTemp: config.warmTemp || 2700, 66 | coolTemp: config.coolTemp || 6500, 67 | inputDebounceDelay: config.inputDebounceDelay || 750, 68 | defaultAutoMode: config.defaultAutoMode ?? true, 69 | }; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/temperature-calculator.ts: -------------------------------------------------------------------------------- 1 | import SunCalc from 'suncalc'; 2 | import { Logger } from 'homebridge'; 3 | import { Config } from './types'; 4 | 5 | export class TemperatureCalculator { 6 | private latitude: number; 7 | private longitude: number; 8 | private warmTemp: number; 9 | private coolTemp: number; 10 | private updateInterval: number; 11 | private curveExponent: number; 12 | 13 | constructor(config: Config, private readonly log: Logger) { 14 | this.latitude = config.latitude; 15 | this.longitude = config.longitude; 16 | this.warmTemp = config.warmTemp || 2700; 17 | this.coolTemp = config.coolTemp || 6500; 18 | this.updateInterval = config.updateInterval || 300000; // 5 minutes 19 | this.curveExponent = config.curveExponent || 3; 20 | } 21 | 22 | async calculateIdealTemp(): Promise { 23 | const transitionFactor = this.calcTransitionFactor(); 24 | return Math.round(this.warmTemp + (this.coolTemp - this.warmTemp) * transitionFactor); 25 | } 26 | 27 | private calcTransitionFactor(): number { 28 | const now = new Date(); 29 | 30 | if (!this.latitude || !this.longitude) { 31 | this.log.error(`Invalid latitude (${this.latitude}) or longitude (${this.longitude})`); 32 | return 0.5; // Default value 33 | } 34 | 35 | // Get current sun position and times 36 | const sunPos = SunCalc.getPosition(now, this.latitude, this.longitude); 37 | const times = SunCalc.getTimes(now, this.latitude, this.longitude); 38 | 39 | // Get the maximum altitude for today 40 | const solarNoon = times.solarNoon; 41 | const maxSunPos = SunCalc.getPosition(solarNoon, this.latitude, this.longitude); 42 | const maxAltitude = maxSunPos.altitude; 43 | const maxAltitudeDegrees = (maxAltitude * 180) / Math.PI; 44 | 45 | // Current altitude in radians 46 | const currentAltitude = sunPos.altitude; 47 | const currentAltitudeDegrees = (currentAltitude * 180) / Math.PI; 48 | 49 | // Before sunrise or after sunset 50 | if (currentAltitude <= 0) { 51 | this.log.info('----------------------------------------'); 52 | this.log.info(`Night Time: ${now.toLocaleTimeString()}`); 53 | this.log.info(`Solar Noon: ${solarNoon.toLocaleTimeString()}`); 54 | this.log.info(`Current Altitude: ${currentAltitudeDegrees.toFixed(2)}°`); 55 | this.log.info(`Max Altitude: ${maxAltitudeDegrees.toFixed(2)}°`); 56 | this.log.info('Transition Factor: 0'); 57 | this.log.info('----------------------------------------'); 58 | 59 | return 0; 60 | } 61 | 62 | // Normalize current altitude relative to today's maximum possible altitude 63 | // This ensures we reach 1.0 at solar noon regardless of season 64 | let transitionFactor = currentAltitude / maxAltitude; 65 | 66 | // Apply the curve exponent to shape the transition 67 | transitionFactor = Math.pow(transitionFactor, this.curveExponent); 68 | 69 | // Ensure we don't exceed 1.0 70 | transitionFactor = Math.min(1, transitionFactor); 71 | 72 | this.log.info('----------------------------------------'); 73 | this.log.info(`Day Time: ${now.toLocaleTimeString()}`); 74 | this.log.info(`Solar Noon: ${solarNoon.toLocaleTimeString()}`); 75 | this.log.info(`Current Altitude: ${currentAltitudeDegrees.toFixed(2)}°`); 76 | this.log.info(`Max Altitude: ${maxAltitudeDegrees.toFixed(2)}°`); 77 | this.log.info(`Transition Factor: ${(transitionFactor * 100).toFixed(1)}%`); 78 | this.log.info('----------------------------------------'); 79 | 80 | return transitionFactor; 81 | } 82 | 83 | getWarmTemp(): number { 84 | return this.warmTemp; 85 | } 86 | 87 | getCoolTemp(): number { 88 | return this.coolTemp; 89 | } 90 | 91 | getUpdateInterval(): number { 92 | return this.updateInterval; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/auto-mode-service.ts: -------------------------------------------------------------------------------- 1 | import { Service, PlatformAccessory, CharacteristicValue } from 'homebridge'; 2 | import { HueDaylightSyncPlatform } from './platform'; 3 | import { LightService } from './light-service'; 4 | import { TemperatureCalculator } from './temperature-calculator'; 5 | import { Config } from './types'; 6 | 7 | export class AutoModeService { 8 | private service: Service; 9 | private isAutoMode: boolean; 10 | private autoUpdateInterval: NodeJS.Timeout | null = null; 11 | private lightService!: LightService; 12 | 13 | constructor( 14 | private readonly platform: HueDaylightSyncPlatform, 15 | private readonly accessory: PlatformAccessory, 16 | private readonly temperatureCalculator: TemperatureCalculator, 17 | private readonly config: Config, 18 | ) { 19 | this.isAutoMode = config.defaultAutoMode ?? true; 20 | this.service = this.accessory.getService('Auto Mode') || this.accessory.addService(this.platform.Service.Switch, 'Auto Mode', 'auto-mode'); 21 | this.service.getCharacteristic(this.platform.Characteristic.On).onSet(this.setAutoMode.bind(this)).onGet(this.getAutoMode.bind(this)); 22 | } 23 | 24 | private async initializeAutoMode() { 25 | this.platform.log.info(`Auto Mode: ${this.isAutoMode ? 'ON' : 'OFF'}`); 26 | this.service.updateCharacteristic(this.platform.Characteristic.On, this.isAutoMode); 27 | if (this.isAutoMode) { 28 | await this.updateTemperature(); 29 | this.startAutoUpdate(); 30 | } else { 31 | await this.lightService.updateBrightnessBasedOnTemperature(); 32 | } 33 | } 34 | 35 | public setLightService(lightService: LightService) { 36 | this.lightService = lightService; 37 | this.initializeAutoMode(); 38 | } 39 | 40 | async setAutoMode(value: CharacteristicValue) { 41 | this.isAutoMode = value as boolean; 42 | this.platform.log.info('Auto Mode:', value); 43 | if (this.isAutoMode) { 44 | await this.updateTemperature(); 45 | this.startAutoUpdate(); 46 | } else { 47 | this.stopAutoUpdate(); 48 | await this.lightService.updateBrightnessBasedOnTemperature(); 49 | } 50 | } 51 | 52 | async getAutoMode(): Promise { 53 | return this.isAutoMode; 54 | } 55 | 56 | public disableAutoModeDueToManualChange() { 57 | if (this.isAutoMode) { 58 | this.platform.log.info('Manual Change Detected: Auto Mode Disabled'); 59 | this.isAutoMode = false; 60 | this.service.updateCharacteristic(this.platform.Characteristic.On, this.isAutoMode); 61 | this.stopAutoUpdate(); 62 | } 63 | } 64 | 65 | private startAutoUpdate() { 66 | this.stopAutoUpdate(); 67 | this.updateTemperature(); 68 | this.autoUpdateInterval = setInterval(() => { 69 | this.updateTemperature(); 70 | }, this.temperatureCalculator.getUpdateInterval()); 71 | this.platform.log.debug('Auto update started'); 72 | } 73 | 74 | private stopAutoUpdate() { 75 | if (this.autoUpdateInterval) { 76 | clearInterval(this.autoUpdateInterval); 77 | this.autoUpdateInterval = null; 78 | this.platform.log.debug('Auto Update Stopped'); 79 | } 80 | } 81 | 82 | private async updateTemperature() { 83 | if (!this.isAutoMode) { 84 | return; 85 | } 86 | const currentTemp = this.lightService.getCurrentTemp(); 87 | const targetTemp = await this.temperatureCalculator.calculateIdealTemp(); 88 | 89 | this.platform.log.info('Auto Mode Update'); 90 | this.platform.log.info(`Current Temp: ${currentTemp}K`); 91 | this.platform.log.info(`Target Temp: ${targetTemp}K`); 92 | this.platform.log.info('----------------------------------------'); 93 | 94 | if (currentTemp !== targetTemp) { 95 | await this.lightService.updateTemperature(targetTemp); 96 | } else { 97 | this.platform.log.debug(`Temperature remains unchanged at ${currentTemp}K`); 98 | // Ensure brightness is updated even if temperature hasn't changed 99 | await this.lightService.updateBrightnessBasedOnTemperature(); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/queue-processor.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import https from 'https'; 3 | import { Logger } from 'homebridge'; 4 | import { Config, LightState, QueueItem } from './types'; 5 | 6 | export class QueueProcessor { 7 | private bridgeIp: string; 8 | private apiToken: string; 9 | private excludedLights: string[]; 10 | private requestQueue: QueueItem[] = []; 11 | private isProcessingQueue = false; 12 | private readonly MAX_RETRIES = 3; 13 | private readonly RETRY_DELAY = 1000; // 1 second 14 | 15 | constructor(config: Config, private readonly log: Logger) { 16 | this.bridgeIp = config.bridgeIp; 17 | this.apiToken = config.apiToken; 18 | this.excludedLights = config.excludedLights || []; 19 | } 20 | 21 | async getLightState(): Promise { 22 | if (!this.bridgeIp) { 23 | this.log.error('Cannot get light state: Hue Bridge IP is not set'); 24 | throw new Error('Hue Bridge IP not set'); 25 | } 26 | 27 | const url = `https://${this.bridgeIp}/clip/v2/resource/light`; 28 | const headers = { 'hue-application-key': this.apiToken }; 29 | 30 | try { 31 | const response = await axios.get(url, { 32 | headers, 33 | httpsAgent: new https.Agent({ rejectUnauthorized: false }), 34 | }); 35 | 36 | if (response.status === 200) { 37 | const lights = response.data.data as Record[]; 38 | if (lights.length > 0) { 39 | const firstLight = lights[0]; 40 | const isOn = (firstLight.on as { on: boolean })?.on ?? false; 41 | const colorTemp = (firstLight.color_temperature as { mirek: number })?.mirek; 42 | if (colorTemp) { 43 | const kelvin = Math.round(1000000 / colorTemp); 44 | this.log.debug(`Light state - On: ${isOn}, Temperature: ${kelvin}K`); 45 | return { isOn, temperature: kelvin }; 46 | } 47 | } 48 | throw new Error('No lights found or no color temperature data available'); 49 | } else { 50 | throw new Error(`Failed to get lights: ${response.status}`); 51 | } 52 | } catch (error) { 53 | if (axios.isAxiosError(error)) { 54 | this.log.error(`Error fetching light state: ${error.message}`); 55 | } else { 56 | this.log.error('Unknown error fetching light state'); 57 | } 58 | throw error; 59 | } 60 | } 61 | 62 | async updateLightsColor(targetTemp: number) { 63 | if (!this.bridgeIp) { 64 | this.log.error('Cannot update lights: Hue Bridge IP is not set'); 65 | return; 66 | } 67 | 68 | const url = `https://${this.bridgeIp}/clip/v2/resource/light`; 69 | const headers = { 'hue-application-key': this.apiToken }; 70 | 71 | try { 72 | const response = await axios.get(url, { 73 | headers, 74 | httpsAgent: new https.Agent({ rejectUnauthorized: false }), 75 | }); 76 | 77 | if (response.status === 200) { 78 | const lights = response.data.data as Record[]; 79 | for (const light of lights) { 80 | const lightId = light.id as string; 81 | const lightName = ((light.metadata as Record)?.name as string) || 'Unknown'; 82 | 83 | // Skip excluded lights 84 | if (this.excludedLights.includes(lightId)) { 85 | this.log.debug(`Skipping excluded light: ${lightName} (${lightId})`); 86 | continue; 87 | } 88 | 89 | this.queueLightUpdate(lightId, lightName, targetTemp); 90 | } 91 | } else { 92 | this.log.error(`Failed to get lights: ${response.status}`); 93 | } 94 | } catch (error) { 95 | if (axios.isAxiosError(error)) { 96 | this.log.error(`Error fetching lights: ${error.message}`); 97 | } else { 98 | this.log.error('Unknown error fetching lights'); 99 | } 100 | } 101 | } 102 | 103 | private queueLightUpdate(lightId: string, lightName: string, kelvin: number) { 104 | this.requestQueue.push({ 105 | lightId, 106 | lightName, 107 | kelvin, 108 | retries: 0, 109 | }); 110 | } 111 | 112 | async processQueue() { 113 | if (this.isProcessingQueue || this.requestQueue.length === 0) { 114 | return; 115 | } 116 | 117 | this.isProcessingQueue = true; 118 | const item = this.requestQueue.shift(); 119 | 120 | if (item) { 121 | try { 122 | await this.changeLightSettings(item.lightId, item.lightName, item.kelvin); 123 | } catch (error) { 124 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 125 | item.lastError = errorMessage; 126 | 127 | if (item.retries < this.MAX_RETRIES) { 128 | item.retries++; 129 | this.log.warn(`Failed to update ${item.lightName} (Attempt ${item.retries}/${this.MAX_RETRIES}): ${errorMessage}`); 130 | 131 | // Add back to queue with delay if it's not the last retry 132 | setTimeout(() => { 133 | this.requestQueue.unshift(item); 134 | this.log.debug(`Retrying ${item.lightName} in ${this.RETRY_DELAY}ms`); 135 | }, this.RETRY_DELAY * Math.pow(2, item.retries - 1)); // Exponential backoff 136 | } else { 137 | this.log.error(`Failed to update ${item.lightName} after ${this.MAX_RETRIES} attempts. Last error: ${item.lastError}`); 138 | } 139 | } 140 | } 141 | 142 | this.isProcessingQueue = false; 143 | 144 | // Process next item after a short delay 145 | if (this.requestQueue.length > 0) { 146 | setTimeout(() => this.processQueue(), 100); 147 | } 148 | } 149 | 150 | private async changeLightSettings(lightId: string, lightName: string, kelvin: number) { 151 | const url = `https://${this.bridgeIp}/clip/v2/resource/light/${lightId}`; 152 | const headers = { 'hue-application-key': this.apiToken }; 153 | const mirek = Math.round(1000000 / kelvin); 154 | const data = { color_temperature: { mirek } }; 155 | 156 | try { 157 | const response = await axios.put(url, data, { 158 | headers, 159 | httpsAgent: new https.Agent({ rejectUnauthorized: false }), 160 | }); 161 | 162 | if (response.status === 200) { 163 | this.log.info(`${lightName}: ${kelvin}K`); 164 | } else { 165 | throw new Error(`HTTP ${response.status}`); 166 | } 167 | } catch (error) { 168 | if (axios.isAxiosError(error)) { 169 | if (error.response?.status === 429) { 170 | throw new Error('Rate limit exceeded'); 171 | } 172 | throw new Error(error.message); 173 | } 174 | throw error; 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/light-service.ts: -------------------------------------------------------------------------------- 1 | import { Service, PlatformAccessory, CharacteristicValue } from 'homebridge'; 2 | import { HueDaylightSyncPlatform } from './platform'; 3 | import { TemperatureCalculator } from './temperature-calculator'; 4 | import { QueueProcessor } from './queue-processor'; 5 | import { kelvinToMired, miredToKelvin, kelvinToSliderPosition, sliderPositionToKelvin } from './utils'; 6 | 7 | export class LightService { 8 | private service: Service; 9 | private isOn = true; 10 | private currentTemp: number; 11 | private isUpdating = false; 12 | private updateTimeout: NodeJS.Timeout | null = null; 13 | 14 | constructor( 15 | private readonly platform: HueDaylightSyncPlatform, 16 | private readonly accessory: PlatformAccessory, 17 | private readonly temperatureCalculator: TemperatureCalculator, 18 | private readonly queueProcessor: QueueProcessor, 19 | private readonly onManualChange: () => void, 20 | private readonly inputDebounceDelay: number = 750, 21 | ) { 22 | this.currentTemp = this.temperatureCalculator.getWarmTemp(); 23 | this.service = this.setupService(); 24 | this.updateHomeKitCharacteristics(); 25 | } 26 | 27 | private setupService(): Service { 28 | const service = this.accessory.getService(this.platform.Service.Lightbulb) || this.accessory.addService(this.platform.Service.Lightbulb); 29 | service.setCharacteristic(this.platform.Characteristic.Name, 'Daylight Sync'); 30 | 31 | service 32 | .getCharacteristic(this.platform.Characteristic.On) 33 | .onSet(this.setOn.bind(this)) 34 | .onGet(() => this.isOn); 35 | 36 | service 37 | .getCharacteristic(this.platform.Characteristic.Brightness) 38 | .onSet(this.setBrightness.bind(this)) 39 | .onGet(() => this.getSliderPosition()); 40 | 41 | service 42 | .getCharacteristic(this.platform.Characteristic.ColorTemperature) 43 | .onSet(this.setColorTemperature.bind(this)) 44 | .onGet(() => kelvinToMired(this.currentTemp)) 45 | .setProps({ 46 | minValue: kelvinToMired(this.temperatureCalculator.getCoolTemp()), 47 | maxValue: kelvinToMired(this.temperatureCalculator.getWarmTemp()), 48 | minStep: 1, 49 | }); 50 | 51 | return service; 52 | } 53 | 54 | async setOn(value: CharacteristicValue) { 55 | this.isOn = value as boolean; 56 | this.platform.log.info('Set Characteristic On:', value); 57 | this.updateHomeKitCharacteristics(); 58 | 59 | if (this.isOn) { 60 | this.debounceUpdate(() => this.updateLights(this.currentTemp)); 61 | } 62 | } 63 | 64 | async setBrightness(value: CharacteristicValue) { 65 | const sliderPosition = value as number; 66 | const newTemp = this.getKelvinFromSliderPosition(sliderPosition); 67 | 68 | this.platform.log.info('----------------------------------------'); 69 | this.platform.log.info('Manual Update'); 70 | this.platform.log.info(`Brightness Slider: ${sliderPosition}%`); 71 | this.platform.log.info(`Target Temp: ${newTemp}K`); 72 | this.platform.log.info('----------------------------------------'); 73 | 74 | this.onManualChange(); 75 | this.debounceUpdate(() => this.updateLights(newTemp)); 76 | } 77 | 78 | async setColorTemperature(value: CharacteristicValue) { 79 | const mired = value as number; 80 | const kelvin = miredToKelvin(mired); 81 | const sliderPosition = this.getSliderPosition(kelvin); 82 | 83 | this.platform.log.info('----------------------------------------'); 84 | this.platform.log.info('Manual Update'); 85 | this.platform.log.info(`Color Slider: ${kelvin}K`); 86 | this.platform.log.info(`Slider Position: ${sliderPosition}%`); 87 | this.platform.log.info('----------------------------------------'); 88 | 89 | this.onManualChange(); 90 | this.debounceUpdate(() => this.updateLights(kelvin)); 91 | } 92 | 93 | getCurrentTemp(): number { 94 | return this.currentTemp; 95 | } 96 | 97 | async updateTemperature(newTemp: number) { 98 | const sliderPosition = this.getSliderPosition(newTemp); 99 | this.platform.log.info(`Brightness Slider: ${sliderPosition}%`); 100 | this.currentTemp = newTemp; 101 | this.updateHomeKitCharacteristics(); 102 | this.updateBrightnessBasedOnTemperature(); 103 | this.debounceUpdate(() => this.updateLights(newTemp)); 104 | } 105 | 106 | async updateBrightnessBasedOnTemperature() { 107 | const sliderPosition = this.getSliderPosition(); 108 | this.service.updateCharacteristic(this.platform.Characteristic.Brightness, sliderPosition); 109 | this.platform.log.debug(`Brightness characteristic value after update: ${this.service.getCharacteristic(this.platform.Characteristic.Brightness).value}`); 110 | } 111 | 112 | private getSliderPosition(temp: number = this.currentTemp): number { 113 | return kelvinToSliderPosition(temp, this.temperatureCalculator.getWarmTemp(), this.temperatureCalculator.getCoolTemp()); 114 | } 115 | 116 | private getKelvinFromSliderPosition(position: number): number { 117 | return sliderPositionToKelvin(position, this.temperatureCalculator.getWarmTemp(), this.temperatureCalculator.getCoolTemp()); 118 | } 119 | 120 | private debounceUpdate(updateFn: () => Promise) { 121 | if (this.updateTimeout) { 122 | clearTimeout(this.updateTimeout); 123 | } 124 | this.updateTimeout = setTimeout(async () => { 125 | await updateFn(); 126 | this.updateTimeout = null; 127 | }, this.inputDebounceDelay); 128 | } 129 | 130 | private async updateLights(newTemp: number) { 131 | this.currentTemp = newTemp; 132 | this.updateHomeKitCharacteristics(); 133 | if (this.isOn) { 134 | await this.performUpdate(); 135 | } 136 | } 137 | 138 | private updateHomeKitCharacteristics(service: Service = this.service) { 139 | const mired = kelvinToMired(this.currentTemp); 140 | const sliderPosition = this.getSliderPosition(); 141 | 142 | service.updateCharacteristic(this.platform.Characteristic.ColorTemperature, mired); 143 | service.updateCharacteristic(this.platform.Characteristic.Brightness, sliderPosition); 144 | service.updateCharacteristic(this.platform.Characteristic.On, this.isOn); 145 | 146 | this.platform.log.debug(`Updated HomeKit - ColorTemperature: ${this.currentTemp}K, Brightness Slider: ${sliderPosition}%, On: ${this.isOn}`); 147 | } 148 | 149 | private async performUpdate(): Promise { 150 | if (this.isUpdating) { 151 | this.platform.log.debug('Update already in progress, scheduling another update'); 152 | return; 153 | } 154 | 155 | this.isUpdating = true; 156 | 157 | try { 158 | await this.queueProcessor.updateLightsColor(this.currentTemp); 159 | this.updateHomeKitCharacteristics(); 160 | } catch (error) { 161 | this.platform.log.error('Error updating lights:', error); 162 | } finally { 163 | this.isUpdating = false; 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | 6 | # Hue Daylight Sync 7 | 8 | [![npm version](https://img.shields.io/npm/v/homebridge-hue-daylight-sync 9 | )](https://badge.fury.io/js/homebridge-hue-daylight-sync) 10 | [![npm downloads](https://img.shields.io/npm/d18m/homebridge-hue-daylight-sync.svg)](https://www.npmjs.com/package/homebridge-hue-daylight-sync) 11 | [![GitHub Issues](https://img.shields.io/github/issues/JoshBello/homebridge-hue-daylight-sync)](https://github.com/JoshBello/homebridge-hue-daylight-sync/issues) 12 | [![GitHub pull requests](https://img.shields.io/github/issues-pr/JoshBello/homebridge-hue-daylight-sync/open)](https://github.com/JoshBello/homebridge-hue-daylight-sync/pulls) 13 | [![verified-by-homebridge](https://badgen.net/badge/homebridge/verified/purple)](https://github.com/homebridge/homebridge/wiki/Verified-Plugins) 14 | 15 | 16 | 17 | ## Hue Daylight Sync: Automated Philips Hue Light Adjustment for Natural Daylight Cycles 18 | 19 | Hue Daylight Sync is a Homebridge plugin that automatically adjusts your Philips Hue lights based on the natural daylight cycle. It calculates the ideal color temperature throughout the day based on your geographical location and smoothly transitions your lights to match. 20 | 21 | ## Demo 22 | 23 | 24 | 25 | ## Features 26 | 27 | - Natural Bell Curve Color Temperature Transitions 28 | - Smooth, Gradual Changes: The plugin now uses a cosine function to create a smooth, bell-shaped curve for color temperature transitions throughout the day. 29 | - Enhanced Natural Lighting: Mimics the natural progression of daylight, providing a more comfortable and realistic lighting environment. 30 | - Automatic color temperature adjustment based on time of day 31 | - Customizable warm and cool temperature ranges 32 | - Geolocation-based calculations for accurate sunlight mimicking 33 | - Manual override option with automatic mode switch 34 | - Exclude specific lights from automatic adjustments 35 | - Smart retry mechanism for reliable updates 36 | 37 | ## Auto Mode and Manual Adjustments 38 | 39 | - **Auto Mode**: Automatically adjusts your lights' color temperature based on the time of day. Enabled by default. 40 | - **Manual Adjustments**: Changing the brightness slider or color temperature in the Home app will disable Auto Mode, allowing you to set your preferred lighting. 41 | - **Re-Enabling Auto Mode**: To resume automatic adjustments, simply toggle the "Auto Mode" switch back on in the Home app. 42 | - **Excluded Lights**: Specify lights that should never be automatically adjusted, perfect for accent lighting or areas where you want consistent color temperature. 43 | 44 | ## Prerequisites 45 | 46 | - [Homebridge](https://homebridge.io/) installed on your system 47 | - Philips Hue Bridge with API access - [Hue Guide](https://developers.meethue.com/develop/hue-api-v2/getting-started/#follow-3-easy-steps) 48 | - Philips Hue color temperature capable lights 49 | 50 | ## Installation 51 | 52 | 1. Install the plugin through Homebridge UI or manually: 53 | 54 | ```bash 55 | npm install -g homebridge-hue-daylight-sync 56 | ``` 57 | 58 | 2. Configure the plugin in your Homebridge `config.json` or through the Homebridge UI. 59 | 60 | ## Configuration 61 | 62 | Add the following to your Homebridge `config.json` file: 63 | 64 | ```json 65 | { 66 | "platforms": [ 67 | { 68 | "platform": "HueDaylightSync", 69 | "name": "Hue Daylight Sync", 70 | "bridgeIp": "YOUR_HUE_BRIDGE_IP", 71 | "apiToken": "YOUR_HUE_API_TOKEN", 72 | "latitude": 51.5072, // Replace with your latitude 73 | "longitude": 0.1276, // Replace with your longitude 74 | "warmTemp": 2700, 75 | "coolTemp": 3000, 76 | "curveExponent": 3, 77 | "updateInterval": 300000, 78 | "inputDebounceDelay": 750, 79 | "defaultAutoMode": true, 80 | "excludedLights": [] // Array of light IDs to exclude from auto adjustments 81 | } 82 | ] 83 | } 84 | ``` 85 | 86 | ### Configuration Options 87 | 88 | | Option | Type | Description | Default | 89 | |--------|------|-------------|---------| 90 | | `bridgeIp` | `string` | The IP address of your Hue Bridge | Required | 91 | | `apiToken` | `string` | Your Hue API token | Required | 92 | | `latitude` | `number` | Your geographical decimal degrees latitude. Example: 51.5072 for London, but use your location's coordinates | Required | 93 | | `longitude` | `number` | Your geographical decimal degrees longitude. Example: 0.1276 for London, but use your location's coordinates | Required | 94 | | `warmTemp` | `number` | Warmest color temperature in Kelvin | 2700 | 95 | | `coolTemp` | `number` | Coolest color temperature in Kelvin | 3000 | 96 | | `curveExponent` | `number` | Adjusts the steepness of the transition curve | 3 | 97 | | `updateInterval` | `number` | Interval in milliseconds between temperature updates | 300000 (5 minutes) | 98 | | `inputDebounceDelay` | `number` | Delay in milliseconds to prevent rapid successive updates | 750 | 99 | | `defaultAutoMode` | `boolean` | Enable Auto Mode by default | true | 100 | | `excludedLights` | `string[]` | Array of light IDs to exclude from automatic adjustments | [] | 101 | 102 | **Note**: For `latitude` and `longitude`, you must enter the coordinates for your specific location. The values shown in the example are for London, UK - make sure to replace these with your own coordinates. 103 | 104 | You can find your coordinates using online services like Google Maps or websites like https://www.latlong.net/ 105 | 106 | ### Excluding Lights 107 | 108 | To exclude specific lights from automatic adjustments: 109 | 110 | 1. Find the light ID from your Hue Bridge (using the Hue API or developer tools) 111 | 2. Add the light ID to the `excludedLights` array in your config 112 | 3. Excluded lights will maintain their manual settings and won't be affected by the daylight sync 113 | 114 | Example configuration with excluded lights: 115 | ```json 116 | { 117 | "excludedLights": ["light1", "light2"] 118 | } 119 | ``` 120 | 121 | ## Usage 122 | 123 | Once installed and configured, the plugin will appear in your Home app as a light accessory with an additional switch for the Auto Mode. 124 | 125 | - Toggle the main switch to turn the Daylight Sync on or off. 126 | - Use the brightness slider to manually adjust the color temperature. 127 | - Toggle the Auto Mode switch to enable or disable automatic temperature adjustments. 128 | - Excluded lights will maintain their manual settings regardless of Auto Mode status. 129 | 130 | When Auto Mode is enabled, the plugin will automatically adjust your Hue lights' color temperature throughout the day to match natural daylight patterns. 131 | 132 | ## Troubleshooting 133 | 134 | If you encounter any issues: 135 | 136 | 1. Check your Homebridge logs for any error messages. 137 | 2. Ensure your Hue Bridge IP and API token are correct. 138 | 3. Verify that your latitude and longitude are set correctly. 139 | 4. Make sure your Hue lights support color temperature adjustments. 140 | 5. Verify that excluded light IDs are correct if using that feature. 141 | 142 | ## Contributing 143 | 144 | Contributions are welcome! Please feel free to submit a Pull Request. 145 | 146 | ## License 147 | 148 | This project is licensed under the MIT License. 149 | 150 | ## Acknowledgements 151 | 152 | - [Homebridge](https://homebridge.io/) for making this integration possible. 153 | - [Philips Hue](https://www.philips-hue.com/) for their smart lighting system and API. -------------------------------------------------------------------------------- /homebridge-ui/public/index.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Hue Daylight Sync

4 |
Automated Philips Hue Light Adjustment for Natural Daylight Cycles
5 |
6 | 7 | 8 |
9 |
Bridge Settings
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 |
19 | 20 | 21 |
22 |
Location Settings
23 |
24 | 25 | 26 |
27 |
28 | 29 | 30 |
31 |
32 | 33 | 34 |
35 |
36 | 37 |
38 | 39 |
40 |
41 |
Temperature Range (K):
42 |
43 |
44 | 45 | 46 |
47 |
48 | 49 | 50 |
51 |
52 | 53 |
54 |
Curve Exponent:
55 |
56 | 57 |
58 |
59 |
60 |
61 | 62 | 63 | 64 | 65 |
66 |
Lights to Exclude:
67 |
68 | 69 |
70 | 71 | 72 | 73 |
74 |
75 | 76 | 77 | 78 |
79 | 80 | 81 | 293 | 294 | 637 | 638 | 639 | 640 | 641 | -------------------------------------------------------------------------------- /homebridge-ui/public/js/modules/jquery.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery v3.5.1 | (c) JS Foundation and other contributors | jquery.org/license */ 2 | !function(e,t){'use strict';'object'==typeof module&&'object'==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error('jQuery requires a window with a document');return t(e);}:t(e);}('undefined'!=typeof window?window:this,function(C,e){'use strict';var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e);}:function(e){return t.concat.apply([],e);},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return'function'==typeof e&&'number'!=typeof e.nodeType;},x=function(e){return null!=e&&e===e.window;},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement('script');if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o);}function w(e){return null==e?e+'':'object'==typeof e||'function'==typeof e?n[o.call(e)]||'object':typeof e;}var f='3.5.1',S=function(e,t){return new S.fn.init(e,t);};function p(e){var t=!!e&&'length'in e&&e.length,n=w(e);return!m(e)&&!x(e)&&('array'===n||0===t||'number'==typeof t&&0+~]|'+M+')'+M+'*'),U=new RegExp(M+'|>'),X=new RegExp(F),V=new RegExp('^'+I+'$'),G={ID:new RegExp('^#('+I+')'),CLASS:new RegExp('^\\.('+I+')'),TAG:new RegExp('^('+I+'|[*])'),ATTR:new RegExp('^'+W),PSEUDO:new RegExp('^'+F),CHILD:new RegExp('^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\('+M+'*(even|odd|(([+-]|)(\\d*)n|)'+M+'*(?:([+-]|)'+M+'*(\\d+)|))'+M+'*\\)|)','i'),bool:new RegExp('^(?:'+R+')$','i'),needsContext:new RegExp('^'+M+'*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\('+M+'*((?:-\\d)?\\d*)'+M+'*\\)|)(?=[^-]|$)','i')},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp('\\\\[\\da-fA-F]{1,6}'+M+'?|\\\\([^\\r\\n\\f])','g'),ne=function(e,t){var n='0x'+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320));},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?'\0'===e?'\ufffd':e.slice(0,-1)+'\\'+e.charCodeAt(e.length-1).toString(16)+' ':'\\'+e;},oe=function(){T();},ae=be(function(e){return!0===e.disabled&&'fieldset'===e.nodeName.toLowerCase();},{dir:'parentNode',next:'legend'});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType;}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t));}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1;}};}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],'string'!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n;}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n;}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n;}if(d.qsa&&!N[t+' ']&&(!v||!v.test(t))&&(1!==p||'object'!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute('id'))?s=s.replace(re,ie):e.setAttribute('id',s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?'#'+s:':scope')+' '+xe(l[o]);c=l.join(',');}try{return H.apply(n,f.querySelectorAll(c)),n;}catch(e){N(t,!0);}finally{s===S&&e.removeAttribute('id');}}}return g(t.replace($,'$1'),e,n,r);}function ue(){var r=[];return function e(t,n){return r.push(t+' ')>b.cacheLength&&delete e[r.shift()],e[t+' ']=n;};}function le(e){return e[S]=!0,e;}function ce(e){var t=C.createElement('fieldset');try{return!!e(t);}catch(e){return!1;}finally{t.parentNode&&t.parentNode.removeChild(t),t=null;}}function fe(e,t){var n=e.split('|'),r=n.length;while(r--)b.attrHandle[n[r]]=t;}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1;}function de(t){return function(e){return'input'===e.nodeName.toLowerCase()&&e.type===t;};}function he(n){return function(e){var t=e.nodeName.toLowerCase();return('input'===t||'button'===t)&&e.type===n;};}function ge(t){return function(e){return'form'in e?e.parentNode&&!1===e.disabled?'label'in e?'label'in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:'label'in e&&e.disabled===t;};}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]));});});}function ye(e){return e&&'undefined'!=typeof e.getElementsByTagName&&e;}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||'HTML');},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener('unload',oe,!1):n.attachEvent&&n.attachEvent('onunload',oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement('div')),'undefined'!=typeof e.querySelectorAll&&!e.querySelectorAll(':scope fieldset div').length;}),d.attributes=ce(function(e){return e.className='i',!e.getAttribute('className');}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment('')),!e.getElementsByTagName('*').length;}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length;}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute('id')===t;};},b.find.ID=function(e,t){if('undefined'!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[];}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t='undefined'!=typeof e.getAttributeNode&&e.getAttributeNode('id');return t&&t.value===n;};},b.find.ID=function(e,t){if('undefined'!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode('id'))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode('id'))&&n.value===e)return[o];}return[];}}),b.find.TAG=d.getElementsByTagName?function(e,t){return'undefined'!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0;}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if('*'===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r;}return o;},b.find.CLASS=d.getElementsByClassName&&function(e,t){if('undefined'!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e);},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML='',e.querySelectorAll('[msallowcapture^=\'\']').length&&v.push('[*^$]='+M+'*(?:\'\'|"")'),e.querySelectorAll('[selected]').length||v.push('\\['+M+'*(?:value|'+R+')'),e.querySelectorAll('[id~='+S+'-]').length||v.push('~='),(t=C.createElement('input')).setAttribute('name',''),e.appendChild(t),e.querySelectorAll('[name=\'\']').length||v.push('\\['+M+'*name'+M+'*='+M+'*(?:\'\'|"")'),e.querySelectorAll(':checked').length||v.push(':checked'),e.querySelectorAll('a#'+S+'+*').length||v.push('.#.+[+~]'),e.querySelectorAll('\\\f'),v.push('[\\r\\n\\f]');}),ce(function(e){e.innerHTML='';var t=C.createElement('input');t.setAttribute('type','hidden'),e.appendChild(t).setAttribute('name','D'),e.querySelectorAll('[name=d]').length&&v.push('name'+M+'*[*^$|!~]?='),2!==e.querySelectorAll(':enabled').length&&v.push(':enabled',':disabled'),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(':disabled').length&&v.push(':enabled',':disabled'),e.querySelectorAll('*,:x'),v.push(',.*:');})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,'*'),c.call(e,'[s!=\'\']:x'),s.push('!=',F);}),v=v.length&&new RegExp(v.join('|')),s=s.length&&new RegExp(s.join('|')),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)));}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1;},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1);}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0;}),C;},se.matches=function(e,t){return se(e,null,null,t);},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+' ']&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n;}catch(e){N(t,!0);}return 0':{dir:'parentNode',first:!0},' ':{dir:'parentNode'},'+':{dir:'previousSibling',first:!0},'~':{dir:'previousSibling'}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||'').replace(te,ne),'~='===e[2]&&(e[3]=' '+e[3]+' '),e.slice(0,4);},CHILD:function(e){return e[1]=e[1].toLowerCase(),'nth'===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*('even'===e[3]||'odd'===e[3])),e[5]=+(e[7]+e[8]||'odd'===e[3])):e[3]&&se.error(e[0]),e;},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||'':n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(')',n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3));}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return'*'===e?function(){return!0;}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t;};},CLASS:function(e){var t=m[e+' '];return t||(t=new RegExp('(^|'+M+')'+e+'('+M+'|$)'))&&m(e,function(e){return t.test('string'==typeof e.className&&e.className||'undefined'!=typeof e.getAttribute&&e.getAttribute('class')||'');});},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?'!='===r:!r||(t+='','='===r?t===i:'!='===r?t!==i:'^='===r?i&&0===t.indexOf(i):'*='===r?i&&-1','#'===e.firstChild.getAttribute('href');})||fe('type|href|height|width',function(e,t,n){if(!n)return e.getAttribute(t,'type'===t.toLowerCase()?1:2);}),d.attributes&&ce(function(e){return e.innerHTML='',e.firstChild.setAttribute('value',''),''===e.firstChild.getAttribute('value');})||fe('value',function(e,t,n){if(!n&&'input'===e.nodeName.toLowerCase())return e.defaultValue;}),ce(function(e){return null==e.getAttribute('disabled');})||fe(R,function(e,t,n){var r;if(!n)return!0===e[t]?t.toLowerCase():(r=e.getAttributeNode(t))&&r.specified?r.value:null;}),se;}(C);S.find=d,S.expr=d.selectors,S.expr[':']=S.expr.pseudos,S.uniqueSort=S.unique=d.uniqueSort,S.text=d.getText,S.isXMLDoc=d.isXML,S.contains=d.contains,S.escapeSelector=d.escape;var h=function(e,t,n){var r=[],i=void 0!==n;while((e=e[t])&&9!==e.nodeType)if(1===e.nodeType){if(i&&S(e).is(n))break;r.push(e);}return r;},T=function(e,t){for(var n=[];e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n;},k=S.expr.match.needsContext;function A(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase();}var N=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r;}):n.nodeType?S.grep(e,function(e){return e===n!==r;}):'string'!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,'string'==typeof e){if(!(r='<'===e[0]&&'>'===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this;}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this;}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this);}).prototype=S.fn,j=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e;}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement('div')),(fe=E.createElement('input')).setAttribute('type','radio'),fe.setAttribute('checked','checked'),fe.setAttribute('name','t'),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML='',y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML='',y.option=!!ce.lastChild;var ge={thead:[1,'','
'],col:[2,'','
'],tr:[2,'','
'],td:[3,'','
'],_default:[0,'','']};function ve(e,t){var n;return n='undefined'!=typeof e.getElementsByTagName?e.getElementsByTagName(t||'*'):'undefined'!=typeof e.querySelectorAll?e.querySelectorAll(t||'*'):[],void 0===t||t&&A(e,t)?S.merge([e],n):n;}function ye(e,t){for(var n=0,r=e.length;n','']);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,'table')&&A(11!==t.nodeType?t:t.firstChild,'tr')&&S(e).children('tbody')[0]||e;}function Le(e){return e.type=(null!==e.getAttribute('type'))+'/'+e.type,e;}function He(e){return'true/'===(e.type||'').slice(0,5)?e.type=e.type.slice(5):e.removeAttribute('type'),e;}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,'handle events'),s)for(n=0,r=s[i].length;n').attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on('load error',i=function(e){r.remove(),i=null,e&&t('error'===e.type?404:200,e.type);}),E.head.appendChild(r[0]);},abort:function(){i&&i();}};});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:'callback',jsonpCallback:function(){var e=Xt.pop()||S.expando+'_'+Ct.guid++;return this[e]=!0,e;}}),S.ajaxPrefilter('json jsonp',function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?'url':'string'==typeof e.data&&0===(e.contentType||'').indexOf('application/x-www-form-urlencoded')&&Vt.test(e.data)&&'data');if(a||'jsonp'===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,'$1'+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?'&':'?')+e.jsonp+'='+r),e.converters['script json']=function(){return o||S.error(r+' was not called'),o[0];},e.dataTypes[0]='json',i=C[r],C[r]=function(){o=arguments;},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0;}),'script';}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument('').body).innerHTML='
',2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return'string'!=typeof e?[]:('boolean'==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument('')).createElement('base')).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o;},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(' ');return-1').append(S.parseHTML(e)).find(r):e);}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e]);});}),this;},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem;}).length;},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,'position'),c=S(e),f={};'static'===l&&(e.style.position='relative'),s=c.offset(),o=S.css(e,'top'),u=S.css(e,'left'),('absolute'===l||'fixed'===l)&&-1<(o+u).indexOf('auto')?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),'using'in t?t.using.call(e,f):('number'==typeof f.top&&(f.top+='px'),'number'==typeof f.left&&(f.left+='px'),c.css(f));}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e);});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0;},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if('fixed'===S.css(r,'position'))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&'static'===S.css(e,'position'))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,'borderTopWidth',!0),i.left+=S.css(e,'borderLeftWidth',!0));}return{top:t.top-i.top-S.css(r,'marginTop',!0),left:t.left-i.left-S.css(r,'marginLeft',!0)};}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&'static'===S.css(e,'position'))e=e.offsetParent;return e||re;});}}),S.each({scrollLeft:'pageXOffset',scrollTop:'pageYOffset'},function(t,i){var o='pageYOffset'===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n;},t,e,arguments.length);};}),S.each(['top','left'],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+'px':t;});}),S.each({Height:'height',Width:'width'},function(a,s){S.each({padding:'inner'+a,content:s,'':'outer'+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||'boolean'!=typeof e),i=r||(!0===e||!0===t?'margin':'border');return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf('outer')?e['inner'+a]:e.document.documentElement['client'+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body['scroll'+a],r['scroll'+a],e.body['offset'+a],r['offset'+a],r['client'+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i);},s,n?e:void 0,n);};});}),S.each(['ajaxStart','ajaxStop','ajaxComplete','ajaxError','ajaxSuccess','ajaxSend'],function(e,t){S.fn[t]=function(e){return this.on(t,e);};}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n);},unbind:function(e,t){return this.off(e,null,t);},delegate:function(e,t,n,r){return this.on(t,e,n,r);},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,'**'):this.off(t,e||'**',n);},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e);}}),S.each('blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu'.split(' '),function(e,n){S.fn[n]=function(e,t){return 0