├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── .jshintrc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── button.py ├── config └── .gitignore ├── controllers ├── index.js └── settings.js ├── lib ├── color-schedule.js ├── colors.json ├── db.js ├── hex2rgb.js ├── light-watcher.js ├── lights │ ├── fastled │ │ └── index.js │ ├── hue │ │ ├── index.js │ │ └── setup.js │ └── index.js ├── logger.js └── time-check.js ├── logs └── .gitignore ├── nodemon.json ├── package-lock.json ├── package.json ├── public ├── css │ ├── bootstrap.min.css │ ├── bootstrap.min.css.map │ └── custom.css ├── favicon.ico └── scripts │ ├── bootstrap.min.js │ ├── jquery-3.6.0.min.js │ ├── jquery-3.6.0.min.map │ ├── jscolor.js │ └── main.js ├── server.js ├── utils └── brightChecker.js └── views ├── includes ├── flash.html ├── header.html └── nav.html ├── index.html ├── layout.html └── settings.html /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] This change requires a documentation update 15 | 16 | # How Has This Been Tested? 17 | 18 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration 19 | 20 | - [ ] Test A 21 | - [ ] Test B 22 | 23 | **Test Configuration**: 24 | * Firmware version: 25 | * Hardware: 26 | * Toolchain: 27 | * SDK: 28 | 29 | # Checklist: 30 | 31 | - [ ] My code follows the style guidelines of this project 32 | - [ ] I have performed a self-review of my own code 33 | - [ ] I have commented my code, particularly in hard-to-understand areas 34 | - [ ] I have made corresponding changes to the documentation 35 | - [ ] My changes generate no new warnings 36 | - [ ] I have added tests that prove my fix is effective or that my feature works 37 | - [ ] New and existing unit tests pass locally with my changes 38 | - [ ] Any dependent changes have been merged and published in downstream modules 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary directory 2 | tmp 3 | 4 | # Logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 30 | node_modules 31 | 32 | # Optional npm cache directory 33 | .npm 34 | 35 | # Optional REPL history 36 | .node_repl_history 37 | 38 | .env -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | "jquery": true -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.1.1] - 2021-05-19 8 | 9 | ### Changed 10 | - Forcing a resync of light status whenever an update to its schedule is made 11 | - Consolidated the "what schedule to use" logic between the two areas that inherit the schedule 12 | - Accomodating a [20+ year old Firefox bug](https://bugzilla.mozilla.org/show_bug.cgi?id=46845) where form fields appeared to be populated with outdated info after submission 13 | 14 | ## [1.1.0] - 2021-05-17 15 | ### Added 16 | - The [bootstrap-icons](https://icons.getbootstrap.com/) library 17 | - The [cie-rgb-color-converter](https://www.npmjs.com/package/cie-rgb-color-converter) library, to have RGB->XY conversion happen outside of the Node Hue library 18 | - A favicon 19 | 20 | ### Changed 21 | - To the [flatpickr](https://flatpickr.js.org/) library for time selection 22 | - The logic for turn-on to turn-off when time is up (unless there's an adjacent schedule) 23 | - The "Refresh Page" message now only shows if something actually changed 24 | - Conflicting state changes now use logic to understand who should take precedent 25 | - The system now makes state changes based on the schedule, rather than pulverizing all lights every minute 26 | - Upgraded the following libraries: jscolor, jQuery, Bootstrap, node-hue-api, dotenv, lokijs, moment, morgan, nunjucks, winston 27 | 28 | ### Removed 29 | - The following libraries: timepicker and body-parser 30 | - The glyphicons 31 | - The routes directory (wasn't used - that logic is in the controller) 32 | 33 | ## [1.0.0] - 2019-12-08 34 | ### Added 35 | - Support for more than just Hue lights 36 | - Support [FastLED](https://github.com/jasoncoon/esp8266-fastled-webserver), include multiple palettes & patterns 37 | - Ability to setup the Hue bridge within the app itself 38 | - Ability to delete lights that are no longer discoverable by the app 39 | - Ability to set a default brightness, per bulb 40 | - A CHANGELOG file 41 | 42 | ### Changed 43 | - Moved from callbacks to async & awaits 44 | - Switched from [semistandard](https://www.npmjs.com/package/semistandard) to [standard](https://www.npmjs.com/package/standard) for linting 45 | - All Hue lights are not included by default, must be opted-in from the setup page 46 | - Changed database from [lowdb](https://www.npmjs.com/package/lowdb) to [LokiJS](https://www.npmjs.com/package/lokijs) (system will automigrate your settings) 47 | - All the big adjustments that came from bumping [Hue API](https://github.com/peter-murray/node-hue-api) from v2 to v3 48 | - No longer presumes you want you app called Caron Nightlights (although you're still welcomed to) 49 | - Upgraded the following libraries: jQuery, express-session, node-hue-api & timepicker 50 | 51 | ### Removed 52 | - Semicolons 53 | - The following libraries: async, jsonfile, lodash, lowdb and sprintf-js 54 | 55 | ## [0.0.2] - 2019-07-15 56 | ### Changed 57 | - Minor version bump for all updated packages 58 | - Brightness range now goes from 0 to 100 in steps of 5, rather than steps of 10 59 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at eric.caron@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | * Using welcoming and inclusive language 36 | * Being respectful of differing viewpoints and experiences 37 | * Gracefully accepting constructive criticism 38 | * Focusing on what is best for the community 39 | * Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | * The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | * Trolling, insulting/derogatory comments, and personal or political attacks 46 | * Public or private harassment 47 | * Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | * Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to ban temporarily or 61 | permanently any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | [version]: http://contributor-covenant.org/version/1/4/ 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Eric Caron 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Internet-enabled children's nightlight 2 | Powered by Node.JS and a variety of types of internet-controllable lights, such as: 3 | * [Philips Hue](https://www2.meethue.com/en-us/) 4 | * [NeoPixel & ESP8266](https://github.com/jasoncoon/esp8266-fastled-webserver) 5 | 6 | ## Why Does This Exist? 7 | I wanted my kids to have a nightlight in their room that: 8 | 9 | * Had programmable color & brightness 10 | * Had an adjustable timer (turns off X minutes after being turned on) 11 | * Had an interface to show history of button pushes (so I can see when they were awake) 12 | * Schedule the light to change color on a schedule (so at 7am it turns to blue to indicate "Ok, it's morning time!"). This is great for sleep training! 13 | 14 | ### Other Great Uses 15 | * Our [kitchen lightstrips](https://www2.meethue.com/en-us/lightstrips) now turn on at 9pm, and off at 7am, to provide a great evening ambience. 16 | 17 | ## Live Demos 18 | | Demo of button turning light on | 19 | | -------------------------------- | 20 | | ![light turn on](https://cloud.githubusercontent.com/assets/70704/12696100/d261de2c-c726-11e5-9022-74036dab6a3a.gif) | 21 | 22 | | Demo of button turning light off | 23 | | -------------------------------- | 24 | | ![light turn off](https://cloud.githubusercontent.com/assets/70704/12696097/d25e4ab4-c726-11e5-91a0-861b13149c83.gif) | 25 | 26 | 27 | ## What Does This Do? 28 | There is a Node.js web server that runs on the network, connects to the Philips Hue bridge, and listens on the network for some button to get pushed. 29 | 30 | Each button gets associated with a light on the network. If the button is pushed and the light is off - the light is turned on (and a timer is started to automatically turn the light off after X minutes). If the button is pushed and the light is on - the light is turned off. 31 | 32 | There is also a web application that sets the default color and colors during specific time periods. The web interface - in addition to showing times of button pushes - permits turning the light on with & without the timer, and turning the light off. 33 | 34 | ## Setup 35 | ### Getting The App Running 36 | For the most part, it is a straight-forward Node.js application. After downloading this repo and extracting it to a directory of your choice, run: 37 | 38 | * `npm install` 39 | * `npm start` 40 | 41 | You can now access http://localhost:3000/ and view the complete interface. (On my home network, I put this on a Rapsberry Pi and set the in-house DNS to know it as "nightlight". So now babysitters and family just go to http://nightlight/ to use it.) 42 | 43 | ### Environment Configurables 44 | There are some variables that much be defined in the environment before starting the application. 45 | For your convenience, you can also stored these in a `.env` file. 46 | 47 | Variable | Purpose | Default 48 | --- | --- | --- 49 | SITE_NAME | Shown in page header and logged with Hue bridge | Nightlight System 50 | NODE_ENV | Determines if application should cache & catch uncaught errors (if set to *production*), or exit | *blank* 51 | PORT | Port app listens on | 3000 52 | SESSION_SECRET | How cookie data is encrypted | secret 53 | 54 | ### Keeping The App Running 55 | You'll find plenty of other great tutorials on the web about running a Node.js app as a daemon, but here are a couple: 56 | 57 | * http://howtonode.org/deploying-node-upstart-monit 58 | * https://serversforhackers.com/video/process-monitoring-with-supervisord 59 | 60 | ## Screenshots 61 | | Desktop interface for monitor and control | 62 | | ----------------------------------------- | 63 | | | 64 | 65 | | Mobile interface for monitor and control | 66 | | ---------------------------------------- | 67 | | ![mobile view](https://cloud.githubusercontent.com/assets/70704/12696099/d26159b6-c726-11e5-8952-de1e04d173e4.png) | 68 | 69 | 70 | ## Acknowledgements 71 | * [Original blog about hacking the Dash button](https://medium.com/p/794214b0bdd8) 72 | * [jscolor Color Picker](http://jscolor.com/) - Makes selecting colors really easy 73 | * [Nunjucks](https://mozilla.github.io/nunjucks/) - Really great templating language for JavaScript 74 | * [flatpickr](https://flatpickr.js.org/) - Fast way to add time handling to the interface 75 | * [node-dash-button](https://github.com/hortinstein/node-dash-button) - Fantastic module to find Node button on the network, and bind it to events. No longer used, but inspiring. 76 | * [node-hue-api](https://github.com/peter-murray/node-hue-api) - Wonderful module to find and control Philips Hue bridges and bulbs 77 | * [FastLED + ESP8266 Web Server](https://github.com/jasoncoon/esp8266-fastled-webserver) - Saved me a ton of time programming a non-Hue LED circuit 78 | -------------------------------------------------------------------------------- /button.py: -------------------------------------------------------------------------------- 1 | ## 2 | # 3 | # I've found this script useful for attaching a Raspberry Pi to a button 4 | # and then having the button turn the light on/off 5 | # 6 | ## 7 | import os 8 | import requests 9 | import signal 10 | import time 11 | import RPi.GPIO as GPIO 12 | 13 | # I like to have CntlC be handled gracefully 14 | def signal_handler(signal, frame): 15 | os._exit(1) 16 | signal.signal(signal.SIGINT, signal_handler) 17 | 18 | url = 'http://nightlight/' # I map this to my internal DNS hosting the node app 19 | gpio_pin=18 # The GPIO pin the button is attached to 20 | longpress_threshold=5 # If button is held this length of time, tells system to leave light on 21 | light_id=1 # This corresponds with the light's id on the network 22 | 23 | GPIO.setmode(GPIO.BCM) 24 | GPIO.setup(gpio_pin, GPIO.IN, pull_up_down=GPIO.PUD_UP) 25 | 26 | while True: 27 | try: 28 | time.sleep(0.2) 29 | 30 | if GPIO.input(gpio_pin) == False: # Listen for the press, the loop until it steps 31 | print "Started press" 32 | pressed_time=time.time() 33 | while GPIO.input(gpio_pin) == False: 34 | time.sleep(0.2) 35 | 36 | pressed_time=time.time()-pressed_time 37 | print "Button pressed %d, POSTing to nightlight server" % pressed_time 38 | if pressed_time { 46 | res.redirect('/settings') 47 | }).catch(e => { 48 | req.flash('error', e.toString()) 49 | res.redirect('/settings') 50 | }) 51 | } else if (req.body.cmd === 'attach-hue-lights') { 52 | const addingLights = req.body.lights 53 | for (let i = 0; i < addingLights.length; i++) { 54 | await lights.add('hue', { deviceId: addingLights[i] }) 55 | } 56 | req.flash('success', 'Lights successfully associated with this system') 57 | } 58 | lightWatcher.init() 59 | res.redirect('/') 60 | }) 61 | 62 | module.exports = router 63 | -------------------------------------------------------------------------------- /lib/color-schedule.js: -------------------------------------------------------------------------------- 1 | const lightWatcher = require('../lib/light-watcher') 2 | 3 | const ColorSchedule = { 4 | create: function (req, res) { 5 | const light = req.db.lights.get(req.body.light) 6 | if (!light) { 7 | req.flash('error', 'Unable to load database for that light.') 8 | return res.redirect('/') 9 | } 10 | 11 | const scheduleKey = req.body.start_time + '==' + req.body.end_time 12 | const scheduleValue = { 13 | start: req.body.start_time, 14 | end: req.body.end_time, 15 | state: req.body.state 16 | } 17 | Object.keys(req.body.settings).forEach(function (settingKey) { 18 | scheduleValue[settingKey] = req.body.settings[settingKey] 19 | }) 20 | light.colorSchedule[scheduleKey] = scheduleValue 21 | req.db.lights.update(light) 22 | lightWatcher.update(req.body.light) 23 | req.flash('success', 'Color schedule has been successfully added for the light!') 24 | res.redirect('/') 25 | }, 26 | update: function (req, res) { 27 | const light = req.db.lights.get(req.body.light) 28 | if (!light) { 29 | req.flash('error', 'Unable to load database for that light.') 30 | return res.redirect('/') 31 | } 32 | 33 | const oldKey = req.body.id 34 | const newKey = req.body.start_time + '==' + req.body.end_time 35 | 36 | const scheduleValue = { 37 | start: req.body.start_time, 38 | end: req.body.end_time, 39 | state: req.body.state 40 | } 41 | Object.keys(req.body.settings).forEach(function (settingKey) { 42 | scheduleValue[settingKey] = req.body.settings[settingKey] 43 | }) 44 | 45 | if (oldKey !== newKey) { 46 | delete light.colorSchedule[oldKey] 47 | } 48 | light.colorSchedule[newKey] = scheduleValue 49 | req.db.lights.update(light) 50 | lightWatcher.update(req.body.light, { forceOff: true }) 51 | req.flash('success', 'Updated schedule for that light.') 52 | res.redirect('/') 53 | }, 54 | delete: function (req, res) { 55 | const light = req.db.lights.get(req.body.light) 56 | if (!light) { 57 | req.flash('error', 'Unable to load database for that light.') 58 | return res.json({ success: false }) 59 | } 60 | 61 | const scheduleKey = req.body.id 62 | if (!light.colorSchedule[scheduleKey]) { 63 | req.flash('error', 'Unable to load database for that light.') 64 | return res.json({ success: false }) 65 | } 66 | delete light.colorSchedule[scheduleKey] 67 | req.db.lights.update(light) 68 | lightWatcher.update(req.body.light) 69 | req.flash('success', 'Deleted color schedule for that light.') 70 | return res.json({ success: true }) 71 | } 72 | } 73 | 74 | module.exports = ColorSchedule 75 | -------------------------------------------------------------------------------- /lib/colors.json: -------------------------------------------------------------------------------- 1 | [{"title":"Alice Blue","hex":"#F0F8FF"},{"title":"Antique White","hex":"#FAEBD7"},{"title":"Aqua","hex":"#00FFFF"},{"title":"Aquamarine","hex":"#7FFFD4"},{"title":"Azure","hex":"#F0FFFF"},{"title":"Beige","hex":"#F5F5DC"},{"title":"Bisque","hex":"#FFE4C4"},{"title":"Black","hex":"#000000"},{"title":"Blanched Almond","hex":"#FFEBCD"},{"title":"Blue","hex":"#0000FF"},{"title":"Blue Violet","hex":"#8A2BE2"},{"title":"Brown","hex":"#A52A2A"},{"title":"Burlywood","hex":"#DEB887"},{"title":"Cadet Blue","hex":"#5F9EA0"},{"title":"Chartreuse","hex":"#7FFF00"},{"title":"Chocolate","hex":"#D2691E"},{"title":"Coral","hex":"#FF7F50"},{"title":"Cornflower","hex":"#6495ED"},{"title":"Cornsilk","hex":"#FFF8DC"},{"title":"Crimson","hex":"#DC143C"},{"title":"Cyan","hex":"#00FFFF"},{"title":"Dark Blue","hex":"#00008B"},{"title":"Dark Cyan","hex":"#008B8B"},{"title":"Dark Goldenrod","hex":"#B8860B"},{"title":"Dark Gray","hex":"#A9A9A9"},{"title":"Dark Green","hex":"#006400"},{"title":"Dark Khaki","hex":"#BDB76B"},{"title":"Dark Magenta","hex":"#8B008B"},{"title":"Dark Olive Green","hex":"#556B2F"},{"title":"Dark Orange","hex":"#FF8C00"},{"title":"Dark Orchid","hex":"#9932CC"},{"title":"Dark Red","hex":"#8B0000"},{"title":"Dark Salmon","hex":"#E9967A"},{"title":"Dark Sea Green","hex":"#8FBC8F"},{"title":"Dark Slate Blue","hex":"#483D8B"},{"title":"Dark Slate Gray","hex":"#2F4F4F"},{"title":"Dark Turquoise","hex":"#00CED1"},{"title":"Dark Violet","hex":"#9400D3"},{"title":"Deep Pink","hex":"#FF1493"},{"title":"Deep Sky Blue","hex":"#00BFFF"},{"title":"Dim Gray","hex":"#696969"},{"title":"Dodger Blue","hex":"#1E90FF"},{"title":"Firebrick","hex":"#B22222"},{"title":"Floral White","hex":"#FFFAF0"},{"title":"Forest Green","hex":"#228B22"},{"title":"Fuchsia","hex":"#FF00FF"},{"title":"Gainsboro","hex":"#DCDCDC"},{"title":"Ghost White","hex":"#F8F8FF"},{"title":"Gold","hex":"#FFD700"},{"title":"Goldenrod","hex":"#DAA520"},{"title":"Gray (X11)","hex":"#BEBEBE"},{"title":"Gray (W3C)","hex":"#808080"},{"title":"Green (X11)","hex":"#00FF00"},{"title":"Green (W3C)","hex":"#008000"},{"title":"Green Yellow","hex":"#ADFF2F"},{"title":"Honeydew","hex":"#F0FFF0"},{"title":"Hot Pink","hex":"#FF69B4"},{"title":"Indian Red","hex":"#CD5C5C"},{"title":"Indigo","hex":"#4B0082"},{"title":"Ivory","hex":"#FFFFF0"},{"title":"Khaki","hex":"#F0E68C"},{"title":"Lavender","hex":"#E6E6FA"},{"title":"Lavender Blush","hex":"#FFF0F5"},{"title":"Lawn Green","hex":"#7CFC00"},{"title":"Laser Lemon","hex":"#FFFF54"},{"title":"Lemon Chiffon","hex":"#FFFACD"},{"title":"Light Blue","hex":"#ADD8E6"},{"title":"Light Coral","hex":"#F08080"},{"title":"Light Cyan","hex":"#E0FFFF"},{"title":"Light Goldenrod","hex":"#FAFAD2"},{"title":"Light Gray","hex":"#D3D3D3"},{"title":"Light Green","hex":"#90EE90"},{"title":"Light Pink","hex":"#FFB6C1"},{"title":"Light Salmon","hex":"#FFA07A"},{"title":"Light Sea Green","hex":"#20B2AA"},{"title":"Light Sky Blue","hex":"#87CEFA"},{"title":"Light Slate Gray","hex":"#778899"},{"title":"Light Steel Blue","hex":"#B0C4DE"},{"title":"Light Yellow","hex":"#FFFFE0"},{"title":"Lime (W3C)","hex":"#00FF00"},{"title":"Lime Green","hex":"#32CD32"},{"title":"Linen","hex":"#FAF0E6"},{"title":"Magenta","hex":"#FF00FF"},{"title":"Maroon (X11)","hex":"#B03060"},{"title":"Maroon (W3C)","hex":"#7F0000"},{"title":"Medium Aquamarine","hex":"#66CDAA"},{"title":"Medium Blue","hex":"#0000CD"},{"title":"Medium Orchid","hex":"#BA55D3"},{"title":"Medium Purple","hex":"#9370DB"},{"title":"Medium Sea Green","hex":"#3CB371"},{"title":"Medium Slate Blue","hex":"#7B68EE"},{"title":"Medium Spring Green","hex":"#00FA9A"},{"title":"Medium Turquoise","hex":"#48D1CC"},{"title":"Medium Violet Red","hex":"#C71585"},{"title":"Midnight Blue","hex":"#191970"},{"title":"Mint Cream","hex":"#F5FFFA"},{"title":"Misty Rose","hex":"#FFE4E1"},{"title":"Moccasin","hex":"#FFE4B5"},{"title":"Navajo White","hex":"#FFDEAD"},{"title":"Navy","hex":"#000080"},{"title":"Old Lace","hex":"#FDF5E6"},{"title":"Olive","hex":"#808000"},{"title":"Olive Drab","hex":"#6B8E23"},{"title":"Orange","hex":"#FFA500"},{"title":"Orange Red","hex":"#FF4500"},{"title":"Orchid","hex":"#DA70D6"},{"title":"Pale Goldenrod","hex":"#EEE8AA"},{"title":"Pale Green","hex":"#98FB98"},{"title":"Pale Turquoise","hex":"#AFEEEE"},{"title":"Pale Violet Red","hex":"#DB7093"},{"title":"Papaya Whip","hex":"#FFEFD5"},{"title":"Peach Puff","hex":"#FFDAB9"},{"title":"Peru","hex":"#CD853F"},{"title":"Pink","hex":"#FFC0CB"},{"title":"Plum","hex":"#DDA0DD"},{"title":"Powder Blue","hex":"#B0E0E6"},{"title":"Purple (X11)","hex":"#A020F0"},{"title":"Purple (W3C)","hex":"#7F007F"},{"title":"Red","hex":"#FF0000"},{"title":"Rosy Brown","hex":"#BC8F8F"},{"title":"Royal Blue","hex":"#4169E1"},{"title":"Saddle Brown","hex":"#8B4513"},{"title":"Salmon","hex":"#FA8072"},{"title":"Sandy Brown","hex":"#F4A460"},{"title":"Sea Green","hex":"#2E8B57"},{"title":"Seashell","hex":"#FFF5EE"},{"title":"Sienna","hex":"#A0522D"},{"title":"Silver (W3C)","hex":"#C0C0C0"},{"title":"Sky Blue","hex":"#87CEEB"},{"title":"Slate Blue","hex":"#6A5ACD"},{"title":"Slate Gray","hex":"#708090"},{"title":"Snow","hex":"#FFFAFA"},{"title":"Spring Green","hex":"#00FF7F"},{"title":"Steel Blue","hex":"#4682B4"},{"title":"Tan","hex":"#D2B48C"},{"title":"Teal","hex":"#008080"},{"title":"Thistle","hex":"#D8BFD8"},{"title":"Tomato","hex":"#FF6347"},{"title":"Turquoise","hex":"#40E0D0"},{"title":"Violet","hex":"#EE82EE"},{"title":"Wheat","hex":"#F5DEB3"},{"title":"White","hex":"#FFFFFF"},{"title":"White Smoke","hex":"#F5F5F5"},{"title":"Yellow","hex":"#FFFF00"},{"title":"Yellow Green","hex":"#9ACD32"}] 2 | -------------------------------------------------------------------------------- /lib/db.js: -------------------------------------------------------------------------------- 1 | /* eslint new-cap: ["error", { "newIsCap": false }] */ 2 | const EventEmitter = require('events') 3 | const loki = require('lokijs') 4 | const path = require('path') 5 | const fs = require('fs') 6 | const oldDbFile = path.resolve(__dirname, '..', 'config', 'db.json') 7 | const newDbFile = path.resolve(__dirname, '..', 'config', 'db2.json') 8 | 9 | const dbEmitter = new EventEmitter() 10 | 11 | const DB = new loki(newDbFile, { 12 | autoload: true, 13 | autoloadCallback: databaseInitialize, 14 | autosave: true, 15 | autosaveInterval: 4000 16 | }) 17 | 18 | function databaseInitialize () { 19 | exports.lights = DB.getCollection('lights') || DB.addCollection('lights') 20 | exports.settings = DB.getCollection('settings') || DB.addCollection('settings') 21 | if (fs.existsSync(oldDbFile)) { 22 | const oldDbData = require(oldDbFile) 23 | exports.settings.insert({ type: 'hue', ip: oldDbData.bridge[0].ip, username: oldDbData.bridge[0].username }) 24 | oldDbData.lights.forEach(origLight => { 25 | const light = { 26 | type: 'hue', 27 | deviceId: origLight.id, 28 | colorSchedule: origLight.colorSchedule || {}, 29 | settings: origLight.settings || {} 30 | } 31 | exports.lights.insert(light) 32 | }) 33 | fs.unlinkSync(oldDbFile) 34 | } 35 | dbEmitter.emit('loaded') 36 | } 37 | 38 | exports.close = function () { 39 | DB.close() 40 | } 41 | 42 | exports.save = function () { 43 | DB.saveDatabase() 44 | } 45 | 46 | exports.event = dbEmitter 47 | -------------------------------------------------------------------------------- /lib/hex2rgb.js: -------------------------------------------------------------------------------- 1 | module.exports = function (hex) { 2 | if (hex.length !== 6) { 3 | throw new Error('hex2rgb only accepts hex values in the 000000 format') 4 | } 5 | return [ 6 | parseInt(hex.substring(0, 2), 16), 7 | parseInt(hex.substring(2, 4), 16), 8 | parseInt(hex.substring(4, 6), 16) 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /lib/light-watcher.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger') 2 | const timeCheck = require('./time-check') 3 | const moment = require('moment') 4 | 5 | const lights = require('./lights') 6 | const hex2rgb = require('./hex2rgb') 7 | const colorConverter = require('cie-rgb-color-converter') 8 | 9 | let lightIds = [] 10 | let currentLights = [] 11 | 12 | exports.remove = function (lightId) { 13 | clearTimeout(currentLights[lightId].timer) 14 | } 15 | 16 | exports.update = async function (lightId, options) { 17 | if (typeof options === 'undefined') options = {} 18 | 19 | // Useful for when we're making live changes to schedule 20 | if (options.forceOff) { 21 | lights.turnOff(lightId) 22 | } 23 | 24 | const curDate = moment() 25 | if (currentLights[lightId].timer) { 26 | clearTimeout(currentLights[lightId].timer) 27 | } 28 | 29 | const light = currentLights[lightId] 30 | const { lightSettings, closestTime } = timeCheck.schedulePicker(light) 31 | 32 | light.timer = setTimeout(function () { 33 | exports.update(lightId) 34 | }, closestTime - curDate) 35 | 36 | if (lightSettings === false) return 37 | 38 | const deviceState = await lights.getState(light) 39 | if (!deviceState) { 40 | if (typeof light.retries !== 'undefined' && light.retries > 10) { 41 | logger.error(`Failed 10 times to get state for ${JSON.stringify(light)}. Giving up`, deviceState) 42 | return 43 | } if (typeof light.retries === 'undefined') { 44 | light.retries = 1 45 | } else { 46 | light.retries++ 47 | } 48 | logger.error(`Failed ${light.retires} times to get state for ${JSON.stringify(light)}. Pausing then trying again`, deviceState) 49 | 50 | clearTimeout(light.timer) 51 | light.timer = setTimeout(function () { 52 | exports.update(lightId) 53 | }, light.retries * 60 * 1000) 54 | return 55 | } 56 | 57 | light.retries = 0 58 | 59 | if (deviceState.on && lightSettings.state === 'asis') { 60 | // If the light is already on, we still need to double-check that we're 61 | // showing the right color or brightness 62 | let changeColor = false 63 | if (light.type === 'hue') { 64 | if (light.device.mappedColorGamut) { 65 | const newColor = hex2rgb(lightSettings.color) 66 | const currentColorXY = deviceState.xy 67 | const newColorXY = colorConverter.rgbToXy(newColor[0], newColor[1], newColor[2], light.modelId) 68 | if (Math.floor(newColorXY[0] * 10) !== Math.floor(currentColorXY[0] * 10) || Math.floor(newColorXY[1] * 10) !== Math.floor(currentColorXY[1] * 10)) { 69 | changeColor = true 70 | } 71 | } 72 | if (deviceState.bri !== lightSettings.brightness) { 73 | changeColor = true 74 | } 75 | } else if (light.type === 'fastled') { 76 | if (lightSettings.pattern !== deviceState.pattern) { 77 | changeColor = true 78 | } 79 | if (Math.round(lightSettings.brightness * 2.55) !== deviceState.brightness) { 80 | changeColor = true 81 | } 82 | if (hex2rgb(lightSettings.color).join(',') !== deviceState.solidColor) { 83 | changeColor = true 84 | } 85 | if (lightSettings.palette !== deviceState.palette) { 86 | changeColor = true 87 | } 88 | } 89 | if (changeColor === true) { 90 | lights.turnOn(light.id, lightSettings, true) 91 | } 92 | } else if (deviceState.on && lightSettings.state === 'off') { 93 | lights.turnOff(light.id) 94 | } else if (lightSettings.state === 'on') { 95 | lightSettings.timer = lightSettings.actualEnd - curDate 96 | lights.turnOn(light.id, lightSettings, true) 97 | } 98 | } 99 | 100 | exports.init = async function () { 101 | let i 102 | for (i = 0; i < lightIds.length; i++) { 103 | if (currentLights[lightIds[i]] && currentLights[lightIds[i]].timer) { 104 | clearTimeout(currentLights[lightIds[i]].timer) 105 | } 106 | } 107 | 108 | currentLights = (await lights.getAll()).lights 109 | lightIds = Object.keys(currentLights) 110 | 111 | for (i = 0; i < lightIds.length; i++) { 112 | exports.update(lightIds[i]) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /lib/lights/fastled/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const db = require('../../db') 3 | const hex2rgb = require('../../hex2rgb') 4 | 5 | exports.PALETTES = [ 6 | 'Rainbow', 7 | 'Rainbow Stripe', 8 | 'Cloud', 9 | 'Lava', 10 | 'Ocean', 11 | 'Forest', 12 | 'Party', 13 | 'Heat' 14 | ] 15 | 16 | exports.PATTERNS = [ 17 | 'Pride', 18 | 'Color Waves', 19 | 'Rainbow Twinkles', 20 | 'Snow Twinkles', 21 | 'Cloud Twinkles', 22 | 'Incandescent Twinkles', 23 | 'Retro C9 Twinkles', 24 | 'Red & White Twinkles', 25 | 'Blue & White Twinkles', 26 | 'Red, Green & White Twinkles', 27 | 'Fairy Light Twinkles', 28 | 'Snow 2 Twinkles', 29 | 'Holly Twinkles', 30 | 'Ice Twinkles', 31 | 'Party Twinkles', 32 | 'Forest Twinkles', 33 | 'Lava Twinkles', 34 | 'Fire Twinkles', 35 | 'Cloud 2 Twinkles', 36 | 'Ocean Twinkles', 37 | 'Rainbow', 38 | 'Rainbow With Glitter', 39 | 'Solid Rainbow', 40 | 'Confetti', 41 | 'Sinelon', 42 | 'Beat', 43 | 'Juggle', 44 | 'Fire', 45 | 'Water', 46 | 'Solid Color' 47 | ] 48 | 49 | exports.getAll = async function () { 50 | const allFastLEDs = db.lights.find({ type: 'fastled' }) 51 | const lights = [] 52 | const messages = [] 53 | let light, knownLight 54 | for (let i = 0; i < allFastLEDs.length; i++) { 55 | knownLight = allFastLEDs[i] 56 | light = { 57 | name: knownLight.settings.name, 58 | ip: knownLight.settings.ip, 59 | type: knownLight.type, 60 | id: knownLight.$loki, 61 | knownLight: true, 62 | settings: knownLight.settings || {}, 63 | colorSchedule: knownLight.colorSchedule || {} 64 | } 65 | lights.push(light) 66 | } 67 | return { 68 | lights: lights, 69 | messages: messages 70 | } 71 | } 72 | 73 | exports.getState = async function (light) { 74 | let rawState 75 | try { 76 | rawState = await axios.get(`http://${light.settings.ip}/all`) 77 | } catch (e) { 78 | return false 79 | } 80 | const state = {} 81 | let part 82 | for (let i = 0; i < rawState.data.length; i++) { 83 | part = rawState.data[i] 84 | if (part.name === 'power') { 85 | state.on = part.value 86 | } else if (part.name === 'pattern') { 87 | state.pattern = exports.PATTERNS[part.value] 88 | } else if (part.name === 'palette') { 89 | state.palette = exports.PALETTES[part.value] 90 | } else { 91 | state[part.name] = part.value 92 | } 93 | } 94 | return state 95 | } 96 | 97 | exports.turnOn = async function (light, lightSettings) { 98 | if (lightSettings.brightness) { 99 | await axios.post(`http://${light.settings.ip}/brightness?value=${Math.round(lightSettings.brightness * 2.55)}`) 100 | } 101 | const patternKey = exports.PATTERNS.indexOf(lightSettings.pattern) 102 | if (patternKey >= 0) { 103 | await axios.post(`http://${light.settings.ip}/pattern?value=${patternKey}`) 104 | } 105 | const paletteKey = exports.PALETTES.indexOf(lightSettings.palette) 106 | if (paletteKey >= 0) { 107 | await axios.post(`http://${light.settings.ip}/palette?value=${paletteKey}`) 108 | } 109 | if (lightSettings.color && lightSettings.pattern === 'Solid Color') { 110 | const hex = hex2rgb(lightSettings.color) 111 | await axios.post(`http://${light.settings.ip}/solidColor?r=${hex[0]}&g=${hex[1]}&b=${hex[2]}`) 112 | } 113 | await axios.post(`http://${light.settings.ip}/power?value=1`) 114 | } 115 | 116 | exports.turnOff = async function (light) { 117 | await axios.post(`http://${light.settings.ip}/power?value=0`) 118 | } 119 | -------------------------------------------------------------------------------- /lib/lights/hue/index.js: -------------------------------------------------------------------------------- 1 | const hue = require('node-hue-api').v3 2 | const hex2rgb = require('../../hex2rgb') 3 | const colorConverter = require('cie-rgb-color-converter') 4 | const db = require('../../db') 5 | 6 | exports.setup = require('./setup') 7 | 8 | let connectedApi = false 9 | const getApi = async function () { 10 | if (connectedApi === false) { 11 | const bridgeInfo = db.settings.findOne({ type: 'hue' }) 12 | if (!bridgeInfo) { 13 | throw new Error('Hue bridge not found in the local database') 14 | } 15 | connectedApi = await hue.api.createLocal(bridgeInfo.ip).connect(bridgeInfo.username) 16 | } 17 | return connectedApi 18 | } 19 | 20 | exports.getAll = async function () { 21 | const api = await getApi() 22 | const allHueLights = await api.lights.getAll() 23 | const messages = [] 24 | const lights = [] 25 | allHueLights.forEach(hueLight => { 26 | const light = {} 27 | light.device = hueLight 28 | const knownLight = db.lights.findOne({ type: 'hue', deviceId: String(hueLight.id) }) 29 | if (knownLight !== null) { 30 | light.type = knownLight.type 31 | light.name = hueLight.name 32 | light.modelId = hueLight.modelid 33 | light.deviceId = knownLight.deviceId 34 | light.id = knownLight.$loki 35 | light.settings = knownLight.settings || {} 36 | light.colorSchedule = knownLight.colorSchedule || {} 37 | lights.push(light) 38 | } 39 | }) 40 | 41 | return { 42 | lights: lights, 43 | messages: messages 44 | } 45 | } 46 | 47 | exports.getUnknown = async function () { 48 | const api = await getApi() 49 | const allHueLights = await api.lights.getAll() 50 | const lights = [] 51 | allHueLights.forEach(hueLight => { 52 | const knownLight = db.lights.findOne({ type: 'hue', deviceId: String(hueLight.id) }) 53 | if (knownLight === null) { 54 | lights.push(hueLight) 55 | } 56 | }) 57 | 58 | return lights 59 | } 60 | 61 | exports.turnOn = async function (light, lightSettings) { 62 | const api = await getApi() 63 | const LightState = hue.lightStates.LightState 64 | const state = new LightState().on() 65 | 66 | if (typeof lightSettings.brightness !== 'undefined' && lightSettings.brightness !== false) { 67 | const brightness = parseInt(lightSettings.brightness) 68 | if (!isNaN(brightness)) { 69 | state.brightness(lightSettings.brightness) 70 | } 71 | } 72 | 73 | if (lightSettings.color) { 74 | const newColor = hex2rgb(lightSettings.color) 75 | const newXY = colorConverter.rgbToXy(newColor[0], newColor[1], newColor[2], light.modelId) 76 | state.xy(newXY.x, newXY.y) 77 | } 78 | 79 | await api.lights.setLightState(light.deviceId, state) 80 | } 81 | 82 | exports.turnOff = async function (light) { 83 | const api = await getApi() 84 | const LightState = hue.lightStates.LightState 85 | 86 | const state = new LightState().off() 87 | await api.lights.setLightState(light.deviceId, state) 88 | } 89 | 90 | exports.getState = async function (light) { 91 | const api = await getApi() 92 | try { 93 | return await api.lights.getLightState(light.deviceId) 94 | } catch (e) { 95 | return false 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /lib/lights/hue/setup.js: -------------------------------------------------------------------------------- 1 | const hue = require('node-hue-api').v3 2 | 3 | const APPLICATION_NAME = process.env.SITE_NAME || 'Nightlight System' 4 | const DEVICE_NAME = 'Web Interface' 5 | 6 | module.exports = async function (req, res) { 7 | let setting = req.db.settings.findOne({ type: 'hue' }) 8 | if (setting) { 9 | throw new Error('Hue bridge already exists in the local database') 10 | } 11 | setting = { 12 | type: 'hue' 13 | } 14 | 15 | return hue.discovery.nupnpSearch() 16 | .then(searchResults => { 17 | if (searchResults.length === 0) { 18 | throw new Error('No bridges found') 19 | } 20 | req.log('info', `${searchResults.length} Hue Bridges Found`) 21 | setting.ip = searchResults[0].ipaddress 22 | return hue.api.createLocal(setting.ip).connect() 23 | }) 24 | .then(api => { 25 | return api.users.createUser(APPLICATION_NAME, DEVICE_NAME) 26 | }) 27 | .then(createdUser => { 28 | req.flash('success', 'Hue Bridge successfully found and saved to database') 29 | req.log('info', 'User successfully created on local bridge') 30 | setting.username = createdUser 31 | req.db.settings.insert(setting) 32 | }).catch(e => { 33 | req.flash('error', e.toString()) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /lib/lights/index.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment') 2 | const timeCheck = require('../time-check') 3 | const logger = require('../logger') 4 | const db = require('../db') 5 | 6 | const hue = require('./hue/') 7 | exports.hue = hue 8 | 9 | const timers = {} 10 | const logs = {} 11 | 12 | const DEFAULT_STAYON_MINUTES = 60 13 | 14 | const fastled = require('./fastled/') 15 | let lastChange = new Date() 16 | 17 | exports.updateLastChange = function () { 18 | lastChange = new Date() 19 | } 20 | exports.getLastChange = function () { 21 | return lastChange 22 | } 23 | 24 | exports.fastled = fastled 25 | 26 | exports.getAll = async function () { 27 | const lights = {} 28 | let messages = [] 29 | 30 | const hueLights = await hue.getAll() 31 | messages = messages.concat(hueLights.messages) 32 | Object.keys(hueLights.lights).forEach(function (lightId) { 33 | const hueLight = hueLights.lights[lightId] 34 | lights[hueLight.id] = hueLight 35 | }) 36 | 37 | const fastLEDLigts = await fastled.getAll() 38 | messages = messages.concat(fastLEDLigts.messages) 39 | Object.keys(fastLEDLigts.lights).forEach(function (lightId) { 40 | const fastLEDLight = fastLEDLigts.lights[lightId] 41 | lights[fastLEDLight.id] = fastLEDLight 42 | }) 43 | 44 | Object.keys(timers).forEach(function (lightId) { 45 | if (timers[lightId].timer && !timers[lightId].timer._called && timers[lightId].time) { 46 | lights[lightId].timerTime = timers[lightId].time.format() 47 | } 48 | }) 49 | Object.keys(logs).forEach(function (lightId) { 50 | lights[lightId].logs = logs[lightId] 51 | }) 52 | 53 | return { 54 | lights: lights, 55 | messages: messages 56 | } 57 | } 58 | 59 | exports.add = async function (type, lightDetails) { 60 | const light = { 61 | type: type, 62 | settings: {}, 63 | colorSchedule: {} 64 | } 65 | Object.keys(lightDetails).forEach(function (key) { 66 | light[key] = lightDetails[key] 67 | }) 68 | db.lights.insert(light) 69 | exports.updateLastChange() 70 | } 71 | 72 | exports.turnOn = async function (lightId, lightDetails, selectedSchedule) { 73 | const light = db.lights.get(lightId) 74 | 75 | if (typeof lightDetails === 'undefined') { 76 | lightDetails = {} 77 | } 78 | if (typeof selectedSchedule === 'undefined') { 79 | selectedSchedule = false 80 | } 81 | 82 | if (selectedSchedule === false && typeof light.colorSchedule !== 'undefined') { 83 | Object.assign(lightDetails, timeCheck.schedulePicker(light).lightSettings) 84 | } 85 | if (!lightDetails.color) { 86 | lightDetails.color = (typeof light.settings.color !== 'undefined') ? light.settings.color : 'FFFFFF' 87 | } 88 | if (!lightDetails.brightness) { 89 | lightDetails.brightness = (typeof light.settings.brightness !== 'undefined') ? light.settings.brightness : 50 90 | } 91 | if (!lightDetails.pattern) { 92 | lightDetails.pattern = (typeof light.settings.pattern !== 'undefined') ? light.settings.pattern : 'Solid Color' 93 | } 94 | if (!lightDetails.palette) { 95 | lightDetails.palette = (typeof light.settings.palette !== 'undefined') ? light.settings.palette : 'Heat' 96 | } 97 | 98 | if (light.type === 'hue') { 99 | hue.turnOn(light, lightDetails) 100 | } else if (light.type === 'fastled') { 101 | fastled.turnOn(light, lightDetails) 102 | } 103 | 104 | if (lightDetails.timer) { 105 | let stayOnTime = DEFAULT_STAYON_MINUTES * 60 * 1000 106 | if (typeof lightDetails.timer === 'number') { 107 | stayOnTime = lightDetails.timer 108 | } else if (typeof light.settings.stayOnMinutes === 'number') { 109 | stayOnTime = light.settings.stayOnMinutes * 60 * 1000 110 | } 111 | 112 | if (typeof timers[lightId] !== 'undefined' && timers[lightId].timer) { 113 | clearTimeout(timers[lightId].timer) 114 | } 115 | timers[lightId] = { 116 | time: moment().add(stayOnTime, 'ms'), 117 | timer: setTimeout(function () { 118 | exports.turnOff(lightId) 119 | exports.addLog(lightId, `${Math.round(stayOnTime / 60000)} minutes reached, turning off light ${lightId}.`) 120 | }, stayOnTime) 121 | } 122 | } 123 | exports.updateLastChange() 124 | } 125 | 126 | exports.turnOff = async function (lightId) { 127 | const light = db.lights.get(lightId) 128 | if (light.type === 'hue') { 129 | hue.turnOff(light) 130 | } else if (light.type === 'fastled') { 131 | fastled.turnOff(light) 132 | } 133 | if (typeof timers[lightId] !== 'undefined') timers[lightId].time = false 134 | exports.updateLastChange() 135 | } 136 | 137 | exports.getState = async function (light) { 138 | if (light.type === 'hue') { 139 | return hue.getState(light) 140 | } else if (light.type === 'fastled') { 141 | return fastled.getState(light) 142 | } 143 | } 144 | 145 | exports.addLog = function (lightId, message) { 146 | if (typeof logs[lightId] === 'undefined') logs[lightId] = [] 147 | logs[lightId].unshift('[' + (new Date()).toString() + '] ' + message) 148 | while (logs[lightId].length > 20) { 149 | logs[lightId].pop() 150 | } 151 | logger.info(message) 152 | } 153 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable new-cap */ 2 | 3 | const winston = require('winston') 4 | const path = require('path') 5 | 6 | const logPath = path.join(__dirname, '..', 'logs') 7 | const logger = new (winston.createLogger)({ 8 | transports: [ 9 | new (winston.transports.Console)(), 10 | new (winston.transports.File)({ 11 | name: 'info-file', 12 | filename: path.resolve(logPath, 'info.log'), 13 | level: 'info' 14 | }), 15 | new (winston.transports.File)({ 16 | name: 'error-file', 17 | filename: path.resolve(logPath, 'error.log'), 18 | level: 'error' 19 | }) 20 | ] 21 | }) 22 | 23 | logger.stream = { 24 | write: function (message, encoding) { 25 | // jshint unused:false 26 | 27 | logger.info(message) 28 | } 29 | } 30 | module.exports = logger 31 | -------------------------------------------------------------------------------- /lib/time-check.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment') 2 | 3 | const isBetween = function (curDate, lowerBound, upperBound) { 4 | const lowerBoundDate = moment(lowerBound, 'h:ma') 5 | const upperBoundDate = moment(upperBound, 'h:ma') 6 | 7 | if (lowerBoundDate <= upperBoundDate) { 8 | return (lowerBoundDate <= curDate && curDate <= upperBoundDate) 9 | } else { 10 | return (lowerBoundDate <= curDate || curDate <= upperBoundDate) 11 | } 12 | } 13 | 14 | exports.schedulePicker = function (light) { 15 | const schedules = Object.values(light.colorSchedule) 16 | const curDate = moment() 17 | let closestTime = moment().add(1, 'd') 18 | let lightSettings = false 19 | 20 | // Determine what schedule currently applies based on newest set 21 | // or most time remaining 22 | let schedule 23 | for (let i = 0; i < schedules.length; i++) { 24 | schedule = schedules[i] 25 | 26 | if (!schedule.start) continue 27 | 28 | schedule.actualStart = moment(schedule.start, 'h:ma') 29 | schedule.actualEnd = moment(schedule.end, 'h:ma') 30 | if (schedule.actualEnd < curDate) { 31 | schedule.actualEnd.add(1, 'd') 32 | } 33 | schedule.nextStart = moment(schedule.start, 'h:ma') 34 | if (schedule.nextStart < curDate) { 35 | schedule.nextStart.add(1, 'd') 36 | } 37 | 38 | // Watch the next time a change happens 39 | if (schedule.nextStart < closestTime) closestTime = schedule.nextStart 40 | if (schedule.actualEnd < closestTime) closestTime = schedule.actualEnd 41 | 42 | if (isBetween(curDate, schedule.start, schedule.end)) { 43 | if (lightSettings === false) { 44 | lightSettings = schedule 45 | } else { 46 | if (lightSettings.start !== schedule.start) { 47 | if (schedule.actualStart > lightSettings.actualStart) { 48 | lightSettings = schedule 49 | } 50 | } else if (lightSettings.actualEnd < schedule.actualEnd) { 51 | lightSettings = schedule 52 | } 53 | } 54 | } else if (schedule.state === 'off' && curDate.format('h:mm a') === schedule.start) { 55 | lightSettings = schedule 56 | } 57 | } 58 | 59 | return { lightSettings, closestTime } 60 | } 61 | -------------------------------------------------------------------------------- /logs/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": [ 4 | ".git", 5 | "node_modules/**/node_modules", 6 | "config/*.json" 7 | ], 8 | "verbose": false, 9 | "env": { 10 | "NODE_ENV": "development" 11 | }, 12 | "ext": "js" 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hue-website-nightlights", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/ecaron/hue-website-nightlights.git" 8 | }, 9 | "dependencies": { 10 | "axios": "^0.21.1", 11 | "bootstrap-icons": "^1.5.0", 12 | "cie-rgb-color-converter": "^1.0.6", 13 | "connect-flash": "0.1.1", 14 | "dotenv": "^10.0.0", 15 | "express": "4.17.1", 16 | "express-session": "1.17.2", 17 | "flatpickr": "^4.6.9", 18 | "lokijs": "^1.5.12", 19 | "moment": "2.29.1", 20 | "morgan": "1.10.0", 21 | "node-hue-api": "^5.0.0-beta.6", 22 | "nunjucks": "^3.2.3", 23 | "winston": "3.3.3" 24 | }, 25 | "devDependencies": { 26 | "snazzy": "9.0.0", 27 | "standard": "16.0.3" 28 | }, 29 | "standard": { 30 | "ignore": [ 31 | "public/scripts/bootstrap.min.js", 32 | "public/scripts/jscolor.js" 33 | ] 34 | }, 35 | "scripts": { 36 | "start": "node server", 37 | "lint": "standard | snazzy", 38 | "standard": "standard --fix" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/css/custom.css: -------------------------------------------------------------------------------- 1 | .flash-boxes p { 2 | border-radius: 1em; 3 | } 4 | .card-title { 5 | border-bottom: 1px solid #ccc; 6 | } 7 | button, .button { 8 | transition: all 0.3s ease 0s; 9 | } 10 | button.color-btn { 11 | border:1px solid #ccc; 12 | } 13 | .expand-log { 14 | cursor: pointer; 15 | } 16 | 17 | .hover-zone { 18 | border-radius: 12px; 19 | border-style: solid; 20 | border-width: 1px 2px 2px 1px; 21 | border-color: #BF6E3A; 22 | background: #FFDEBA; 23 | margin: 6px; 24 | } 25 | 26 | .hover-zone:hover { 27 | background: #FFAE7A; 28 | transition: all 0.3s ease 0s; 29 | } 30 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecaron/smart-nightlight-manager/8d498cb49eb4e16148559397b9fc10ff94443849/public/favicon.ico -------------------------------------------------------------------------------- /public/scripts/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v5.0.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 5 | */ 6 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter(t=>t.matches(e)),parents(t,e){const i=[];let n=t.parentNode;for(;n&&n.nodeType===Node.ELEMENT_NODE&&3!==n.nodeType;)n.matches(e)&&i.push(n),n=n.parentNode;return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]}},e=t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t},i=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i="#"+i.split("#")[1]),e=i&&"#"!==i?i.trim():null}return e},n=t=>{const e=i(t);return e&&document.querySelector(e)?e:null},s=t=>{const e=i(t);return e?document.querySelector(e):null},o=t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0},r=t=>{t.dispatchEvent(new Event("transitionend"))},a=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),l=e=>a(e)?e.jquery?e[0]:e:"string"==typeof e&&e.length>0?t.findOne(e):null,c=(t,e)=>{let i=!1;const n=e+5;t.addEventListener("transitionend",(function e(){i=!0,t.removeEventListener("transitionend",e)})),setTimeout(()=>{i||r(t)},n)},d=(t,e,i)=>{Object.keys(i).forEach(n=>{const s=i[n],o=e[n],r=o&&a(o)?"element":null==(l=o)?""+l:{}.toString.call(l).match(/\s([a-z]+)/i)[1].toLowerCase();var l;if(!new RegExp(s).test(r))throw new TypeError(`${t.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)})},h=t=>{if(!t)return!1;if(t.style&&t.parentNode&&t.parentNode.style){const e=getComputedStyle(t),i=getComputedStyle(t.parentNode);return"none"!==e.display&&"none"!==i.display&&"hidden"!==e.visibility}return!1},u=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),f=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?f(t.parentNode):null},p=()=>{},m=t=>t.offsetHeight,g=()=>{const{jQuery:t}=window;return t&&!document.body.hasAttribute("data-bs-no-jquery")?t:null},_=()=>"rtl"===document.documentElement.dir,b=t=>{var e;e=()=>{const e=g();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e):e()},v=t=>{"function"==typeof t&&t()},y=new Map;var w={set(t,e,i){y.has(t)||y.set(t,new Map);const n=y.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>y.has(t)&&y.get(t).get(e)||null,remove(t,e){if(!y.has(t))return;const i=y.get(t);i.delete(e),0===i.size&&y.delete(t)}};const E=/[^.]*(?=\..*)\.|.*/,T=/\..*/,A=/::\d+$/,L={};let O=1;const k={mouseenter:"mouseover",mouseleave:"mouseout"},C=/^(mouseenter|mouseleave)/i,x=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function D(t,e){return e&&`${e}::${O++}`||t.uidEvent||O++}function N(t){const e=D(t);return t.uidEvent=e,L[e]=L[e]||{},L[e]}function S(t,e,i=null){const n=Object.keys(t);for(let s=0,o=n.length;sfunction(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};n?n=t(n):i=t(i)}const[o,r,a]=I(e,i,n),l=N(t),c=l[a]||(l[a]={}),d=S(c,r,o?i:null);if(d)return void(d.oneOff=d.oneOff&&s);const h=D(r,e.replace(E,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(let a=o.length;a--;)if(o[a]===r)return s.delegateTarget=r,n.oneOff&&H.off(t,s.type,e,i),i.apply(r,[s]);return null}}(t,i,n):function(t,e){return function i(n){return n.delegateTarget=t,i.oneOff&&H.off(t,n.type,e),e.apply(t,[n])}}(t,i);u.delegationSelector=o?i:null,u.originalHandler=r,u.oneOff=s,u.uidEvent=h,c[h]=u,t.addEventListener(a,u,o)}function P(t,e,i,n,s){const o=S(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function M(t){return t=t.replace(T,""),k[t]||t}const H={on(t,e,i,n){j(t,e,i,n,!1)},one(t,e,i,n){j(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=I(e,i,n),a=r!==e,l=N(t),c=e.startsWith(".");if(void 0!==o){if(!l||!l[r])return;return void P(t,l,r,o,s?i:null)}c&&Object.keys(l).forEach(i=>{!function(t,e,i,n){const s=e[i]||{};Object.keys(s).forEach(o=>{if(o.includes(n)){const n=s[o];P(t,e,i,n.originalHandler,n.delegationSelector)}})}(t,l,i,e.slice(1))});const d=l[r]||{};Object.keys(d).forEach(i=>{const n=i.replace(A,"");if(!a||e.includes(n)){const e=d[i];P(t,l,r,e.originalHandler,e.delegationSelector)}})},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=g(),s=M(e),o=e!==s,r=x.has(s);let a,l=!0,c=!0,d=!1,h=null;return o&&n&&(a=n.Event(e,i),n(t).trigger(a),l=!a.isPropagationStopped(),c=!a.isImmediatePropagationStopped(),d=a.isDefaultPrevented()),r?(h=document.createEvent("HTMLEvents"),h.initEvent(s,l,!0)):h=new CustomEvent(e,{bubbles:l,cancelable:!0}),void 0!==i&&Object.keys(i).forEach(t=>{Object.defineProperty(h,t,{get:()=>i[t]})}),d&&h.preventDefault(),c&&t.dispatchEvent(h),h.defaultPrevented&&void 0!==a&&a.preventDefault(),h}};class R{constructor(t){(t=l(t))&&(this._element=t,w.set(this._element,this.constructor.DATA_KEY,this))}dispose(){w.remove(this._element,this.constructor.DATA_KEY),H.off(this._element,this.constructor.EVENT_KEY),Object.getOwnPropertyNames(this).forEach(t=>{this[t]=null})}_queueCallback(t,e,i=!0){if(!i)return void v(t);const n=o(e);H.one(e,"transitionend",()=>v(t)),c(e,n)}static getInstance(t){return w.get(t,this.DATA_KEY)}static get VERSION(){return"5.0.1"}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}static get DATA_KEY(){return"bs."+this.NAME}static get EVENT_KEY(){return"."+this.DATA_KEY}}class B extends R{static get NAME(){return"alert"}close(t){const e=t?this._getRootElement(t):this._element,i=this._triggerCloseEvent(e);null===i||i.defaultPrevented||this._removeElement(e)}_getRootElement(t){return s(t)||t.closest(".alert")}_triggerCloseEvent(t){return H.trigger(t,"close.bs.alert")}_removeElement(t){t.classList.remove("show");const e=t.classList.contains("fade");this._queueCallback(()=>this._destroyElement(t),t,e)}_destroyElement(t){t.parentNode&&t.parentNode.removeChild(t),H.trigger(t,"closed.bs.alert")}static jQueryInterface(t){return this.each((function(){let e=w.get(this,"bs.alert");e||(e=new B(this)),"close"===t&&e[t](this)}))}static handleDismiss(t){return function(e){e&&e.preventDefault(),t.close(this)}}}H.on(document,"click.bs.alert.data-api",'[data-bs-dismiss="alert"]',B.handleDismiss(new B)),b(B);class W extends R{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){let e=w.get(this,"bs.button");e||(e=new W(this)),"toggle"===t&&e[t]()}))}}function q(t){return"true"===t||"false"!==t&&(t===Number(t).toString()?Number(t):""===t||"null"===t?null:t)}function z(t){return t.replace(/[A-Z]/g,t=>"-"+t.toLowerCase())}H.on(document,"click.bs.button.data-api",'[data-bs-toggle="button"]',t=>{t.preventDefault();const e=t.target.closest('[data-bs-toggle="button"]');let i=w.get(e,"bs.button");i||(i=new W(e)),i.toggle()}),b(W);const U={setDataAttribute(t,e,i){t.setAttribute("data-bs-"+z(e),i)},removeDataAttribute(t,e){t.removeAttribute("data-bs-"+z(e))},getDataAttributes(t){if(!t)return{};const e={};return Object.keys(t.dataset).filter(t=>t.startsWith("bs")).forEach(i=>{let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),e[n]=q(t.dataset[i])}),e},getDataAttribute:(t,e)=>q(t.getAttribute("data-bs-"+z(e))),offset(t){const e=t.getBoundingClientRect();return{top:e.top+document.body.scrollTop,left:e.left+document.body.scrollLeft}},position:t=>({top:t.offsetTop,left:t.offsetLeft})},$={interval:5e3,keyboard:!0,slide:!1,pause:"hover",wrap:!0,touch:!0},F={interval:"(number|boolean)",keyboard:"boolean",slide:"(boolean|string)",pause:"(string|boolean)",wrap:"boolean",touch:"boolean"},V="next",K="prev",X="left",Y="right";class Q extends R{constructor(e,i){super(e),this._items=null,this._interval=null,this._activeElement=null,this._isPaused=!1,this._isSliding=!1,this.touchTimeout=null,this.touchStartX=0,this.touchDeltaX=0,this._config=this._getConfig(i),this._indicatorsElement=t.findOne(".carousel-indicators",this._element),this._touchSupported="ontouchstart"in document.documentElement||navigator.maxTouchPoints>0,this._pointerEvent=Boolean(window.PointerEvent),this._addEventListeners()}static get Default(){return $}static get NAME(){return"carousel"}next(){this._isSliding||this._slide(V)}nextWhenVisible(){!document.hidden&&h(this._element)&&this.next()}prev(){this._isSliding||this._slide(K)}pause(e){e||(this._isPaused=!0),t.findOne(".carousel-item-next, .carousel-item-prev",this._element)&&(r(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null}cycle(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config&&this._config.interval&&!this._isPaused&&(this._updateInterval(),this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))}to(e){this._activeElement=t.findOne(".active.carousel-item",this._element);const i=this._getItemIndex(this._activeElement);if(e>this._items.length-1||e<0)return;if(this._isSliding)return void H.one(this._element,"slid.bs.carousel",()=>this.to(e));if(i===e)return this.pause(),void this.cycle();const n=e>i?V:K;this._slide(n,this._items[e])}_getConfig(t){return t={...$,...t},d("carousel",t,F),t}_handleSwipe(){const t=Math.abs(this.touchDeltaX);if(t<=40)return;const e=t/this.touchDeltaX;this.touchDeltaX=0,e&&this._slide(e>0?Y:X)}_addEventListeners(){this._config.keyboard&&H.on(this._element,"keydown.bs.carousel",t=>this._keydown(t)),"hover"===this._config.pause&&(H.on(this._element,"mouseenter.bs.carousel",t=>this.pause(t)),H.on(this._element,"mouseleave.bs.carousel",t=>this.cycle(t))),this._config.touch&&this._touchSupported&&this._addTouchEventListeners()}_addTouchEventListeners(){const e=t=>{!this._pointerEvent||"pen"!==t.pointerType&&"touch"!==t.pointerType?this._pointerEvent||(this.touchStartX=t.touches[0].clientX):this.touchStartX=t.clientX},i=t=>{this.touchDeltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this.touchStartX},n=t=>{!this._pointerEvent||"pen"!==t.pointerType&&"touch"!==t.pointerType||(this.touchDeltaX=t.clientX-this.touchStartX),this._handleSwipe(),"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout(t=>this.cycle(t),500+this._config.interval))};t.find(".carousel-item img",this._element).forEach(t=>{H.on(t,"dragstart.bs.carousel",t=>t.preventDefault())}),this._pointerEvent?(H.on(this._element,"pointerdown.bs.carousel",t=>e(t)),H.on(this._element,"pointerup.bs.carousel",t=>n(t)),this._element.classList.add("pointer-event")):(H.on(this._element,"touchstart.bs.carousel",t=>e(t)),H.on(this._element,"touchmove.bs.carousel",t=>i(t)),H.on(this._element,"touchend.bs.carousel",t=>n(t)))}_keydown(t){/input|textarea/i.test(t.target.tagName)||("ArrowLeft"===t.key?(t.preventDefault(),this._slide(Y)):"ArrowRight"===t.key&&(t.preventDefault(),this._slide(X)))}_getItemIndex(e){return this._items=e&&e.parentNode?t.find(".carousel-item",e.parentNode):[],this._items.indexOf(e)}_getItemByOrder(t,e){const i=t===V,n=t===K,s=this._getItemIndex(e),o=this._items.length-1;if((n&&0===s||i&&s===o)&&!this._config.wrap)return e;const r=(s+(n?-1:1))%this._items.length;return-1===r?this._items[this._items.length-1]:this._items[r]}_triggerSlideEvent(e,i){const n=this._getItemIndex(e),s=this._getItemIndex(t.findOne(".active.carousel-item",this._element));return H.trigger(this._element,"slide.bs.carousel",{relatedTarget:e,direction:i,from:s,to:n})}_setActiveIndicatorElement(e){if(this._indicatorsElement){const i=t.findOne(".active",this._indicatorsElement);i.classList.remove("active"),i.removeAttribute("aria-current");const n=t.find("[data-bs-target]",this._indicatorsElement);for(let t=0;t{H.trigger(this._element,"slid.bs.carousel",{relatedTarget:r,direction:u,from:o,to:a})};if(this._element.classList.contains("slide")){r.classList.add(h),m(r),s.classList.add(d),r.classList.add(d);const t=()=>{r.classList.remove(d,h),r.classList.add("active"),s.classList.remove("active",h,d),this._isSliding=!1,setTimeout(f,0)};this._queueCallback(t,s,!0)}else s.classList.remove("active"),r.classList.add("active"),this._isSliding=!1,f();l&&this.cycle()}_directionToOrder(t){return[Y,X].includes(t)?_()?t===X?K:V:t===X?V:K:t}_orderToDirection(t){return[V,K].includes(t)?_()?t===K?X:Y:t===K?Y:X:t}static carouselInterface(t,e){let i=w.get(t,"bs.carousel"),n={...$,...U.getDataAttributes(t)};"object"==typeof e&&(n={...n,...e});const s="string"==typeof e?e:n.slide;if(i||(i=new Q(t,n)),"number"==typeof e)i.to(e);else if("string"==typeof s){if(void 0===i[s])throw new TypeError(`No method named "${s}"`);i[s]()}else n.interval&&n.ride&&(i.pause(),i.cycle())}static jQueryInterface(t){return this.each((function(){Q.carouselInterface(this,t)}))}static dataApiClickHandler(t){const e=s(this);if(!e||!e.classList.contains("carousel"))return;const i={...U.getDataAttributes(e),...U.getDataAttributes(this)},n=this.getAttribute("data-bs-slide-to");n&&(i.interval=!1),Q.carouselInterface(e,i),n&&w.get(e,"bs.carousel").to(n),t.preventDefault()}}H.on(document,"click.bs.carousel.data-api","[data-bs-slide], [data-bs-slide-to]",Q.dataApiClickHandler),H.on(window,"load.bs.carousel.data-api",()=>{const e=t.find('[data-bs-ride="carousel"]');for(let t=0,i=e.length;tt===this._element);null!==o&&r.length&&(this._selector=o,this._triggerArray.push(i))}this._parent=this._config.parent?this._getParent():null,this._config.parent||this._addAriaAndCollapsedClass(this._element,this._triggerArray),this._config.toggle&&this.toggle()}static get Default(){return G}static get NAME(){return"collapse"}toggle(){this._element.classList.contains("show")?this.hide():this.show()}show(){if(this._isTransitioning||this._element.classList.contains("show"))return;let e,i;this._parent&&(e=t.find(".show, .collapsing",this._parent).filter(t=>"string"==typeof this._config.parent?t.getAttribute("data-bs-parent")===this._config.parent:t.classList.contains("collapse")),0===e.length&&(e=null));const n=t.findOne(this._selector);if(e){const t=e.find(t=>n!==t);if(i=t?w.get(t,"bs.collapse"):null,i&&i._isTransitioning)return}if(H.trigger(this._element,"show.bs.collapse").defaultPrevented)return;e&&e.forEach(t=>{n!==t&&J.collapseInterface(t,"hide"),i||w.set(t,"bs.collapse",null)});const s=this._getDimension();this._element.classList.remove("collapse"),this._element.classList.add("collapsing"),this._element.style[s]=0,this._triggerArray.length&&this._triggerArray.forEach(t=>{t.classList.remove("collapsed"),t.setAttribute("aria-expanded",!0)}),this.setTransitioning(!0);const o="scroll"+(s[0].toUpperCase()+s.slice(1));this._queueCallback(()=>{this._element.classList.remove("collapsing"),this._element.classList.add("collapse","show"),this._element.style[s]="",this.setTransitioning(!1),H.trigger(this._element,"shown.bs.collapse")},this._element,!0),this._element.style[s]=this._element[o]+"px"}hide(){if(this._isTransitioning||!this._element.classList.contains("show"))return;if(H.trigger(this._element,"hide.bs.collapse").defaultPrevented)return;const t=this._getDimension();this._element.style[t]=this._element.getBoundingClientRect()[t]+"px",m(this._element),this._element.classList.add("collapsing"),this._element.classList.remove("collapse","show");const e=this._triggerArray.length;if(e>0)for(let t=0;t{this.setTransitioning(!1),this._element.classList.remove("collapsing"),this._element.classList.add("collapse"),H.trigger(this._element,"hidden.bs.collapse")},this._element,!0)}setTransitioning(t){this._isTransitioning=t}_getConfig(t){return(t={...G,...t}).toggle=Boolean(t.toggle),d("collapse",t,Z),t}_getDimension(){return this._element.classList.contains("width")?"width":"height"}_getParent(){let{parent:e}=this._config;e=l(e);const i=`[data-bs-toggle="collapse"][data-bs-parent="${e}"]`;return t.find(i,e).forEach(t=>{const e=s(t);this._addAriaAndCollapsedClass(e,[t])}),e}_addAriaAndCollapsedClass(t,e){if(!t||!e.length)return;const i=t.classList.contains("show");e.forEach(t=>{i?t.classList.remove("collapsed"):t.classList.add("collapsed"),t.setAttribute("aria-expanded",i)})}static collapseInterface(t,e){let i=w.get(t,"bs.collapse");const n={...G,...U.getDataAttributes(t),..."object"==typeof e&&e?e:{}};if(!i&&n.toggle&&"string"==typeof e&&/show|hide/.test(e)&&(n.toggle=!1),i||(i=new J(t,n)),"string"==typeof e){if(void 0===i[e])throw new TypeError(`No method named "${e}"`);i[e]()}}static jQueryInterface(t){return this.each((function(){J.collapseInterface(this,t)}))}}H.on(document,"click.bs.collapse.data-api",'[data-bs-toggle="collapse"]',(function(e){("A"===e.target.tagName||e.delegateTarget&&"A"===e.delegateTarget.tagName)&&e.preventDefault();const i=U.getDataAttributes(this),s=n(this);t.find(s).forEach(t=>{const e=w.get(t,"bs.collapse");let n;e?(null===e._parent&&"string"==typeof i.parent&&(e._config.parent=i.parent,e._parent=e._getParent()),n="toggle"):n=i,J.collapseInterface(t,n)})})),b(J);var tt="top",et="bottom",it="right",nt="left",st=[tt,et,it,nt],ot=st.reduce((function(t,e){return t.concat([e+"-start",e+"-end"])}),[]),rt=[].concat(st,["auto"]).reduce((function(t,e){return t.concat([e,e+"-start",e+"-end"])}),[]),at=["beforeRead","read","afterRead","beforeMain","main","afterMain","beforeWrite","write","afterWrite"];function lt(t){return t?(t.nodeName||"").toLowerCase():null}function ct(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function dt(t){return t instanceof ct(t).Element||t instanceof Element}function ht(t){return t instanceof ct(t).HTMLElement||t instanceof HTMLElement}function ut(t){return"undefined"!=typeof ShadowRoot&&(t instanceof ct(t).ShadowRoot||t instanceof ShadowRoot)}var ft={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];ht(s)&<(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});ht(n)&<(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function pt(t){return t.split("-")[0]}function mt(t){var e=t.getBoundingClientRect();return{width:e.width,height:e.height,top:e.top,right:e.right,bottom:e.bottom,left:e.left,x:e.left,y:e.top}}function gt(t){var e=mt(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function _t(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&ut(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function bt(t){return ct(t).getComputedStyle(t)}function vt(t){return["table","td","th"].indexOf(lt(t))>=0}function yt(t){return((dt(t)?t.ownerDocument:t.document)||window.document).documentElement}function wt(t){return"html"===lt(t)?t:t.assignedSlot||t.parentNode||(ut(t)?t.host:null)||yt(t)}function Et(t){return ht(t)&&"fixed"!==bt(t).position?t.offsetParent:null}function Tt(t){for(var e=ct(t),i=Et(t);i&&vt(i)&&"static"===bt(i).position;)i=Et(i);return i&&("html"===lt(i)||"body"===lt(i)&&"static"===bt(i).position)?e:i||function(t){var e=-1!==navigator.userAgent.toLowerCase().indexOf("firefox");if(-1!==navigator.userAgent.indexOf("Trident")&&ht(t)&&"fixed"===bt(t).position)return null;for(var i=wt(t);ht(i)&&["html","body"].indexOf(lt(i))<0;){var n=bt(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function At(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}var Lt=Math.max,Ot=Math.min,kt=Math.round;function Ct(t,e,i){return Lt(t,Ot(e,i))}function xt(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function Dt(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}var Nt={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=pt(i.placement),l=At(a),c=[nt,it].indexOf(a)>=0?"height":"width";if(o&&r){var d=function(t,e){return xt("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:Dt(t,st))}(s.padding,i),h=gt(o),u="y"===l?tt:nt,f="y"===l?et:it,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=Tt(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=d[u],y=_-h[c]-d[f],w=_/2-h[c]/2+b,E=Ct(v,w,y),T=l;i.modifiersData[n]=((e={})[T]=E,e.centerOffset=E-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&_t(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]},St={top:"auto",right:"auto",bottom:"auto",left:"auto"};function It(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.offsets,r=t.position,a=t.gpuAcceleration,l=t.adaptive,c=t.roundOffsets,d=!0===c?function(t){var e=t.x,i=t.y,n=window.devicePixelRatio||1;return{x:kt(kt(e*n)/n)||0,y:kt(kt(i*n)/n)||0}}(o):"function"==typeof c?c(o):o,h=d.x,u=void 0===h?0:h,f=d.y,p=void 0===f?0:f,m=o.hasOwnProperty("x"),g=o.hasOwnProperty("y"),_=nt,b=tt,v=window;if(l){var y=Tt(i),w="clientHeight",E="clientWidth";y===ct(i)&&"static"!==bt(y=yt(i)).position&&(w="scrollHeight",E="scrollWidth"),y=y,s===tt&&(b=et,p-=y[w]-n.height,p*=a?1:-1),s===nt&&(_=it,u-=y[E]-n.width,u*=a?1:-1)}var T,A=Object.assign({position:r},l&&St);return a?Object.assign({},A,((T={})[b]=g?"0":"",T[_]=m?"0":"",T.transform=(v.devicePixelRatio||1)<2?"translate("+u+"px, "+p+"px)":"translate3d("+u+"px, "+p+"px, 0)",T)):Object.assign({},A,((e={})[b]=g?p+"px":"",e[_]=m?u+"px":"",e.transform="",e))}var jt={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:pt(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,It(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,It(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}},Pt={passive:!0},Mt={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=ct(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,Pt)})),a&&l.addEventListener("resize",i.update,Pt),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,Pt)})),a&&l.removeEventListener("resize",i.update,Pt)}},data:{}},Ht={left:"right",right:"left",bottom:"top",top:"bottom"};function Rt(t){return t.replace(/left|right|bottom|top/g,(function(t){return Ht[t]}))}var Bt={start:"end",end:"start"};function Wt(t){return t.replace(/start|end/g,(function(t){return Bt[t]}))}function qt(t){var e=ct(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function zt(t){return mt(yt(t)).left+qt(t).scrollLeft}function Ut(t){var e=bt(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function $t(t,e){var i;void 0===e&&(e=[]);var n=function t(e){return["html","body","#document"].indexOf(lt(e))>=0?e.ownerDocument.body:ht(e)&&Ut(e)?e:t(wt(e))}(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=ct(n),r=s?[o].concat(o.visualViewport||[],Ut(n)?n:[]):n,a=e.concat(r);return s?a:a.concat($t(wt(r)))}function Ft(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function Vt(t,e){return"viewport"===e?Ft(function(t){var e=ct(t),i=yt(t),n=e.visualViewport,s=i.clientWidth,o=i.clientHeight,r=0,a=0;return n&&(s=n.width,o=n.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(r=n.offsetLeft,a=n.offsetTop)),{width:s,height:o,x:r+zt(t),y:a}}(t)):ht(e)?function(t){var e=mt(t);return e.top=e.top+t.clientTop,e.left=e.left+t.clientLeft,e.bottom=e.top+t.clientHeight,e.right=e.left+t.clientWidth,e.width=t.clientWidth,e.height=t.clientHeight,e.x=e.left,e.y=e.top,e}(e):Ft(function(t){var e,i=yt(t),n=qt(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=Lt(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=Lt(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+zt(t),l=-n.scrollTop;return"rtl"===bt(s||i).direction&&(a+=Lt(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(yt(t)))}function Kt(t){return t.split("-")[1]}function Xt(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?pt(s):null,r=s?Kt(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case tt:e={x:a,y:i.y-n.height};break;case et:e={x:a,y:i.y+i.height};break;case it:e={x:i.x+i.width,y:l};break;case nt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?At(o):null;if(null!=c){var d="y"===c?"height":"width";switch(r){case"start":e[c]=e[c]-(i[d]/2-n[d]/2);break;case"end":e[c]=e[c]+(i[d]/2-n[d]/2)}}return e}function Yt(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.boundary,r=void 0===o?"clippingParents":o,a=i.rootBoundary,l=void 0===a?"viewport":a,c=i.elementContext,d=void 0===c?"popper":c,h=i.altBoundary,u=void 0!==h&&h,f=i.padding,p=void 0===f?0:f,m=xt("number"!=typeof p?p:Dt(p,st)),g="popper"===d?"reference":"popper",_=t.elements.reference,b=t.rects.popper,v=t.elements[u?g:d],y=function(t,e,i){var n="clippingParents"===e?function(t){var e=$t(wt(t)),i=["absolute","fixed"].indexOf(bt(t).position)>=0&&ht(t)?Tt(t):t;return dt(i)?e.filter((function(t){return dt(t)&&_t(t,i)&&"body"!==lt(t)})):[]}(t):[].concat(e),s=[].concat(n,[i]),o=s[0],r=s.reduce((function(e,i){var n=Vt(t,i);return e.top=Lt(n.top,e.top),e.right=Ot(n.right,e.right),e.bottom=Ot(n.bottom,e.bottom),e.left=Lt(n.left,e.left),e}),Vt(t,o));return r.width=r.right-r.left,r.height=r.bottom-r.top,r.x=r.left,r.y=r.top,r}(dt(v)?v:v.contextElement||yt(t.elements.popper),r,l),w=mt(_),E=Xt({reference:w,element:b,strategy:"absolute",placement:s}),T=Ft(Object.assign({},b,E)),A="popper"===d?T:w,L={top:y.top-A.top+m.top,bottom:A.bottom-y.bottom+m.bottom,left:y.left-A.left+m.left,right:A.right-y.right+m.right},O=t.modifiersData.offset;if("popper"===d&&O){var k=O[s];Object.keys(L).forEach((function(t){var e=[it,et].indexOf(t)>=0?1:-1,i=[tt,et].indexOf(t)>=0?"y":"x";L[t]+=k[i]*e}))}return L}function Qt(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?rt:l,d=Kt(n),h=d?a?ot:ot.filter((function(t){return Kt(t)===d})):st,u=h.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=h);var f=u.reduce((function(e,i){return e[i]=Yt(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[pt(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}var Gt={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,d=i.boundary,h=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=pt(g),b=l||(_!==g&&p?function(t){if("auto"===pt(t))return[];var e=Rt(t);return[Wt(t),e,Wt(e)]}(g):[Rt(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat("auto"===pt(i)?Qt(e,{placement:i,boundary:d,rootBoundary:h,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,E=new Map,T=!0,A=v[0],L=0;L=0,D=x?"width":"height",N=Yt(e,{placement:O,boundary:d,rootBoundary:h,altBoundary:u,padding:c}),S=x?C?it:nt:C?et:tt;y[D]>w[D]&&(S=Rt(S));var I=Rt(S),j=[];if(o&&j.push(N[k]<=0),a&&j.push(N[S]<=0,N[I]<=0),j.every((function(t){return t}))){A=O,T=!1;break}E.set(O,j)}if(T)for(var P=function(t){var e=v.find((function(e){var i=E.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return A=e,"break"},M=p?3:1;M>0&&"break"!==P(M);M--);e.placement!==A&&(e.modifiersData[n]._skip=!0,e.placement=A,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function Zt(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function Jt(t){return[tt,it,et,nt].some((function(e){return t[e]>=0}))}var te={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=Yt(e,{elementContext:"reference"}),a=Yt(e,{altBoundary:!0}),l=Zt(r,n),c=Zt(a,s,o),d=Jt(l),h=Jt(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:d,hasPopperEscaped:h},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":d,"data-popper-escaped":h})}},ee={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=rt.reduce((function(t,i){return t[i]=function(t,e,i){var n=pt(t),s=[nt,tt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[nt,it].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},ie={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=Xt({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},ne={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,d=i.altBoundary,h=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=Yt(e,{boundary:l,rootBoundary:c,padding:h,altBoundary:d}),_=pt(e.placement),b=Kt(e.placement),v=!b,y=At(_),w="x"===y?"y":"x",E=e.modifiersData.popperOffsets,T=e.rects.reference,A=e.rects.popper,L="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O={x:0,y:0};if(E){if(o||a){var k="y"===y?tt:nt,C="y"===y?et:it,x="y"===y?"height":"width",D=E[y],N=E[y]+g[k],S=E[y]-g[C],I=f?-A[x]/2:0,j="start"===b?T[x]:A[x],P="start"===b?-A[x]:-T[x],M=e.elements.arrow,H=f&&M?gt(M):{width:0,height:0},R=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},B=R[k],W=R[C],q=Ct(0,T[x],H[x]),z=v?T[x]/2-I-q-B-L:j-q-B-L,U=v?-T[x]/2+I+q+W+L:P+q+W+L,$=e.elements.arrow&&Tt(e.elements.arrow),F=$?"y"===y?$.clientTop||0:$.clientLeft||0:0,V=e.modifiersData.offset?e.modifiersData.offset[e.placement][y]:0,K=E[y]+z-V-F,X=E[y]+U-V;if(o){var Y=Ct(f?Ot(N,K):N,D,f?Lt(S,X):S);E[y]=Y,O[y]=Y-D}if(a){var Q="x"===y?tt:nt,G="x"===y?et:it,Z=E[w],J=Z+g[Q],st=Z-g[G],ot=Ct(f?Ot(J,K):J,Z,f?Lt(st,X):st);E[w]=ot,O[w]=ot-Z}}e.modifiersData[n]=O}},requiresIfExists:["offset"]};function se(t,e,i){void 0===i&&(i=!1);var n,s,o=yt(e),r=mt(t),a=ht(e),l={scrollLeft:0,scrollTop:0},c={x:0,y:0};return(a||!a&&!i)&&(("body"!==lt(e)||Ut(o))&&(l=(n=e)!==ct(n)&&ht(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:qt(n)),ht(e)?((c=mt(e)).x+=e.clientLeft,c.y+=e.clientTop):o&&(c.x=zt(o))),{x:r.left+l.scrollLeft-c.x,y:r.top+l.scrollTop-c.y,width:r.width,height:r.height}}var oe={placement:"bottom",modifiers:[],strategy:"absolute"};function re(){for(var t=arguments.length,e=new Array(t),i=0;i"applyStyles"===t.name&&!1===t.enabled);this._popper=de(e,this._menu,i),n&&U.setDataAttribute(this._menu,"popper","static")}"ontouchstart"in document.documentElement&&!t.closest(".navbar-nav")&&[].concat(...document.body.children).forEach(t=>H.on(t,"mouseover",p)),this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.toggle("show"),this._element.classList.toggle("show"),H.trigger(this._element,"shown.bs.dropdown",e)}}hide(){if(u(this._element)||!this._menu.classList.contains("show"))return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_addEventListeners(){H.on(this._element,"click.bs.dropdown",t=>{t.preventDefault(),this.toggle()})}_completeHide(t){H.trigger(this._element,"hide.bs.dropdown",t).defaultPrevented||("ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach(t=>H.off(t,"mouseover",p)),this._popper&&this._popper.destroy(),this._menu.classList.remove("show"),this._element.classList.remove("show"),this._element.setAttribute("aria-expanded","false"),U.removeDataAttribute(this._menu,"popper"),H.trigger(this._element,"hidden.bs.dropdown",t))}_getConfig(t){if(t={...this.constructor.Default,...U.getDataAttributes(this._element),...t},d("dropdown",t,this.constructor.DefaultType),"object"==typeof t.reference&&!a(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError("dropdown".toUpperCase()+': Option "reference" provided type "object" without a required "getBoundingClientRect" method.');return t}_getMenuElement(){return t.next(this._element,".dropdown-menu")[0]}_getPlacement(){const t=this._element.parentNode;if(t.classList.contains("dropend"))return _e;if(t.classList.contains("dropstart"))return be;const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?pe:fe:e?ge:me}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map(t=>Number.parseInt(t,10)):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return"static"===this._config.display&&(t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,..."function"==typeof this._config.popperConfig?this._config.popperConfig(t):this._config.popperConfig}}_selectMenuItem(e){const i=t.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter(h);if(!i.length)return;let n=i.indexOf(e.target);"ArrowUp"===e.key&&n>0&&n--,"ArrowDown"===e.key&&nthis.matches('[data-bs-toggle="dropdown"]')?this:t.prev(this,'[data-bs-toggle="dropdown"]')[0];if("Escape"===e.key)return n().focus(),void we.clearMenus();i||"ArrowUp"!==e.key&&"ArrowDown"!==e.key?i&&"Space"!==e.key?we.getInstance(n())._selectMenuItem(e):we.clearMenus():n().click()}}H.on(document,"keydown.bs.dropdown.data-api",'[data-bs-toggle="dropdown"]',we.dataApiKeydownHandler),H.on(document,"keydown.bs.dropdown.data-api",".dropdown-menu",we.dataApiKeydownHandler),H.on(document,"click.bs.dropdown.data-api",we.clearMenus),H.on(document,"keyup.bs.dropdown.data-api",we.clearMenus),H.on(document,"click.bs.dropdown.data-api",'[data-bs-toggle="dropdown"]',(function(t){t.preventDefault(),we.dropdownInterface(this)})),b(we);const Ee=()=>{const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)},Te=(t=Ee())=>{Ae(),Le("body","paddingRight",e=>e+t),Le(".fixed-top, .fixed-bottom, .is-fixed, .sticky-top","paddingRight",e=>e+t),Le(".sticky-top","marginRight",e=>e-t)},Ae=()=>{const t=document.body.style.overflow;t&&U.setDataAttribute(document.body,"overflow",t),document.body.style.overflow="hidden"},Le=(e,i,n)=>{const s=Ee();t.find(e).forEach(t=>{if(t!==document.body&&window.innerWidth>t.clientWidth+s)return;const e=t.style[i],o=window.getComputedStyle(t)[i];U.setDataAttribute(t,i,e),t.style[i]=n(Number.parseFloat(o))+"px"})},Oe=()=>{ke("body","overflow"),ke("body","paddingRight"),ke(".fixed-top, .fixed-bottom, .is-fixed, .sticky-top","paddingRight"),ke(".sticky-top","marginRight")},ke=(e,i)=>{t.find(e).forEach(t=>{const e=U.getDataAttribute(t,i);void 0===e?t.style.removeProperty(i):(U.removeDataAttribute(t,i),t.style[i]=e)})},Ce={isVisible:!0,isAnimated:!1,rootElement:document.body,clickCallback:null},xe={isVisible:"boolean",isAnimated:"boolean",rootElement:"element",clickCallback:"(function|null)"};class De{constructor(t){this._config=this._getConfig(t),this._isAppended=!1,this._element=null}show(t){this._config.isVisible?(this._append(),this._config.isAnimated&&m(this._getElement()),this._getElement().classList.add("show"),this._emulateAnimation(()=>{v(t)})):v(t)}hide(t){this._config.isVisible?(this._getElement().classList.remove("show"),this._emulateAnimation(()=>{this.dispose(),v(t)})):v(t)}_getElement(){if(!this._element){const t=document.createElement("div");t.className="modal-backdrop",this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_getConfig(t){return(t={...Ce,..."object"==typeof t?t:{}}).rootElement=t.rootElement||document.body,d("backdrop",t,xe),t}_append(){this._isAppended||(this._config.rootElement.appendChild(this._getElement()),H.on(this._getElement(),"mousedown.bs.backdrop",()=>{v(this._config.clickCallback)}),this._isAppended=!0)}dispose(){this._isAppended&&(H.off(this._element,"mousedown.bs.backdrop"),this._getElement().parentNode.removeChild(this._element),this._isAppended=!1)}_emulateAnimation(t){if(!this._config.isAnimated)return void v(t);const e=o(this._getElement());H.one(this._getElement(),"transitionend",()=>v(t)),c(this._getElement(),e)}}const Ne={backdrop:!0,keyboard:!0,focus:!0},Se={backdrop:"(boolean|string)",keyboard:"boolean",focus:"boolean"};class Ie extends R{constructor(e,i){super(e),this._config=this._getConfig(i),this._dialog=t.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._isShown=!1,this._ignoreBackdropClick=!1,this._isTransitioning=!1}static get Default(){return Ne}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){if(this._isShown||this._isTransitioning)return;this._isAnimated()&&(this._isTransitioning=!0);const e=H.trigger(this._element,"show.bs.modal",{relatedTarget:t});this._isShown||e.defaultPrevented||(this._isShown=!0,Te(),document.body.classList.add("modal-open"),this._adjustDialog(),this._setEscapeEvent(),this._setResizeEvent(),H.on(this._element,"click.dismiss.bs.modal",'[data-bs-dismiss="modal"]',t=>this.hide(t)),H.on(this._dialog,"mousedown.dismiss.bs.modal",()=>{H.one(this._element,"mouseup.dismiss.bs.modal",t=>{t.target===this._element&&(this._ignoreBackdropClick=!0)})}),this._showBackdrop(()=>this._showElement(t)))}hide(t){if(t&&t.preventDefault(),!this._isShown||this._isTransitioning)return;if(H.trigger(this._element,"hide.bs.modal").defaultPrevented)return;this._isShown=!1;const e=this._isAnimated();e&&(this._isTransitioning=!0),this._setEscapeEvent(),this._setResizeEvent(),H.off(document,"focusin.bs.modal"),this._element.classList.remove("show"),H.off(this._element,"click.dismiss.bs.modal"),H.off(this._dialog,"mousedown.dismiss.bs.modal"),this._queueCallback(()=>this._hideModal(),this._element,e)}dispose(){[window,this._dialog].forEach(t=>H.off(t,".bs.modal")),this._backdrop.dispose(),super.dispose(),H.off(document,"focusin.bs.modal")}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new De({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_getConfig(t){return t={...Ne,...U.getDataAttributes(this._element),...t},d("modal",t,Se),t}_showElement(e){const i=this._isAnimated(),n=t.findOne(".modal-body",this._dialog);this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE||document.body.appendChild(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0,n&&(n.scrollTop=0),i&&m(this._element),this._element.classList.add("show"),this._config.focus&&this._enforceFocus(),this._queueCallback(()=>{this._config.focus&&this._element.focus(),this._isTransitioning=!1,H.trigger(this._element,"shown.bs.modal",{relatedTarget:e})},this._dialog,i)}_enforceFocus(){H.off(document,"focusin.bs.modal"),H.on(document,"focusin.bs.modal",t=>{document===t.target||this._element===t.target||this._element.contains(t.target)||this._element.focus()})}_setEscapeEvent(){this._isShown?H.on(this._element,"keydown.dismiss.bs.modal",t=>{this._config.keyboard&&"Escape"===t.key?(t.preventDefault(),this.hide()):this._config.keyboard||"Escape"!==t.key||this._triggerBackdropTransition()}):H.off(this._element,"keydown.dismiss.bs.modal")}_setResizeEvent(){this._isShown?H.on(window,"resize.bs.modal",()=>this._adjustDialog()):H.off(window,"resize.bs.modal")}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide(()=>{document.body.classList.remove("modal-open"),this._resetAdjustments(),Oe(),H.trigger(this._element,"hidden.bs.modal")})}_showBackdrop(t){H.on(this._element,"click.dismiss.bs.modal",t=>{this._ignoreBackdropClick?this._ignoreBackdropClick=!1:t.target===t.currentTarget&&(!0===this._config.backdrop?this.hide():"static"===this._config.backdrop&&this._triggerBackdropTransition())}),this._backdrop.show(t)}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(H.trigger(this._element,"hidePrevented.bs.modal").defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight;t||(this._element.style.overflowY="hidden"),this._element.classList.add("modal-static");const e=o(this._dialog);H.off(this._element,"transitionend"),H.one(this._element,"transitionend",()=>{this._element.classList.remove("modal-static"),t||(H.one(this._element,"transitionend",()=>{this._element.style.overflowY=""}),c(this._element,e))}),c(this._element,e),this._element.focus()}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=Ee(),i=e>0;(!i&&t&&!_()||i&&!t&&_())&&(this._element.style.paddingLeft=e+"px"),(i&&!t&&!_()||!i&&t&&_())&&(this._element.style.paddingRight=e+"px")}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Ie.getInstance(this)||new Ie(this,"object"==typeof t?t:{});if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}H.on(document,"click.bs.modal.data-api",'[data-bs-toggle="modal"]',(function(t){const e=s(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),H.one(e,"show.bs.modal",t=>{t.defaultPrevented||H.one(e,"hidden.bs.modal",()=>{h(this)&&this.focus()})}),(Ie.getInstance(e)||new Ie(e)).toggle(this)})),b(Ie);const je={backdrop:!0,keyboard:!0,scroll:!1},Pe={backdrop:"boolean",keyboard:"boolean",scroll:"boolean"};class Me extends R{constructor(t,e){super(t),this._config=this._getConfig(e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._addEventListeners()}static get NAME(){return"offcanvas"}static get Default(){return je}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||H.trigger(this._element,"show.bs.offcanvas",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._element.style.visibility="visible",this._backdrop.show(),this._config.scroll||(Te(),this._enforceFocusOnElement(this._element)),this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add("show"),this._queueCallback(()=>{H.trigger(this._element,"shown.bs.offcanvas",{relatedTarget:t})},this._element,!0))}hide(){this._isShown&&(H.trigger(this._element,"hide.bs.offcanvas").defaultPrevented||(H.off(document,"focusin.bs.offcanvas"),this._element.blur(),this._isShown=!1,this._element.classList.remove("show"),this._backdrop.hide(),this._queueCallback(()=>{this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._element.style.visibility="hidden",this._config.scroll||Oe(),H.trigger(this._element,"hidden.bs.offcanvas")},this._element,!0)))}dispose(){this._backdrop.dispose(),super.dispose(),H.off(document,"focusin.bs.offcanvas")}_getConfig(t){return t={...je,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},d("offcanvas",t,Pe),t}_initializeBackDrop(){return new De({isVisible:this._config.backdrop,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:()=>this.hide()})}_enforceFocusOnElement(t){H.off(document,"focusin.bs.offcanvas"),H.on(document,"focusin.bs.offcanvas",e=>{document===e.target||t===e.target||t.contains(e.target)||t.focus()}),t.focus()}_addEventListeners(){H.on(this._element,"click.dismiss.bs.offcanvas",'[data-bs-dismiss="offcanvas"]',()=>this.hide()),H.on(this._element,"keydown.dismiss.bs.offcanvas",t=>{this._config.keyboard&&"Escape"===t.key&&this.hide()})}static jQueryInterface(t){return this.each((function(){const e=w.get(this,"bs.offcanvas")||new Me(this,"object"==typeof t?t:{});if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}H.on(document,"click.bs.offcanvas.data-api",'[data-bs-toggle="offcanvas"]',(function(e){const i=s(this);if(["A","AREA"].includes(this.tagName)&&e.preventDefault(),u(this))return;H.one(i,"hidden.bs.offcanvas",()=>{h(this)&&this.focus()});const n=t.findOne(".offcanvas.show");n&&n!==i&&Me.getInstance(n).hide(),(w.get(i,"bs.offcanvas")||new Me(i)).toggle(this)})),H.on(window,"load.bs.offcanvas.data-api",()=>{t.find(".offcanvas.show").forEach(t=>(w.get(t,"bs.offcanvas")||new Me(t)).show())}),b(Me);const He=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Re=/^(?:(?:https?|mailto|ftp|tel|file):|[^#&/:?]*(?:[#/?]|$))/i,Be=/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i,We=(t,e)=>{const i=t.nodeName.toLowerCase();if(e.includes(i))return!He.has(i)||Boolean(Re.test(t.nodeValue)||Be.test(t.nodeValue));const n=e.filter(t=>t instanceof RegExp);for(let t=0,e=n.length;t{We(t,a)||i.removeAttribute(t.nodeName)})}return n.body.innerHTML}const ze=new RegExp("(^|\\s)bs-tooltip\\S+","g"),Ue=new Set(["sanitize","allowList","sanitizeFn"]),$e={animation:"boolean",template:"string",title:"(string|element|function)",trigger:"string",delay:"(number|object)",html:"boolean",selector:"(string|boolean)",placement:"(string|function)",offset:"(array|string|function)",container:"(string|element|boolean)",fallbackPlacements:"array",boundary:"(string|element)",customClass:"(string|function)",sanitize:"boolean",sanitizeFn:"(null|function)",allowList:"object",popperConfig:"(null|object|function)"},Fe={AUTO:"auto",TOP:"top",RIGHT:_()?"left":"right",BOTTOM:"bottom",LEFT:_()?"right":"left"},Ve={animation:!0,template:'',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:[0,0],container:!1,fallbackPlacements:["top","right","bottom","left"],boundary:"clippingParents",customClass:"",sanitize:!0,sanitizeFn:null,allowList:{"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},popperConfig:null},Ke={HIDE:"hide.bs.tooltip",HIDDEN:"hidden.bs.tooltip",SHOW:"show.bs.tooltip",SHOWN:"shown.bs.tooltip",INSERTED:"inserted.bs.tooltip",CLICK:"click.bs.tooltip",FOCUSIN:"focusin.bs.tooltip",FOCUSOUT:"focusout.bs.tooltip",MOUSEENTER:"mouseenter.bs.tooltip",MOUSELEAVE:"mouseleave.bs.tooltip"};class Xe extends R{constructor(t,e){if(void 0===he)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t),this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this._config=this._getConfig(e),this.tip=null,this._setListeners()}static get Default(){return Ve}static get NAME(){return"tooltip"}static get Event(){return Ke}static get DefaultType(){return $e}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(t){if(this._isEnabled)if(t){const e=this._initializeOnDelegatedTarget(t);e._activeTrigger.click=!e._activeTrigger.click,e._isWithActiveTrigger()?e._enter(null,e):e._leave(null,e)}else{if(this.getTipElement().classList.contains("show"))return void this._leave(null,this);this._enter(null,this)}}dispose(){clearTimeout(this._timeout),H.off(this._element.closest(".modal"),"hide.bs.modal",this._hideModalHandler),this.tip&&this.tip.parentNode&&this.tip.parentNode.removeChild(this.tip),this._popper&&this._popper.destroy(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this.isWithContent()||!this._isEnabled)return;const t=H.trigger(this._element,this.constructor.Event.SHOW),i=f(this._element),n=null===i?this._element.ownerDocument.documentElement.contains(this._element):i.contains(this._element);if(t.defaultPrevented||!n)return;const s=this.getTipElement(),o=e(this.constructor.NAME);s.setAttribute("id",o),this._element.setAttribute("aria-describedby",o),this.setContent(),this._config.animation&&s.classList.add("fade");const r="function"==typeof this._config.placement?this._config.placement.call(this,s,this._element):this._config.placement,a=this._getAttachment(r);this._addAttachmentClass(a);const{container:l}=this._config;w.set(s,this.constructor.DATA_KEY,this),this._element.ownerDocument.documentElement.contains(this.tip)||(l.appendChild(s),H.trigger(this._element,this.constructor.Event.INSERTED)),this._popper?this._popper.update():this._popper=de(this._element,s,this._getPopperConfig(a)),s.classList.add("show");const c="function"==typeof this._config.customClass?this._config.customClass():this._config.customClass;c&&s.classList.add(...c.split(" ")),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach(t=>{H.on(t,"mouseover",p)});const d=this.tip.classList.contains("fade");this._queueCallback(()=>{const t=this._hoverState;this._hoverState=null,H.trigger(this._element,this.constructor.Event.SHOWN),"out"===t&&this._leave(null,this)},this.tip,d)}hide(){if(!this._popper)return;const t=this.getTipElement();if(H.trigger(this._element,this.constructor.Event.HIDE).defaultPrevented)return;t.classList.remove("show"),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach(t=>H.off(t,"mouseover",p)),this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1;const e=this.tip.classList.contains("fade");this._queueCallback(()=>{this._isWithActiveTrigger()||("show"!==this._hoverState&&t.parentNode&&t.parentNode.removeChild(t),this._cleanTipClass(),this._element.removeAttribute("aria-describedby"),H.trigger(this._element,this.constructor.Event.HIDDEN),this._popper&&(this._popper.destroy(),this._popper=null))},this.tip,e),this._hoverState=""}update(){null!==this._popper&&this._popper.update()}isWithContent(){return Boolean(this.getTitle())}getTipElement(){if(this.tip)return this.tip;const t=document.createElement("div");return t.innerHTML=this._config.template,this.tip=t.children[0],this.tip}setContent(){const e=this.getTipElement();this.setElementContent(t.findOne(".tooltip-inner",e),this.getTitle()),e.classList.remove("fade","show")}setElementContent(t,e){if(null!==t)return a(e)?(e=l(e),void(this._config.html?e.parentNode!==t&&(t.innerHTML="",t.appendChild(e)):t.textContent=e.textContent)):void(this._config.html?(this._config.sanitize&&(e=qe(e,this._config.allowList,this._config.sanitizeFn)),t.innerHTML=e):t.textContent=e)}getTitle(){let t=this._element.getAttribute("data-bs-original-title");return t||(t="function"==typeof this._config.title?this._config.title.call(this._element):this._config.title),t}updateAttachment(t){return"right"===t?"end":"left"===t?"start":t}_initializeOnDelegatedTarget(t,e){const i=this.constructor.DATA_KEY;return(e=e||w.get(t.delegateTarget,i))||(e=new this.constructor(t.delegateTarget,this._getDelegateConfig()),w.set(t.delegateTarget,i,e)),e}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map(t=>Number.parseInt(t,10)):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"onChange",enabled:!0,phase:"afterWrite",fn:t=>this._handlePopperPlacementChange(t)}],onFirstUpdate:t=>{t.options.placement!==t.placement&&this._handlePopperPlacementChange(t)}};return{...e,..."function"==typeof this._config.popperConfig?this._config.popperConfig(e):this._config.popperConfig}}_addAttachmentClass(t){this.getTipElement().classList.add("bs-tooltip-"+this.updateAttachment(t))}_getAttachment(t){return Fe[t.toUpperCase()]}_setListeners(){this._config.trigger.split(" ").forEach(t=>{if("click"===t)H.on(this._element,this.constructor.Event.CLICK,this._config.selector,t=>this.toggle(t));else if("manual"!==t){const e="hover"===t?this.constructor.Event.MOUSEENTER:this.constructor.Event.FOCUSIN,i="hover"===t?this.constructor.Event.MOUSELEAVE:this.constructor.Event.FOCUSOUT;H.on(this._element,e,this._config.selector,t=>this._enter(t)),H.on(this._element,i,this._config.selector,t=>this._leave(t))}}),this._hideModalHandler=()=>{this._element&&this.hide()},H.on(this._element.closest(".modal"),"hide.bs.modal",this._hideModalHandler),this._config.selector?this._config={...this._config,trigger:"manual",selector:""}:this._fixTitle()}_fixTitle(){const t=this._element.getAttribute("title"),e=typeof this._element.getAttribute("data-bs-original-title");(t||"string"!==e)&&(this._element.setAttribute("data-bs-original-title",t||""),!t||this._element.getAttribute("aria-label")||this._element.textContent||this._element.setAttribute("aria-label",t),this._element.setAttribute("title",""))}_enter(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusin"===t.type?"focus":"hover"]=!0),e.getTipElement().classList.contains("show")||"show"===e._hoverState?e._hoverState="show":(clearTimeout(e._timeout),e._hoverState="show",e._config.delay&&e._config.delay.show?e._timeout=setTimeout(()=>{"show"===e._hoverState&&e.show()},e._config.delay.show):e.show())}_leave(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusout"===t.type?"focus":"hover"]=e._element.contains(t.relatedTarget)),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState="out",e._config.delay&&e._config.delay.hide?e._timeout=setTimeout(()=>{"out"===e._hoverState&&e.hide()},e._config.delay.hide):e.hide())}_isWithActiveTrigger(){for(const t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1}_getConfig(t){const e=U.getDataAttributes(this._element);return Object.keys(e).forEach(t=>{Ue.has(t)&&delete e[t]}),(t={...this.constructor.Default,...e,..."object"==typeof t&&t?t:{}}).container=!1===t.container?document.body:l(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),d("tooltip",t,this.constructor.DefaultType),t.sanitize&&(t.template=qe(t.template,t.allowList,t.sanitizeFn)),t}_getDelegateConfig(){const t={};if(this._config)for(const e in this._config)this.constructor.Default[e]!==this._config[e]&&(t[e]=this._config[e]);return t}_cleanTipClass(){const t=this.getTipElement(),e=t.getAttribute("class").match(ze);null!==e&&e.length>0&&e.map(t=>t.trim()).forEach(e=>t.classList.remove(e))}_handlePopperPlacementChange(t){const{state:e}=t;e&&(this.tip=e.elements.popper,this._cleanTipClass(),this._addAttachmentClass(this._getAttachment(e.placement)))}static jQueryInterface(t){return this.each((function(){let e=w.get(this,"bs.tooltip");const i="object"==typeof t&&t;if((e||!/dispose|hide/.test(t))&&(e||(e=new Xe(this,i)),"string"==typeof t)){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}b(Xe);const Ye=new RegExp("(^|\\s)bs-popover\\S+","g"),Qe={...Xe.Default,placement:"right",offset:[0,8],trigger:"click",content:"",template:''},Ge={...Xe.DefaultType,content:"(string|element|function)"},Ze={HIDE:"hide.bs.popover",HIDDEN:"hidden.bs.popover",SHOW:"show.bs.popover",SHOWN:"shown.bs.popover",INSERTED:"inserted.bs.popover",CLICK:"click.bs.popover",FOCUSIN:"focusin.bs.popover",FOCUSOUT:"focusout.bs.popover",MOUSEENTER:"mouseenter.bs.popover",MOUSELEAVE:"mouseleave.bs.popover"};class Je extends Xe{static get Default(){return Qe}static get NAME(){return"popover"}static get Event(){return Ze}static get DefaultType(){return Ge}isWithContent(){return this.getTitle()||this._getContent()}setContent(){const e=this.getTipElement();this.setElementContent(t.findOne(".popover-header",e),this.getTitle());let i=this._getContent();"function"==typeof i&&(i=i.call(this._element)),this.setElementContent(t.findOne(".popover-body",e),i),e.classList.remove("fade","show")}_addAttachmentClass(t){this.getTipElement().classList.add("bs-popover-"+this.updateAttachment(t))}_getContent(){return this._element.getAttribute("data-bs-content")||this._config.content}_cleanTipClass(){const t=this.getTipElement(),e=t.getAttribute("class").match(Ye);null!==e&&e.length>0&&e.map(t=>t.trim()).forEach(e=>t.classList.remove(e))}static jQueryInterface(t){return this.each((function(){let e=w.get(this,"bs.popover");const i="object"==typeof t?t:null;if((e||!/dispose|hide/.test(t))&&(e||(e=new Je(this,i),w.set(this,"bs.popover",e)),"string"==typeof t)){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}b(Je);const ti={offset:10,method:"auto",target:""},ei={offset:"number",method:"string",target:"(string|element)"};class ii extends R{constructor(t,e){super(t),this._scrollElement="BODY"===this._element.tagName?window:this._element,this._config=this._getConfig(e),this._selector=`${this._config.target} .nav-link, ${this._config.target} .list-group-item, ${this._config.target} .dropdown-item`,this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,H.on(this._scrollElement,"scroll.bs.scrollspy",()=>this._process()),this.refresh(),this._process()}static get Default(){return ti}static get NAME(){return"scrollspy"}refresh(){const e=this._scrollElement===this._scrollElement.window?"offset":"position",i="auto"===this._config.method?e:this._config.method,s="position"===i?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),t.find(this._selector).map(e=>{const o=n(e),r=o?t.findOne(o):null;if(r){const t=r.getBoundingClientRect();if(t.width||t.height)return[U[i](r).top+s,o]}return null}).filter(t=>t).sort((t,e)=>t[0]-e[0]).forEach(t=>{this._offsets.push(t[0]),this._targets.push(t[1])})}dispose(){H.off(this._scrollElement,".bs.scrollspy"),super.dispose()}_getConfig(t){if("string"!=typeof(t={...ti,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}}).target&&a(t.target)){let{id:i}=t.target;i||(i=e("scrollspy"),t.target.id=i),t.target="#"+i}return d("scrollspy",t,ei),t}_getScrollTop(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop}_getScrollHeight(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)}_getOffsetHeight(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height}_process(){const t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),i=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=i){const t=this._targets[this._targets.length-1];this._activeTarget!==t&&this._activate(t)}else{if(this._activeTarget&&t0)return this._activeTarget=null,void this._clear();for(let e=this._offsets.length;e--;)this._activeTarget!==this._targets[e]&&t>=this._offsets[e]&&(void 0===this._offsets[e+1]||t`${t}[data-bs-target="${e}"],${t}[href="${e}"]`),n=t.findOne(i.join(","));n.classList.contains("dropdown-item")?(t.findOne(".dropdown-toggle",n.closest(".dropdown")).classList.add("active"),n.classList.add("active")):(n.classList.add("active"),t.parents(n,".nav, .list-group").forEach(e=>{t.prev(e,".nav-link, .list-group-item").forEach(t=>t.classList.add("active")),t.prev(e,".nav-item").forEach(e=>{t.children(e,".nav-link").forEach(t=>t.classList.add("active"))})})),H.trigger(this._scrollElement,"activate.bs.scrollspy",{relatedTarget:e})}_clear(){t.find(this._selector).filter(t=>t.classList.contains("active")).forEach(t=>t.classList.remove("active"))}static jQueryInterface(t){return this.each((function(){const e=ii.getInstance(this)||new ii(this,"object"==typeof t?t:{});if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}H.on(window,"load.bs.scrollspy.data-api",()=>{t.find('[data-bs-spy="scroll"]').forEach(t=>new ii(t))}),b(ii);class ni extends R{static get NAME(){return"tab"}show(){if(this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE&&this._element.classList.contains("active"))return;let e;const i=s(this._element),n=this._element.closest(".nav, .list-group");if(n){const i="UL"===n.nodeName||"OL"===n.nodeName?":scope > li > .active":".active";e=t.find(i,n),e=e[e.length-1]}const o=e?H.trigger(e,"hide.bs.tab",{relatedTarget:this._element}):null;if(H.trigger(this._element,"show.bs.tab",{relatedTarget:e}).defaultPrevented||null!==o&&o.defaultPrevented)return;this._activate(this._element,n);const r=()=>{H.trigger(e,"hidden.bs.tab",{relatedTarget:this._element}),H.trigger(this._element,"shown.bs.tab",{relatedTarget:e})};i?this._activate(i,i.parentNode,r):r()}_activate(e,i,n){const s=(!i||"UL"!==i.nodeName&&"OL"!==i.nodeName?t.children(i,".active"):t.find(":scope > li > .active",i))[0],o=n&&s&&s.classList.contains("fade"),r=()=>this._transitionComplete(e,s,n);s&&o?(s.classList.remove("show"),this._queueCallback(r,e,!0)):r()}_transitionComplete(e,i,n){if(i){i.classList.remove("active");const e=t.findOne(":scope > .dropdown-menu .active",i.parentNode);e&&e.classList.remove("active"),"tab"===i.getAttribute("role")&&i.setAttribute("aria-selected",!1)}e.classList.add("active"),"tab"===e.getAttribute("role")&&e.setAttribute("aria-selected",!0),m(e),e.classList.contains("fade")&&e.classList.add("show");let s=e.parentNode;if(s&&"LI"===s.nodeName&&(s=s.parentNode),s&&s.classList.contains("dropdown-menu")){const i=e.closest(".dropdown");i&&t.find(".dropdown-toggle",i).forEach(t=>t.classList.add("active")),e.setAttribute("aria-expanded",!0)}n&&n()}static jQueryInterface(t){return this.each((function(){const e=w.get(this,"bs.tab")||new ni(this);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}H.on(document,"click.bs.tab.data-api",'[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),u(this)||(w.get(this,"bs.tab")||new ni(this)).show()})),b(ni);const si={animation:"boolean",autohide:"boolean",delay:"number"},oi={animation:!0,autohide:!0,delay:5e3};class ri extends R{constructor(t,e){super(t),this._config=this._getConfig(e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get DefaultType(){return si}static get Default(){return oi}static get NAME(){return"toast"}show(){H.trigger(this._element,"show.bs.toast").defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove("hide"),m(this._element),this._element.classList.add("showing"),this._queueCallback(()=>{this._element.classList.remove("showing"),this._element.classList.add("show"),H.trigger(this._element,"shown.bs.toast"),this._maybeScheduleHide()},this._element,this._config.animation))}hide(){this._element.classList.contains("show")&&(H.trigger(this._element,"hide.bs.toast").defaultPrevented||(this._element.classList.remove("show"),this._queueCallback(()=>{this._element.classList.add("hide"),H.trigger(this._element,"hidden.bs.toast")},this._element,this._config.animation)))}dispose(){this._clearTimeout(),this._element.classList.contains("show")&&this._element.classList.remove("show"),super.dispose()}_getConfig(t){return t={...oi,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}},d("toast",t,this.constructor.DefaultType),t}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout(()=>{this.hide()},this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){H.on(this._element,"click.dismiss.bs.toast",'[data-bs-dismiss="toast"]',()=>this.hide()),H.on(this._element,"mouseover.bs.toast",t=>this._onInteraction(t,!0)),H.on(this._element,"mouseout.bs.toast",t=>this._onInteraction(t,!1)),H.on(this._element,"focusin.bs.toast",t=>this._onInteraction(t,!0)),H.on(this._element,"focusout.bs.toast",t=>this._onInteraction(t,!1))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){let e=w.get(this,"bs.toast");if(e||(e=new ri(this,"object"==typeof t&&t)),"string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return b(ri),{Alert:B,Button:W,Carousel:Q,Collapse:J,Dropdown:we,Modal:Ie,Offcanvas:Me,Popover:Je,ScrollSpy:ii,Tab:ni,Toast:ri,Tooltip:Xe}})); 7 | //# sourceMappingURL=bootstrap.bundle.min.js.map -------------------------------------------------------------------------------- /public/scripts/main.js: -------------------------------------------------------------------------------- 1 | /* global $:false, moment:false, confirm:false, location:false */ 2 | 3 | $(document).ready(function () { 4 | $('ul.navbar-nav li a').each(function (i, elem) { 5 | if ($(elem).attr('href') === window.location.pathname) { 6 | $(elem).addClass('active') 7 | } 8 | }) 9 | 10 | $('.expand-log').click(function () { 11 | if ($(this).hasClass('bi-plus-circle')) { 12 | $(this).removeClass('bi-plus-circle').addClass('bi-dash-circle') 13 | } else { 14 | $(this).removeClass('bi-dash-circle').addClass('bi-plus-circle') 15 | } 16 | }) 17 | 18 | // Safest way to know we're showing the right timezone 19 | if ($('.current-date').length) { 20 | setTimeout(function () { 21 | setInterval(function () { 22 | $('.current-date').html(moment().format('h:mma')) 23 | }, 60 * 1000) 24 | $('.current-date').html(moment().format('h:mma')) 25 | }, (60 - (new Date()).getSeconds()) * 1000) 26 | $('.current-date').html(moment().format('h:mma')) 27 | } 28 | 29 | if ($('.time-picker').length) { 30 | $('.time-picker').flatpickr({ enableTime: true, noCalendar: true, dateFormat: 'h:i K' }) 31 | } 32 | 33 | $('.form-select[name="state"]').change(function () { 34 | if ($(this).val() === 'off') { 35 | $(this).parents('form').find('.color-btn').hide() 36 | $(this).parents('form').find('.time-picker[name="end_time"]').hide() 37 | } else { 38 | $(this).parents('form').find('.color-btn').show() 39 | $(this).parents('form').find('.time-picker[name="end_time"]').show() 40 | } 41 | }) 42 | $('.form-select[name="state"]').each(function (i, elem) { 43 | $(elem).change() 44 | }) 45 | 46 | const lastChange = new Date() 47 | setInterval(function () { 48 | $.get('/last-update', function (data) { 49 | if (new Date(data) > lastChange) { 50 | $('#suggestRefresh').show() 51 | } 52 | }) 53 | }, 60 * 1000) 54 | $('#suggestRefresh').hide() 55 | 56 | $('span.timer-countdown').each(function (i, span) { 57 | $(span).html(moment($(span).data('time')).fromNow()) 58 | }) 59 | 60 | $('button.btn-close').on('click', function () { 61 | $(this).parents('p').hide() 62 | }) 63 | 64 | $('.delete-schedule-btn').on('click', function () { 65 | if (confirm('Are you sure?')) { 66 | $.post('/', { cmd: 'delete-color-schedule', id: $(this).data('schedule'), light: $(this).data('light') }) 67 | .done(function (data) { 68 | location.reload() 69 | }) 70 | } 71 | }) 72 | 73 | $('#configure-hue').on('submit', function (e) { 74 | if (!confirm('Did you already click the physical button on your Hue Bridge?')) { 75 | e.preventDefault() 76 | } 77 | }) 78 | 79 | $('.light-experiment').on('submit', function (e) { 80 | e.preventDefault() 81 | const data = { 82 | cmd: 'experiment' 83 | } 84 | $.each($(this).serializeArray(), function (i, field) { 85 | data[field.name] = field.value 86 | }) 87 | $.ajax({ 88 | type: 'POST', 89 | url: '/', 90 | data: data 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const pkg = require('./package') 3 | const express = require('express') 4 | const session = require('express-session') 5 | const flash = require('connect-flash') 6 | const nunjucks = require('nunjucks') 7 | const path = require('path') 8 | 9 | const db = require('./lib/db') 10 | 11 | process.on('uncaughtException', function (err) { 12 | if (process.env.NODE_ENV === 'production') { 13 | console.log(err) 14 | } else { 15 | throw err 16 | } 17 | }) 18 | 19 | const logger = require('./lib/logger') 20 | const lightWatcher = require('./lib/light-watcher') 21 | 22 | const app = express() 23 | const port = process.env.PORT || 3000 24 | 25 | app.set('views', path.resolve(__dirname, 'views')) 26 | nunjucks.configure('views', { 27 | autoescape: true, 28 | express: app, 29 | noCache: (process.env.NODE_ENV !== 'production') 30 | }) 31 | app.set('view engine', 'html') 32 | 33 | app.use(session({ 34 | secret: process.env.SESSION_SECRET || 'secret', 35 | cookie: { maxAge: 60000, sameSite: true }, 36 | resave: true, 37 | saveUninitialized: true 38 | })) 39 | app.use(flash()) 40 | 41 | app.use('/scripts/flatpickr/', express.static('node_modules/flatpickr/dist')) 42 | app.use('/scripts/moment/', express.static('node_modules/moment')) 43 | app.use('/css/icons/', express.static('node_modules/bootstrap-icons/font')) 44 | app.use(express.static('public')) 45 | 46 | app.use(function (req, res, next) { 47 | const render = res.render 48 | req.db = db 49 | req.log = function (type, message, meta) { 50 | if (typeof meta !== 'object') { 51 | meta = {} 52 | } 53 | meta.method = req.method 54 | meta.url = req.originalUrl 55 | meta.ip = req.ip 56 | if (typeof logger[type] !== 'undefined') { 57 | logger[type](message, meta) 58 | } else { 59 | logger.error('Failed attempt to invoke logger with type "' + type + '"') 60 | logger.info(message, meta) 61 | } 62 | } 63 | 64 | res.locals.site_name = process.env.SITE_NAME || 'Nightlight System' 65 | res.render = function (view, locals, cb) { 66 | res.locals.success_messages = req.flash('success') 67 | res.locals.error_messages = req.flash('error') 68 | if (typeof locals === 'object') locals.user = req.user 69 | render.call(res, view, locals, cb) 70 | } 71 | next() 72 | }) 73 | 74 | app.use(express.json()) 75 | app.use(express.urlencoded({ extended: true })) 76 | 77 | app.use(require('morgan')('combined', { stream: logger.stream })) 78 | app.use(require('./controllers')) 79 | 80 | app.use(function (err, req, res, next) { 81 | // jshint unused:false 82 | console.error(err.stack) 83 | res.status(500).send('Something broke!') 84 | }) 85 | 86 | app.use(function (req, res, next) { 87 | // jshint unused:false 88 | res.status(404).send('Sorry cant find that!') 89 | }) 90 | 91 | db.event.on('loaded', function () { 92 | app.listen(port, function () { 93 | console.log(`Started ${pkg.name}. Listening on port ${port}`) 94 | }) 95 | 96 | lightWatcher.init() 97 | }) 98 | -------------------------------------------------------------------------------- /utils/brightChecker.js: -------------------------------------------------------------------------------- 1 | /* eslint new-cap: ["error", { "newIsCap": false }] */ 2 | const hue = require('node-hue-api').v3 3 | 4 | const hex2rgb = require('../lib/hex2rgb') 5 | const db = require('../lib/db') 6 | 7 | if (process.argv.length !== 3) { 8 | console.warn('This script must be run as `node brightChecker.js ID`, where ID is the number Hue has for light') 9 | process.exit(1) 10 | } 11 | 12 | db.event.on('loaded', function () { 13 | const bridgeInfo = db.settings.findOne({ type: 'hue' }) 14 | 15 | if (!bridgeInfo) { 16 | console.warn('Did you forget to run Hue settings setup?') 17 | process.exit(1) 18 | } 19 | 20 | const LightState = hue.lightStates.LightState 21 | hue.api.createLocal(bridgeInfo.ip).connect(bridgeInfo.username).then(api => { 22 | let brightness = 0 23 | console.log('About to cycle through brightness, from 0% to 100%.') 24 | setInterval(async function () { 25 | const state = new LightState().on().brightness(brightness) 26 | console.log('Brightness at ' + brightness) 27 | if (brightness === 100) process.exit() 28 | brightness += 10 29 | state.rgb(hex2rgb('FFFFFF')) 30 | try { 31 | await api.lights.setLightState(process.argv[2], state) 32 | } catch (e) { 33 | console.warn(e) 34 | process.exit(1) 35 | } 36 | }, 5000) 37 | }).catch(err => { 38 | console.warn(err) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /views/includes/flash.html: -------------------------------------------------------------------------------- 1 | {% if success_messages %} 2 | {% for message in success_messages %} 3 |
4 |
5 |

6 | 7 | {{ message }} 8 |

9 |
10 |
11 | {% endfor %} 12 | {% endif %} 13 | 14 | {% if error_messages %} 15 | {% for message in error_messages %} 16 |
17 |
18 |

19 | 20 | {{ message }} 21 |

22 |
23 |
24 | {% endfor %} 25 | {% endif %} -------------------------------------------------------------------------------- /views/includes/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ site_name }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /views/includes/nav.html: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
Current Time:
7 |
8 |
9 | 10 |
11 | {% for lightId, light in lights %} 12 |
13 |

14 | 15 |
16 | {% if light.result.on %} 17 |
18 |
19 | 20 | 21 | 22 |
23 |
24 | {% else %} 25 |
26 | 27 | 28 | 29 |
30 |
31 | 32 | 41 |
42 | {% endif %} 43 |
44 |

45 | {{ light.name }} 46 | The light is {% if light.result.on %}ON{% else %}OFF{% endif %}. 47 |

48 | {% if light.timerTime %} 49 |

Timer is active. Turning off .

50 | {% endif %} 51 | 52 |
53 |
54 | 55 |
Logs
56 |
57 |
58 |
59 | {% if light.logs|length %} 60 |
    61 | {% for log in light.logs %} 62 |
  • {{ log }}
  • 63 | {% endfor %} 64 |
65 | {% else %} 66 | No log activity. 67 | {% endif %} 68 |
69 |
70 |
71 | 72 |
73 |
74 |
Settings
75 |
76 | 77 | 78 |
79 |
80 | 81 |
82 | 83 |
84 |
85 |
86 | 87 |
88 | 94 | 95 |
96 | 97 |
98 | 105 |
106 | 107 | {% if light.type == "fastled" %} 108 |
109 |
110 | 111 |
112 | 116 |
117 |
118 |
119 |
120 |
121 | 122 |
123 | 127 |
128 |
129 |
130 | {% endif %} 131 | 132 |
133 | 134 |
135 |
136 |
137 |
138 | 139 |
140 |
141 |

Color Schedule

142 | {% set count = 0 %} 143 | {% for scheduleId, schedule in light.colorSchedule %} 144 | {% set count = count + 1 %} 145 |
146 |
147 |
148 |
149 | 150 |
151 |
152 | 153 |
154 |
155 | 161 | 162 |
163 |
164 | 169 |
170 |
171 | 178 |
179 | {% if light.type == "fastled" %} 180 |
181 | 185 |
186 |
187 | 191 |
192 | {% endif %} 193 |
194 | 195 | 196 | 197 | 198 |
199 |
200 | 203 |
204 |
205 |
206 |
207 | 208 | {% endfor %} 209 | 210 |

Set New Color Schedule

211 |
212 |
213 | 214 |
215 |
216 | 217 |
218 |
219 | 225 | 226 |
227 |
228 | 233 |
234 |
235 | 242 |
243 | {% if light.type == "fastled" %} 244 |
245 | 249 |
250 |
251 | 255 |
256 | {% endif %} 257 |
258 | 259 | 260 | 261 |
262 |
263 |
264 |
265 |
266 | 267 | {% else %} 268 |
269 |
270 | No lights found… 271 |
272 |
273 | {% endfor %} 274 |
275 | 276 | {% if offlineLights|length > 0 %} 277 |
278 |
279 |

Offline Lights

280 |
281 | {% for offlineLight in offlineLights %} 282 |
283 | 286 |
287 | {% endfor %} 288 |
289 | 290 | 291 |
292 |
293 |
294 |
295 | {% endif %} 296 | 297 |
298 |
299 |

Experiment With Hue

300 |
301 |
302 | 310 |
311 |
312 | 318 | 319 |
320 |
321 | 328 |
329 |
330 | 331 | 332 | 333 | 334 |
335 |
336 |
337 | 338 |
339 |

Experiment With FastLED

340 |
341 |
342 | 350 |
351 |
352 | 356 |
357 |
358 | 362 |
363 | 364 |
365 | 371 | 372 |
373 |
374 | 381 |
382 |
383 | 384 | 385 | 386 | 387 |
388 |
389 |
390 |
391 | 392 | Please Refresh Page 393 | {% endblock %} 394 | -------------------------------------------------------------------------------- /views/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% include "includes/header.html" %} 5 | 6 | 7 | {% block nav %} 8 | {% include "includes/nav.html" %} 9 | {% endblock %} 10 |
11 | {% block flash %} 12 | {% include "includes/flash.html" %} 13 | {% endblock %} 14 | {% block content %}{% endblock %} 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /views/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block content %} 4 |
5 | {% if hueNotConfigured %} 6 |
7 |

Configure Hue Bridge

8 |

Please first push the button on your Hue Bridge. The click "Proceed".

9 |
10 |
11 | 12 | 13 |
14 |
15 |
16 | {% else %} 17 |
18 |

Known, But Unassociated, Hue Lights

19 |
20 | {% set displayedLight = false %} 21 | {% for light in hueLights %} 22 | {% set displayedLight = true %} 23 |
24 | 27 |
28 | {% endfor %} 29 | {% if displayedLight %} 30 |
31 | 32 | 33 |
34 | {% else %} 35 |

All known lights are already associated.

36 | {% endif %} 37 |
38 |
39 | {% endif %} 40 | 41 |
42 |

Add Non-Hue Light

43 |

Currently supports:

44 | 47 |
48 |
49 |
50 | 54 |
55 |
56 | 57 |
58 |
59 | 60 | 61 |
62 |
63 |
64 |

Sample Configs:

65 |

FastLED

66 |

67 | {"ip": "192.168.1.100", "name": "Feather Light"} 68 |

69 |
70 |
71 |
72 | 73 |
74 |

Re-Sync

75 |

Lights not seeming to be running on schedule? Click re-sync and tell Eric this happened.

76 |
77 |
78 | 79 | 80 |
81 |
82 |
83 |
84 | 85 | {% endblock %} --------------------------------------------------------------------------------