├── manifest.json ├── update.sh ├── public ├── OneSignalSDKWorker.js ├── OneSignalSDKUpdaterWorker.js ├── manifest.json ├── camera.js ├── style.css ├── index.js ├── vaccum.js ├── ligths.js ├── index.html ├── color-picker.css └── color-picker.js ├── index.js ├── devEnv.env ├── services ├── proxy │ ├── config_base.json │ ├── dnsmasq.conf │ └── docker-compose.yml └── motion │ └── motion.conf ├── src ├── routes │ └── v1 │ │ ├── motion │ │ ├── routes.js │ │ └── controller.js │ │ ├── vacuum │ │ ├── validators.js │ │ ├── routes.js │ │ └── controller.js │ │ ├── routes.js │ │ └── light │ │ ├── routes.js │ │ └── controller.js ├── helpers │ ├── validation.js │ └── onesignal.js ├── modules │ ├── duckDns.js │ └── miio-connect.js ├── homewads-api.js └── config │ └── server.js ├── .eslintrc.js ├── docs ├── examples │ ├── hassio-config-configuration.yaml │ └── etc-motion-config.d └── imgs │ └── Google-Assistant-Data-Flow.svg ├── todoing.md ├── .gitignore ├── upserver.sh ├── package.json ├── README.md └── LICENSE /manifest.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | git pull 2 | pm2 reload all -------------------------------------------------------------------------------- /public/OneSignalSDKWorker.js: -------------------------------------------------------------------------------- 1 | importScripts('https://cdn.onesignal.com/sdks/OneSignalSDKWorker.js'); 2 | -------------------------------------------------------------------------------- /public/OneSignalSDKUpdaterWorker.js: -------------------------------------------------------------------------------- 1 | importScripts('https://cdn.onesignal.com/sdks/OneSignalSDKWorker.js'); 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require = require('esm')(module) 2 | require('module-alias/register') 3 | module.exports = require('./src/homewads-api') -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "gcm_sender_id": "482941778795", 3 | "gcm_sender_id_comment": "Do not change the GCM Sender ID" 4 | } -------------------------------------------------------------------------------- /devEnv.env: -------------------------------------------------------------------------------- 1 | export ONE_SIGNAL_USER_KEY="fakekey" 2 | export ONE_SIGNAL_APP_KEY="fakekey" 3 | export BASE_DOMAIN="localhost" 4 | export DYNAMIC_DNS_DOMAIN="fakedomain" 5 | export DYNAMIC_DNS_TOKEN="faketoken" 6 | -------------------------------------------------------------------------------- /services/proxy/config_base.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "engine": "mysql", 4 | "host": "db", 5 | "name": "npm", 6 | "user": "root", 7 | "password": "npm", 8 | "port": 3306 9 | } 10 | } -------------------------------------------------------------------------------- /src/routes/v1/motion/routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import controller from './controller' 3 | 4 | const router = express.Router() 5 | 6 | router.post('/detect', controller.detect) 7 | router.post('/movie', controller.movie) 8 | 9 | export default router 10 | -------------------------------------------------------------------------------- /public/camera.js: -------------------------------------------------------------------------------- 1 | let boxCamera; 2 | { 3 | boxCamera = document.getElementById('video') 4 | } 5 | 6 | async function toggleCamera() { 7 | if (boxCamera.src == '') 8 | boxCamera.src = 'https://modem.' + base_domain + port 9 | else 10 | boxCamera.src = '' 11 | } 12 | -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | @import url(color-picker.css); 2 | 3 | html { 4 | background-color: black; 5 | } 6 | 7 | h1, p, span, pre { 8 | color: white; 9 | } 10 | 11 | img { 12 | max-width: 100%; 13 | } 14 | 15 | progress { 16 | background-color: grey; 17 | border-radius: 4px; 18 | height: 6px; 19 | } -------------------------------------------------------------------------------- /src/routes/v1/vacuum/validators.js: -------------------------------------------------------------------------------- 1 | import handleValidation from '@helpers/validation' 2 | 3 | export default { 4 | checkFields: (req, res, next) => { 5 | req.checkBody('repeats', { error: 'required' }).notEmpty() 6 | req.checkBody('zone', { error: 'required' }).notEmpty() 7 | handleValidation(req, res, next) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'es6': true, 4 | 'node': true 5 | }, 6 | 'extends': [ 7 | 'standard' 8 | ], 9 | 'globals': { 10 | 'Atomics': 'readonly', 11 | 'SharedArrayBuffer': 'readonly' 12 | }, 13 | 'parserOptions': { 14 | 'ecmaVersion': 2018, 15 | 'sourceType': 'module' 16 | }, 17 | 'rules': { 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/helpers/validation.js: -------------------------------------------------------------------------------- 1 | const handleValidation = (req, res, next) => { 2 | return req.getValidationResult() 3 | .then((result) => { 4 | if (!result.isEmpty()) { 5 | let errors = result.mapped() 6 | res.status(422).json(errors) 7 | return false 8 | } else { 9 | next() 10 | } 11 | }) 12 | .catch((error) => { 13 | throw error 14 | }) 15 | } 16 | 17 | export default handleValidation 18 | -------------------------------------------------------------------------------- /src/modules/duckDns.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | 3 | const DNS_URL = 'https://www.duckdns.org/update' 4 | const { DYNAMIC_DNS_DOMAIN, DYNAMIC_DNS_TOKEN } = process.env 5 | 6 | function updateDns () { 7 | fetch(DNS_URL + `?domains=${DYNAMIC_DNS_DOMAIN}&token=${DYNAMIC_DNS_TOKEN}&ip=`) 8 | .then(async res => console.log('DNS Update', await res.text())) 9 | .catch(err => console.log(err.data)) 10 | } 11 | 12 | let updatePid = 0 13 | export default function () { 14 | clearInterval(updatePid) 15 | updatePid = setInterval(updateDns, 600000) 16 | } 17 | -------------------------------------------------------------------------------- /src/routes/v1/routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import vacuum from './vacuum/routes' 3 | import motion from './motion/routes' 4 | import light from './light/routes' 5 | 6 | const router = express.Router() 7 | /** 8 | * Use the modules routes. It's safer doing in a separate file than magically, to 9 | * be sure nester routes will be applied correctly. 10 | */ 11 | router.get('/', (req, res) => { 12 | res.status(200).json(req.headers) 13 | }) 14 | 15 | router.use('/vacuum', vacuum) 16 | router.use('/motion', motion) 17 | router.use('/light', light) 18 | 19 | // Return router 20 | export default router 21 | -------------------------------------------------------------------------------- /src/routes/v1/light/routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import controller from './controller' 3 | 4 | const router = express.Router() 5 | 6 | router.get('/status/:name*?', controller.status) 7 | router.post('/brightness/:name*?', controller.brightness) 8 | router.post('/temperature/:name*?', controller.temperature) 9 | router.post('/color/:name*?', controller.color) 10 | router.post('/on/:name*?', controller.generic('changePower', 'turned on', [true])) 11 | router.post('/off/:name*?', controller.generic('changePower', 'turned off', [false])) 12 | router.post('/toggle/:name*?', controller.toggle) 13 | 14 | export default router 15 | -------------------------------------------------------------------------------- /services/proxy/dnsmasq.conf: -------------------------------------------------------------------------------- 1 | #log all dns queries 2 | log-queries 3 | #dont use hosts nameservers 4 | no-resolv 5 | #use google as default nameservers 6 | server=192.168.15.1 7 | server=8.8.8.8 8 | server=8.8.4.4 9 | #explicitly define host-ip mappings 10 | address=/router.local/10.0.0.1 11 | address=/homeauto.local/10.0.0.2 12 | address=/home.victorwads.com.br/192.168.15.2 13 | address=/proxy.home.victorwads.com.br/192.168.15.2 14 | address=/api.home.victorwads.com.br/192.168.15.2 15 | address=/modem.home.victorwads.com.br/192.168.15.2 16 | address=/camera.home.victorwads.com.br/192.168.15.2 17 | address=/dns.home.victorwads.com.br/192.168.15.2 18 | -------------------------------------------------------------------------------- /docs/examples/hassio-config-configuration.yaml: -------------------------------------------------------------------------------- 1 | group: !include groups.yaml 2 | automation: !include automations.yaml 3 | script: !include scripts.yaml 4 | 5 | http: 6 | base_url: xxxxxxx.duckdns.org:8123 7 | 8 | tts: 9 | - platform: google_translate 10 | 11 | default_config: 12 | 13 | frontend: 14 | themes: !include themes.yaml 15 | 16 | camera: 17 | - platform: mjpeg 18 | mjpeg_url: http://localhost:3081 19 | name: Note Cam 20 | 21 | vacuum: 22 | - platform: xiaomi_miio 23 | host: 10.0.0.11 24 | token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 25 | 26 | yeelight: 27 | devices: 28 | 10.0.0.12: 29 | name: Light Victor's Bedroom 30 | 10.0.0.13: 31 | name: Light Door 32 | -------------------------------------------------------------------------------- /src/routes/v1/vacuum/routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import controller from './controller' 3 | import validators from './validators' 4 | 5 | const router = express.Router() 6 | 7 | router.get('/status', controller.status) 8 | router.get('/zone', controller.zones) 9 | router.post('/zone', [validators.checkFields], controller.cleanZone) 10 | router.post('/speed', controller.setSpeed) 11 | router.post('/start', controller.generic('activateCleaning', 'cleaning')) 12 | router.post('/spot', controller.generic('activateSpotClean', 'cleaning stop')) 13 | router.post('/stop', controller.stop) 14 | router.post('/find', controller.generic('find', 'listen')) 15 | router.post('/dock', controller.generic('activateCharging', 'going back')) 16 | 17 | export default router 18 | -------------------------------------------------------------------------------- /src/homewads-api.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import serveIndex from 'serve-index' 3 | import server from './config/server' 4 | import bodyParser from 'body-parser' 5 | import logger from 'morgan' 6 | import validator from 'express-validator' 7 | import DuckDns from './modules/duckDns' 8 | import versionRoutes from './routes/v1/routes' 9 | import { motionArchive, archivePath } from './routes/v1/motion/controller' 10 | 11 | // Services 12 | DuckDns() 13 | 14 | // UI Server 15 | const home = express() 16 | home.use(logger('dev')) 17 | home.use(express.static('public')) 18 | home.use(archivePath, express.static(motionArchive)) 19 | home.use(archivePath, serveIndex(motionArchive)) 20 | 21 | // API Server 22 | const api = express() 23 | server.cors(api) 24 | api.use(bodyParser.json()) 25 | api.use(bodyParser.urlencoded({ extended: false })) 26 | api.use(validator()) 27 | api.use(logger('dev')) 28 | api.use('/api/v1', versionRoutes) 29 | 30 | server.start(api, home) 31 | export default server 32 | -------------------------------------------------------------------------------- /public/index.js: -------------------------------------------------------------------------------- 1 | const base_domain = location.hostname 2 | let port = location.port == '' ? '' : ':' + location.port 3 | let box 4 | 5 | if (['localhost', '127.0.0.1'].indexOf(base_domain) !== -1) 6 | port = ':' + (port.replace(/[^0-9]/g, '') - 1) 7 | 8 | const API_URL = location.protocol + '//api.' + base_domain + port + '/api/v1/' 9 | const options = { 10 | method: 'POST', 11 | headers: { 12 | 'Content-Type': 'application/json' 13 | }, 14 | } 15 | 16 | window.onload = async () => { 17 | var OneSignal = window.OneSignal || []; 18 | OneSignal.push(function () { 19 | OneSignal.init({ 20 | appId: "d1b284e5-8e85-4b00-bda9-09ae03553fc4", 21 | }); 22 | }); 23 | 24 | box = document.getElementById('box') 25 | } 26 | 27 | async function log(requestPromisse) { 28 | const response = await requestPromisse 29 | let json = await response.json() 30 | let text = JSON.stringify(json, null, 2) 31 | box.innerHTML = text 32 | return json 33 | } -------------------------------------------------------------------------------- /src/config/server.js: -------------------------------------------------------------------------------- 1 | import cors from 'cors' 2 | 3 | const BASE_DOMAIN = process.env.BASE_DOMAIN 4 | const API_PORT = 8080 5 | const HOME_PORT = 8081 6 | 7 | var allowedOrigins = [ 8 | 'https://' + BASE_DOMAIN, 9 | 'https://' + BASE_DOMAIN + ':7443', 10 | 'http://' + BASE_DOMAIN + ':' + API_PORT, 11 | 'http://' + BASE_DOMAIN + ':' + HOME_PORT 12 | ] 13 | 14 | export default { 15 | cors: api => { 16 | api.use(cors({ 17 | origin: function (origin, callback) { 18 | if (!origin) return callback(null, true) 19 | if (allowedOrigins.indexOf(origin) === -1) { 20 | return callback(null, false) 21 | } 22 | return callback(null, true) 23 | } 24 | })) 25 | }, 26 | start: (api, home) => { 27 | api.use((req, res, next) => { 28 | res.setHeader('Content-Type', 'application/json') 29 | next() 30 | }) 31 | api.listen(API_PORT, () => { 32 | console.log('API Server listening on port ' + API_PORT) 33 | }) 34 | home.listen(HOME_PORT, () => { 35 | console.log('Home Server listening on port ' + HOME_PORT) 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /services/proxy/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | image: jc21/nginx-proxy-manager:latest 5 | restart: always 6 | ports: 7 | - 80:80 8 | - 81:81 9 | - 443:443 10 | volumes: 11 | - ./config.json:/app/config/production.json 12 | - ./data:/data 13 | - ./letsencrypt:/etc/letsencrypt 14 | depends_on: 15 | - db 16 | environment: 17 | # if you want pretty colors in your docker logs: 18 | - FORCE_COLOR=1 19 | db: 20 | image: mariadb/server:latest 21 | restart: always 22 | ports: 23 | - 3306:3306 24 | environment: 25 | MARIADB_ROOT_PASSWORD: "npm" 26 | MARIADB_ROOT_HOST: "%" 27 | MARIADB_DATABASE: "npm" 28 | MARIADB_USER: "npm" 29 | MARIADB_PASSWORD: "npm" 30 | volumes: 31 | - ./data/mysql:/var/lib/mysql 32 | dns: 33 | restart: always 34 | image: jpillora/dnsmasq 35 | volumes: 36 | - ./dnsmasq.conf:/etc/dnsmasq.conf 37 | ports: 38 | - "53:53/udp" 39 | - "53:53/tcp" 40 | - "5380:8080/tcp" 41 | cap_add: 42 | - NET_ADMIN -------------------------------------------------------------------------------- /src/routes/v1/motion/controller.js: -------------------------------------------------------------------------------- 1 | import { Notify } from '../../../helpers/onesignal' 2 | 3 | export const archivePath = '/archive' 4 | export const motionArchive = '/home/homeauto/mi-home-api/services/motion/' 5 | 6 | let lastNotify = new Date().getTime() 7 | let recording = false 8 | 9 | function NotifyMotion (motionArea) { 10 | let diff = (new Date().getTime() - lastNotify) / 1000 11 | 12 | if (!recording && diff > 30) { 13 | Notify(`Motion Detected ${motionArea.width}x${motionArea.height}`) 14 | lastNotify = new Date().getTime() 15 | } 16 | } 17 | 18 | export default { 19 | detect: (req, res) => { 20 | NotifyMotion(req.body.motion_area) 21 | res.send() 22 | }, 23 | movie: (req, res) => { 24 | console.log(req.body) 25 | const { file, action, motion_area: motionArea } = req.body 26 | let url = archivePath + '/' + file.replace(motionArchive, '').replace(/\/\//g, '/') 27 | if (action === 'start') { 28 | NotifyMotion(motionArea) 29 | recording = true 30 | } else if (action === 'end') { 31 | Notify('Video Available', url) 32 | recording = false 33 | } 34 | 35 | res.send() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/helpers/onesignal.js: -------------------------------------------------------------------------------- 1 | import OneSignal from 'onesignal-node' 2 | 3 | const userAuthKey = process.env.ONE_SIGNAL_USER_KEY 4 | const appAuthKey = process.env.ONE_SIGNAL_APP_KEY 5 | const appId = 'd1b284e5-8e85-4b00-bda9-09ae03553fc4' 6 | 7 | const NotificationsQuee = [] 8 | let queePid = 0 9 | function sendQuee () { 10 | if (NotificationsQuee.length === 0) { return } 11 | clearTimeout(queePid) 12 | for (let i = 0; i < NotificationsQuee.length; i++) { 13 | Client.sendNotification(NotificationsQuee[i]) 14 | .then(() => { 15 | NotificationsQuee.splice(i, 1) 16 | }) 17 | .catch(err => { 18 | console.log(err.data) 19 | NotificationsQuee.push(NotificationsQuee[i]) 20 | }) 21 | } 22 | queePid = setTimeout(sendQuee, 5000) 23 | } 24 | 25 | export const Client = new OneSignal.Client({ userAuthKey, app: { appAuthKey, appId } }) 26 | export const Notify = async (msg, url, icon) => { 27 | NotificationsQuee.push(new OneSignal.Notification({ 28 | contents: { 29 | en: msg 30 | }, 31 | url: url, 32 | web_buttons: url ? [{ id: 'see', text: 'Watch', url }] : [], 33 | included_segments: ['Active Users', 'Inactive Users'] 34 | })) 35 | sendQuee() 36 | } 37 | export default OneSignal 38 | -------------------------------------------------------------------------------- /todoing.md: -------------------------------------------------------------------------------- 1 | # TO-DO-ING 2 | 3 | Here are the Topics i'll probably work after: 4 | 5 | ## Functional 6 | - Improve WebIU UX :soon: 7 | - Control Ligths Via Web Interface :heavy_check_mark: 8 | - Create Dimmer API :heavy_check_mark: 9 | - Vaccum :soon: 10 | - Create start Endpoint :heavy_check_mark: 11 | - Notify State changes 12 | - Check Server Baterry Status 13 | - Send power changes Notificaion 14 | - Check Server Storage status 15 | - Set notifications typed icons :soon: 16 | - List Motion videos on Web Interface 17 | - **Find a way to get Xiaomi Gateway integration 18 | - Automation 19 | - Auto Off Lights 20 | - Schecule Actions 21 | 22 | ## Structural 23 | - Replace hass.io 24 | - core -> miio :heavy_check_mark: 25 | - add-on nginx proxy -> dockered nginx proxy :heavy_check_mark: 26 | - add-on duck dns -> api duck dns call :heavy_check_mark: 27 | - add-on dns mask -> dockered dns server :heavy_check_mark: 28 | - Store on BD 29 | - Vaccum Zones 30 | - Devices Configs/Info :soon: 31 | - Prepare API to load devices info :heavy_check_mark: 32 | - Logs 33 | - Purge Old Motion Videos 34 | 35 | ## Security 36 | - Make API Authenticated 37 | - Log All IP Access in BD 38 | - Camera Access 39 | - Web UI Access 40 | - API Access 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | package-lock\.json 64 | 65 | #Vscode 66 | .vscode 67 | 68 | # Docker and Storage 69 | services/motion/movie 70 | services/proxy/config.json 71 | services/proxy/data 72 | services/proxy/letsencrypt -------------------------------------------------------------------------------- /upserver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check Node Install 4 | if [[ ! `npm --help` ]]; then 5 | echo "Please install node" 6 | exit 7 | fi 8 | 9 | # Restart Motion 10 | if [[ `motion --help` ]]; then 11 | while sudo killall motion; 12 | do 13 | sleep 1 14 | done 15 | sudo rm /etc/motion/motion.conf 16 | sudo cp ./services/motion/motion.conf /etc/motion/motion.conf 17 | sudo motion 18 | else 19 | echo "Motion not found" 20 | echo "Run: sudo apt-get install motion" 21 | exit 22 | fi 23 | 24 | # Install Node Depencencies 25 | if [[ ! -d "./node_modules" ]]; then 26 | npm i 27 | fi 28 | 29 | # Start Server 30 | if [[ ! `pm2 --help` ]]; then 31 | npm i -g pm2 32 | fi 33 | 34 | if [[ `pm2 --help` ]]; then 35 | echo "Staring Node API" 36 | pm2 del all 37 | pm2 start index.js 38 | pm2 save 39 | pm2 startup 40 | fi 41 | 42 | #Initialize Proxy Manager 43 | cd services/proxy 44 | if [[ ! -f "config.json" ]]; then 45 | cp config_base.json config.json 46 | fi 47 | 48 | docker-compose down 49 | docker-compose up -d 50 | 51 | echo "Node API - Shold Listen on 8080" 52 | echo "Node API UI - Shold Listen on 8081" 53 | echo "Motion Web - Shold Listen on 3081" 54 | echo "Motion Stream - Shold Listen on 3081" 55 | echo "Proxy Admin - Should Listen HTTP on 81" 56 | echo "Proxy - Should Listen HTTP on 80" 57 | echo "Proxy - Should Listen HTTPS on 443" -------------------------------------------------------------------------------- /docs/examples/etc-motion-config.d: -------------------------------------------------------------------------------- 1 | ## I've changed only this configs, the other i've used default ones 2 | 3 | daemon on 4 | 5 | # ... 6 | 7 | videodevice /dev/video0 8 | 9 | # ... 10 | 11 | width 1280 12 | height 720 13 | framerate 30 14 | minimum_frame_time 0 15 | 16 | # ... 17 | 18 | output_pictures off 19 | output_debug_pictures off 20 | 21 | # ... 22 | 23 | quality 90 24 | picture_type jpeg 25 | 26 | # ... 27 | 28 | ffmpeg_bps 600000 29 | ffmpeg_variable_bitrate 85 30 | ffmpeg_video_codec mp4 31 | 32 | snapshot_interval 0 33 | 34 | # ... 35 | 36 | target_dir /usr/share/hassio/share/motion/ 37 | snapshot_filename snapshot/%Y/%m/%d/%H/%v-%M%S-snapshot 38 | picture_filename picture/%Y/%m/%d/%H/%v-%M%S-%q 39 | movie_filename movie/%Y/%m/%d/%v-%Y%m%d%H%M%S 40 | timelapse_filename timelapse/%Y%m%d-timelapse 41 | 42 | # ... 43 | 44 | stream_port 3081 45 | stream_quality 85 46 | stream_motion on 47 | stream_maxrate 30 48 | 49 | # ... 50 | 51 | stream_localhost off 52 | 53 | # ... 54 | 55 | webcontrol_port 3080 56 | webcontrol_localhost off 57 | webcontrol_html_output on 58 | 59 | # ... 60 | 61 | quiet on 62 | on_movie_start wget -q -O- --header='Content-Type:application/json' --post-data='{"file":"%f", "action":"start", "motion_area": {"x":"%K", "y":"%L", "width": "%i", "height":"%J"}}' http://127.0.0.1:8080/api/v1/motion/movie 63 | on_movie_end wget -q -O- --header='Content-Type:application/json' --post-data='{"file":"%f", "action":"end", "motion_area": {"x":"%K", "y":"%L", "width": "%i", "height":"%J"}}' http://127.0.0.1:8080/api/v1/motion/movie -------------------------------------------------------------------------------- /src/modules/miio-connect.js: -------------------------------------------------------------------------------- 1 | import miio from 'miio' 2 | 3 | export const Type = { 4 | Gateway: {}, 5 | Vacuum: {}, 6 | Light: {} 7 | } 8 | 9 | export function LogError (err, device) { 10 | device.connection = null 11 | console.error('Error with', device.name, err) 12 | return { name: device.name, err } 13 | } 14 | 15 | export function GenericRespose (res, promise) { 16 | promise 17 | .then(promises => promises instanceof Array ? Promise.all(promises) : promises) 18 | .then(result => res.status(200).json(result)) 19 | .catch(err => res.status(500).json(err)) 20 | } 21 | 22 | const DeviceList = [ 23 | { ip: '10.0.0.11', type: Type.Vacuum, name: 'yang' }, 24 | { ip: '10.0.0.12', type: Type.Light, name: 'bedroom' }, 25 | { ip: '10.0.0.13', type: Type.Light, name: 'living room' }, 26 | { ip: '10.0.0.14', type: Type.Light, name: 'bathroom' }, 27 | { ip: '10.0.0.15', type: Type.Light, name: 'workroom' } 28 | ] 29 | 30 | export default async function Connect (type) { 31 | await Promise.all(DeviceList 32 | .filter(device => (device.type === type || !type) && !device.connection) 33 | .map(async device => { 34 | console.log('Connecting with ', device.name) 35 | await miio.device({ address: device.ip }) 36 | .then(connection => { 37 | device.connection = connection 38 | console.log('Connected with ', device.name, device.ip) 39 | }) 40 | .catch(err => LogError(err, device)) 41 | return device 42 | }) 43 | ) 44 | 45 | return DeviceList.filter(device => (device.type === type || !type) && device.connection) 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homewads-api", 3 | "version": "1.3.0", 4 | "description": "Home API to Control Xiaomi Home Appliance", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "nodemon -r esm index.js", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "lint": "./node_modules/.bin/eslint ./src --ext .js --fix" 10 | }, 11 | "pre-commit": [ 12 | "lint" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/victorwads/homewads-api.git" 17 | }, 18 | "keywords": [ 19 | "home", 20 | "vaccum", 21 | "xiaomi", 22 | "mihome", 23 | "automation", 24 | "house" 25 | ], 26 | "author": "@victorwads", 27 | "license": "ISC", 28 | "bugs": { 29 | "url": "https://github.com/victorwads/homewads-api/issues" 30 | }, 31 | "homepage": "https://github.com/victorwads/homewads-api#readme", 32 | "_moduleAliases": { 33 | "@helpers": "src/helpers" 34 | }, 35 | "esm": { 36 | "await": true 37 | }, 38 | "dependencies": { 39 | "cors": "^2.8.5", 40 | "esm": "^3.2.25", 41 | "express": "^4.17.1", 42 | "express-validator": "5.0.1", 43 | "miio": "git://github.com/victorwads/miio.git#vw0.3", 44 | "module-alias": "^2.2.0", 45 | "morgan": "^1.9.1", 46 | "node-fetch": "^2.6.0", 47 | "onesignal-node": "^2.1.0", 48 | "serve-index": "^1.9.1" 49 | }, 50 | "devDependencies": { 51 | "eslint": "^6.0.1", 52 | "eslint-config-standard": "^12.0.0", 53 | "eslint-plugin-import": "^2.18.0", 54 | "eslint-plugin-node": "^9.1.0", 55 | "eslint-plugin-promise": "^4.2.1", 56 | "eslint-plugin-standard": "^4.0.0", 57 | "nodemon": "^1.19.1", 58 | "pre-commit": "^1.2.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /public/vaccum.js: -------------------------------------------------------------------------------- 1 | let boxSpeed, speedState, boxBatery, textBatery, textStatus; 2 | { 3 | zoneBox = document.getElementById('zoneBox') 4 | speedBox = document.getElementById('speedBox') 5 | bateryBox = document.getElementById('bateryBox') 6 | bateryText = document.getElementById('bateryText') 7 | statusText = document.getElementById('statusText') 8 | 9 | getZonesAPI() 10 | .then(res => res.json()) 11 | .then(zones => { 12 | for (let zone in zones) { 13 | zoneBox.innerHTML += `` 14 | } 15 | }) 16 | 17 | configStatus() 18 | setInterval(configStatus, 300000); 19 | } 20 | 21 | async function configStatus() { 22 | const response = await log(getStatusAPI()) 23 | let { fanSpeed, batteryLevel, state } = response; 24 | speedBox.removeEventListener('change', setSpeed) 25 | 26 | if (fanSpeed > 77) 27 | fanSpeed = 'Max' 28 | else if (fanSpeed > 60) 29 | fanSpeed = 'Turbo' 30 | else if (fanSpeed > 38) 31 | fanSpeed = 'Balanced' 32 | else 33 | fanSpeed = 'Quiet' 34 | 35 | speedState = fanSpeed 36 | speedBox.value = fanSpeed 37 | 38 | bateryBox.value = batteryLevel || 0 39 | bateryText.innerHTML = bateryBox.value + '%' 40 | 41 | statusText.innerHTML = state + ' - ' + response.state 42 | 43 | speedBox.addEventListener('change', setSpeed) 44 | } 45 | 46 | async function getStatusAPI() { 47 | return fetch(API_URL + 'vacuum/status', { ...options, method: 'GET' }) 48 | } 49 | 50 | async function getZonesAPI() { 51 | return fetch(API_URL + 'vacuum/zone', { ...options, method: 'GET' }) 52 | } 53 | 54 | async function callGeneric(action) { 55 | log(fetch(API_URL + 'vacuum/' + action, options)) 56 | } 57 | 58 | async function cleanZone() { 59 | log(fetch(API_URL + 'vacuum/zone', { 60 | ...options, 61 | body: JSON.stringify({ 62 | repeats: 1, 63 | zone: zoneBox.value 64 | }) 65 | })) 66 | } 67 | 68 | async function setSpeed() { 69 | if (speedBox.value == speedState) 70 | return 71 | speedState = speedBox.value 72 | log(fetch(API_URL + 'vacuum/speed', { 73 | ...options, 74 | body: JSON.stringify({ 75 | speed: speedState, 76 | }) 77 | })) 78 | } 79 | -------------------------------------------------------------------------------- /src/routes/v1/light/controller.js: -------------------------------------------------------------------------------- 1 | import { color as Color } from 'abstract-things/values' 2 | import Connect, { Type, LogError, GenericRespose } from '../../../modules/miio-connect' 3 | 4 | async function Light (name) { 5 | return (await Connect(Type.Light)) 6 | .filter(light => !name || light.name === name) 7 | } 8 | 9 | async function getStatus (name) { 10 | return Light(name) 11 | .then(list => list.map(device => device.connection._properties)) 12 | } 13 | 14 | const controller = { 15 | status: (req, res) => GenericRespose( 16 | res, 17 | getStatus(req.params.name) 18 | ), 19 | 20 | toggle: (req, res) => GenericRespose( 21 | res, 22 | Light(req.params.name) 23 | .then(list => list.map(async light => { 24 | try { 25 | const power = !light.connection._properties.power 26 | await light.connection.changePower(power) 27 | return { name: light.name, msg: 'turned ' + (power ? 'on' : 'off') } 28 | } catch (err) { 29 | return LogError(err, light) 30 | } 31 | })) 32 | ), 33 | 34 | generic: function (action, msg, args = []) { 35 | return (req, res) => GenericRespose( 36 | res, 37 | Light(req.params.name) 38 | .then(list => list.map(async light => { 39 | try { 40 | await light.connection[action](...args) 41 | return { name: light.name, msg } 42 | } catch (err) { 43 | return LogError(err, light) 44 | } 45 | })) 46 | ) 47 | }, 48 | 49 | color: (req, res) => { 50 | const { color, duration } = req.body 51 | const args = [Color(color, 'rgb'), { duration: duration || 500 }] 52 | 53 | controller.generic('changeColor', 'color changed to ' + color, args)(req, res) 54 | }, 55 | 56 | brightness: (req, res) => { 57 | const { value, duration } = req.body 58 | const args = [parseInt(value), { powerOn: true, duration }] 59 | 60 | controller.generic('changeBrightness', 'brightness changed to ' + value, args)(req, res) 61 | }, 62 | 63 | temperature: (req, res) => { 64 | const { value, duration } = req.body 65 | const args = [Color(value + 'k', 'temperature'), { duration }] 66 | 67 | controller.generic('changeColor', 'temperature changed to ' + value, args)(req, res) 68 | } 69 | } 70 | 71 | export default controller 72 | -------------------------------------------------------------------------------- /public/ligths.js: -------------------------------------------------------------------------------- 1 | let boxLight, lastPost, boxRange, boxTemp 2 | { 3 | boxRange = document.getElementById('ligthRange') 4 | boxTemp = document.getElementById('temperatureRange') 5 | selectLight = document.getElementById('lightBox') 6 | const pickr = Pickr.create({ 7 | el: '.color', 8 | theme: 'nano', 9 | default: '#ffff', 10 | defaultRepresentation: 'HEX', 11 | components: { 12 | // Main components 13 | preview: true, 14 | opacity: true, 15 | hue: true, 16 | 17 | interaction: { 18 | hex: true, 19 | input: true, 20 | save: true 21 | } 22 | } 23 | }); 24 | lastPost = new Date().getTime() 25 | 26 | pickr.on('save', (color, instance) => { 27 | changeColor(color) 28 | }).on('change', (color, instance) => { 29 | let diff = new Date().getTime() - lastPost 30 | if (diff > 100) { 31 | changeColor(color) 32 | } 33 | }); 34 | 35 | boxRange.addEventListener('change', function () { 36 | console.log(this.value) 37 | changeValue('brightness', this.value); 38 | }) 39 | boxTemp.addEventListener('change', function () { 40 | console.log(this.value) 41 | changeValue('temperature', this.value); 42 | }) 43 | } 44 | 45 | async function configStatusLights() { 46 | let apiCall = getLightStatusAPI() 47 | log(apiCall) 48 | const response = await (await apiCall).json() 49 | let { } = response; 50 | return apiCall 51 | } 52 | 53 | async function getLightStatusAPI() { 54 | return fetch(API_URL + 'light/status/' + selectLight.value, { ...options, method: 'GET' }) 55 | } 56 | 57 | async function changeColor(color) { 58 | lastPost = new Date().getTime() 59 | log(fetch(API_URL + 'light/color/' + selectLight.value, { 60 | ...options, 61 | body: JSON.stringify({ color: '#' + color.toHEXA().join(''), duration: 100 }) 62 | })) 63 | } 64 | 65 | async function lightCallGeneric(action) { 66 | log(fetch(API_URL + 'light/' + action + '/' + selectLight.value, options)) 67 | } 68 | 69 | async function configStatusLights() { 70 | const response = await log(getLightStatusAPI()) 71 | } 72 | 73 | async function changeValue(name, value) { 74 | lastPost = new Date().getTime() 75 | log(fetch(API_URL + 'light/' + name + '/' + selectLight.value, { 76 | ...options, 77 | body: JSON.stringify({ value }) 78 | })) 79 | } 80 | 81 | async function changeColorMode(mode) { 82 | return changeValue('mode', mode) 83 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Home API to Control Home Appliance with Hassio, Motion 3 | 4 | This is a Node Js API that centers some home appliance services initially made to able Voice Control of a Xiaomi Vaccum Cleaner. In my home, I've got new devices and the system starts to growing up. If you want to contribute, be free!! 5 | 6 | ## API End Points 7 | 8 | - /api/v1/ 9 | - light/ - If name is passed filter light, if not, all lights are used 10 | - GET:status/:name*?: - Get Lights Status 11 | - POST:on/:name*?: - Turn on Light 12 | - POST:off/:name*?: - Turn off Light 13 | - POST:toggle/:name*?: - Toogle Light Power 14 | - POST:color/:name*?: - Body: {color: string, duration: int} - Change Light color with duration animation 15 | - POST:brightness/:name*?: - Body: {value: int[0-100], duration: int} - Change Light color with duration animation 16 | - POST:temperature/:name*?: - Body: {value: int[2700-6500], duration: int} - Change Light color with duration animation 17 | - vaccum/ 18 | - GET:status: - Get Vaccum Status 19 | - GET:zone: - Retrurn the list of zones 20 | - POST:start - Start cleaning 21 | - POST:stop - Stop the vaccum, even if it is going to dock 22 | - POST:spot - Start spot clean 23 | - POST:find - Make Vaccum play a sound 24 | - POST:zone - Body: {[speed: int,] zone: string, repeats: int} - Start Cleaning one or more Zones, zone names splited by ' ' 25 | - POST:speed - Body: {speed: int} - Start Cleaning one or more Zones, zone names splited by ' ' 26 | - POST:dock - Send the vaccum to dock 27 | - motion/ 28 | - POST:detect -{motion_area: {x: int, y: int, width: int, heigth: int}} - Send Motion Detect Notification 29 | - POST:movie - {file: string, action: string(start|end)[, motion_area: {x: int, y: int, width: int, heigth: int}]} 30 | When Start: Send Motion Detect Notification 31 | When Ends: Send Video Access Link Notification 32 | 33 | # Features 34 | 35 | - Control Xiaomi Vaccum by Google Assitant 36 | - "Send vaccum to dock" 37 | - "Change vaccum's Power to ${speed}" 38 | - "Clean the ${room_name}" 39 | - Embeded Web Interface 40 | - Control Xiaomi Vaccum 41 | - Control Xiaomi Yeelight Ligths 42 | - Stream WebCam Video via WebInterface 43 | - Send Web/Mobile Notifications to Device 44 | - When WebCam Detects Motion - Text With Warning 45 | - When salves Detected Motion video - Text with button to access the via via WebInterface 46 | 47 | [See next planned changes Here](todoing.md) 48 | 49 | # Dependencies 50 | 51 | - Miio - Xiaomi Device Comunication - https://github.com/aholstenson/miio 52 | - Motion - Stream WebCam - https://github.com/Motion-Project/motion 53 | - OneSignal - Send Notifications - https://github.com/zeyneloz/onesignal-node 54 | - IFFT - Google Assistant Integration - https://ifttt.com 55 | 56 | ## Dependencies Configs Examples 57 | 58 | - Motion - [etc/motion/config.d - exemple](docs/examples/etc-motion-config.d) 59 | 60 | # Funcional Diagram 61 | 62 | ![Google Assistant Voice Control Data Flow Diagram](docs/imgs/Google-Assistant-Data-Flow.svg) 63 | 64 | # References 65 | 66 | References that help me to learn to this project 67 | - https://github.com/marcelrv/XiaomiRobotVacuumProtocol 68 | - https://github.com/OpenMiHome/mihome-binary-protocol 69 | - https://kaeni.de/deutsche-sprachpakete-fuer-den-roborock-sweep-one/ 70 | - https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara) 71 | - https://dev.mi.com/docs/passport/en/scopes/ 72 | -------------------------------------------------------------------------------- /src/routes/v1/vacuum/controller.js: -------------------------------------------------------------------------------- 1 | import Connect, { Type } from '../../../modules/miio-connect' 2 | 3 | async function Vacuum () { 4 | // TODO handle more vaccums 5 | return (await Connect(Type.Vacuum))[0].connection 6 | } 7 | 8 | const fanSpeedList = { 9 | quiet: 38, 10 | balanced: 60, 11 | turbo: 77, 12 | max: 100 13 | } 14 | 15 | let aliasZones 16 | { 17 | let lucabedroom = [[24200, 27350, 26600, 31300]] 18 | let kitchen = [[21950, 33300, 26500, 35000]] 19 | let livingroom = [[22000, 29850, 26000, 33100]] 20 | let hall = [[23200, 26000, 24100, 29950]] 21 | let bedroom = [ 22 | [23200, 26000, 24200, 26500], 23 | [22900, 23500, 26000, 26000] 24 | ] 25 | aliasZones = { 26 | lucabedroom, 27 | kitchen, 28 | livingroom, 29 | hall, 30 | bedroom, 31 | susiemes: livingroom, 32 | susiemess: livingroom, 33 | victorbedroom: bedroom 34 | } 35 | } 36 | 37 | function getZone (name) { 38 | name = name.toLowerCase() 39 | .replace(/ room/g, 'room') 40 | .replace(/ and /g, ' ') 41 | .replace(/(up the |the |up my |my |s )/g, '') 42 | .replace(/[^a-z ]*/g, '') 43 | 44 | const coords = [] 45 | let names = name.split(' ') 46 | 47 | names.forEach(name => { 48 | let zoneCoords = aliasZones[name] 49 | 50 | if (zoneCoords instanceof Array) { zoneCoords.forEach(element => coords.push(element)) } 51 | }) 52 | 53 | return coords 54 | } 55 | 56 | async function getStatus () { 57 | return (await Vacuum())._properties 58 | } 59 | 60 | async function setSpeed (speed) { 61 | return Vacuum() 62 | .then(d => d.changeFanSpeed(fanSpeedList[speed.toLowerCase()] || speed)) 63 | } 64 | 65 | const controler = { 66 | cleanZone: (req, res) => { 67 | const { zone, repeats, speed } = req.body 68 | 69 | let coords = getZone(zone) 70 | if (coords === undefined || coords.length === 0) { 71 | return res.status(200).json({ error: `${zone} do not exists` }) 72 | } 73 | if (speed) { setSpeed(speed) } 74 | 75 | for (let i = 0; i < coords.length; i++) { 76 | const zone = coords[i] 77 | if (zone.length === 4) { zone.push(repeats) } 78 | } 79 | 80 | return Vacuum() 81 | .then(d => d.activateZoneClean(coords)) 82 | .then(() => res.status(200).json({ status: `cleaning ${zone}` })) 83 | .catch(err => res.status(500).json({ err })) 84 | }, 85 | 86 | stop: async (req, res) => { 87 | const status = await getStatus() 88 | if (status.state === 'returning') { 89 | await Vacuum().then(d => d.pause()) 90 | } 91 | return controler.generic('deactivateCleaning', 'stoped')(req, res) 92 | }, 93 | 94 | generic: function (action, msg) { 95 | return async (req, res) => { 96 | return Vacuum() 97 | .then(d => d[action]()) 98 | .then(() => res.status(200).json({ status: msg })) 99 | .catch(err => res.status(500).json({ err })) 100 | } 101 | }, 102 | 103 | setSpeed: (req, res) => { 104 | const { speed } = req.body 105 | 106 | setSpeed(speed) 107 | .then(response => res.status(200).json(response)) 108 | .catch(err => res.status(500).json({ err })) 109 | }, 110 | 111 | status: (req, res) => getStatus() 112 | .then(status => res.status(200).json(status)) 113 | .catch(err => res.status(500).json({ err })), 114 | 115 | zones: (req, res) => 116 | res.status(200).json(aliasZones) 117 | } 118 | 119 | export default controler 120 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Victor Wads's House 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |

Camera

21 |
22 | 23 |
24 | 25 |

Vaccum

26 |
27 | Status: 28 | 29 |
30 |
31 | Batery: 32 | 33 | 34 |
35 |
36 | Actions: 37 | 38 | 39 | 40 | 41 | 42 |
43 |
44 | Clean Zone: 45 | 48 | 55 | 56 |
57 |

Lights

58 | 66 | 67 | 68 | 69 | 70 |
71 | Color: 72 |
73 |
74 | Temperature: 75 |
76 |
77 | Brightness: 78 |
79 |

Logs

80 |

81 | 
82 | 
83 | 
84 | 


--------------------------------------------------------------------------------
/public/color-picker.css:
--------------------------------------------------------------------------------
1 | 
2 | /*! Pickr 1.2.1 MIT | https://github.com/Simonwep/pickr */.pickr{position:relative;overflow:visible;transform:translateY(0)}.pickr *{box-sizing:border-box}.pickr .pcr-button{position:relative;height:2em;width:2em;padding:.5em;cursor:pointer;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;border-radius:.15em;background:url('data:image/svg+xml;utf8, ') no-repeat 50%;background-size:0;transition:all .3s}.pickr .pcr-button:before{background:url('data:image/svg+xml;utf8, ');background-size:.5em;z-index:-1;z-index:auto}.pickr .pcr-button:after,.pickr .pcr-button:before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;border-radius:.15em}.pickr .pcr-button:after{transition:background .3s;background:currentColor}.pickr .pcr-button.clear{background-size:70%}.pickr .pcr-button.clear:before{opacity:0}.pickr .pcr-button.clear:focus{box-shadow:0 0 0 1px #f1f3f4,0 0 0 3px currentColor}.pickr .pcr-button.disabled{cursor:not-allowed}.pcr-app button,.pcr-app input,.pickr button,.pickr input{outline:none;border:none;-webkit-appearance:none}.pcr-app button:focus,.pcr-app input:focus,.pickr button:focus,.pickr input:focus{box-shadow:0 0 0 1px #f1f3f4,0 0 0 3px currentColor}.pcr-app[data-theme=nano]{position:absolute;display:flex;flex-direction:column;z-index:10000;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;box-shadow:0 .15em 1.5em 0 rgba(0,0,0,.1),0 0 1em 0 rgba(0,0,0,.03);width:14.25em;max-width:95vw;border-radius:.1em;background:#fff;opacity:0;visibility:hidden;transition:opacity .3s;left:0;top:0}.pcr-app[data-theme=nano].visible{visibility:visible;opacity:1}.pcr-app[data-theme=nano] .pcr-swatches{display:grid;align-items:center;grid-template-columns:repeat(auto-fit,1.75em);margin-top:.6em;padding:0 .6em}.pcr-app[data-theme=nano] .pcr-swatches.pcr-last{margin:0}.pcr-app[data-theme=nano] .pcr-swatches>button{font-size:1em;position:relative;width:calc(1.75em - 5px);height:calc(1.75em - 5px);border-radius:.15em;cursor:pointer;margin:2.5px;transition:all .15s;overflow:hidden;background:transparent;z-index:1}.pcr-app[data-theme=nano] .pcr-swatches>button:before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url('data:image/svg+xml;utf8, ');background-size:6px;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-swatches>button:after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:currentColor;border:1px solid rgba(0,0,0,.05);border-radius:.15em;box-sizing:border-box}.pcr-app[data-theme=nano] .pcr-swatches>button:hover{filter:brightness(1.05)}.pcr-app[data-theme=nano] .pcr-interaction{display:flex;flex-wrap:wrap;align-items:center;margin:0 -.2em;padding:0 .6em .6em}.pcr-app[data-theme=nano] .pcr-interaction>*{margin:0 .2em}.pcr-app[data-theme=nano] .pcr-interaction input{letter-spacing:.07em;font-size:.75em;text-align:center;cursor:pointer;color:#75797e;background:#f1f3f4;border-radius:.15em;transition:all .15s;padding:.45em .5em;margin-top:.6em}.pcr-app[data-theme=nano] .pcr-interaction input:hover{filter:brightness(.975)}.pcr-app[data-theme=nano] .pcr-interaction input:focus{box-shadow:0 0 0 1px #f1f3f4,0 0 0 3px rgba(66,133,244,.75)}.pcr-app[data-theme=nano] .pcr-interaction .pcr-result{color:#75797e;text-align:left;flex:1 1 8em;min-width:8em;transition:all .2s;border-radius:.15em;background:#f1f3f4;cursor:text}.pcr-app[data-theme=nano] .pcr-interaction .pcr-result::selection{background:#4285f4;color:#fff}.pcr-app[data-theme=nano] .pcr-interaction .pcr-type.active{color:#fff;background:#4285f4}.pcr-app[data-theme=nano] .pcr-interaction .pcr-clear,.pcr-app[data-theme=nano] .pcr-interaction .pcr-save{width:auto;color:#fff}.pcr-app[data-theme=nano] .pcr-interaction .pcr-clear:hover,.pcr-app[data-theme=nano] .pcr-interaction .pcr-save:hover{filter:brightness(.925)}.pcr-app[data-theme=nano] .pcr-interaction .pcr-save{background:#4285f4}.pcr-app[data-theme=nano] .pcr-interaction .pcr-clear{background:#f44250}.pcr-app[data-theme=nano] .pcr-interaction .pcr-clear:focus{box-shadow:0 0 0 1px #f1f3f4,0 0 0 3px rgba(244,66,80,.75)}.pcr-app[data-theme=nano] .pcr-selection{display:grid;grid-gap:.6em;grid-template-columns:1fr 4fr;grid-template-rows:5fr auto auto;align-items:center;height:10.5em;width:100%;align-self:flex-start}.pcr-app[data-theme=nano] .pcr-selection .pcr-picker{position:absolute;height:18px;width:18px;border:2px solid #fff;border-radius:100%;user-select:none}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview{grid-area:2/1/4/1;height:100%;width:100%;display:flex;flex-direction:row;justify-content:center;margin-left:.6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-last-color{display:none}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color{position:relative;background:currentColor;width:2em;height:2em;border-radius:50em;overflow:hidden}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color:before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url('data:image/svg+xml;utf8, ');background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette{position:relative;user-select:none;display:flex;flex-direction:column;cursor:grab;cursor:-moz-grab;cursor:-webkit-grab}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser:active,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity:active,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette:active{cursor:grabbing;cursor:-moz-grabbing;cursor:-webkit-grabbing}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette{grid-area:1/1/2/3;width:100%;height:100%;z-index:1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette{border-radius:.15em;width:100%;height:100%}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette:before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url('data:image/svg+xml;utf8, ');background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser{grid-area:2/2/2/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{grid-area:3/2/3/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{height:.5em;margin:0 .6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-picker,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-picker{top:50%;transform:translateY(-50%)}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{flex-grow:1;border-radius:50em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider{background:linear-gradient(90deg,red,#ff0,#0f0,#0ff,#00f,#f0f,red)}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{background:linear-gradient(90deg,transparent,#000),url('data:image/svg+xml;utf8, ');background-size:100%,.25em}


--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
  1 |                                  Apache License
  2 |                            Version 2.0, January 2004
  3 |                         http://www.apache.org/licenses/
  4 | 
  5 |    TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
  6 | 
  7 |    1. Definitions.
  8 | 
  9 |       "License" shall mean the terms and conditions for use, reproduction,
 10 |       and distribution as defined by Sections 1 through 9 of this document.
 11 | 
 12 |       "Licensor" shall mean the copyright owner or entity authorized by
 13 |       the copyright owner that is granting the License.
 14 | 
 15 |       "Legal Entity" shall mean the union of the acting entity and all
 16 |       other entities that control, are controlled by, or are under common
 17 |       control with that entity. For the purposes of this definition,
 18 |       "control" means (i) the power, direct or indirect, to cause the
 19 |       direction or management of such entity, whether by contract or
 20 |       otherwise, or (ii) ownership of fifty percent (50%) or more of the
 21 |       outstanding shares, or (iii) beneficial ownership of such entity.
 22 | 
 23 |       "You" (or "Your") shall mean an individual or Legal Entity
 24 |       exercising permissions granted by this License.
 25 | 
 26 |       "Source" form shall mean the preferred form for making modifications,
 27 |       including but not limited to software source code, documentation
 28 |       source, and configuration files.
 29 | 
 30 |       "Object" form shall mean any form resulting from mechanical
 31 |       transformation or translation of a Source form, including but
 32 |       not limited to compiled object code, generated documentation,
 33 |       and conversions to other media types.
 34 | 
 35 |       "Work" shall mean the work of authorship, whether in Source or
 36 |       Object form, made available under the License, as indicated by a
 37 |       copyright notice that is included in or attached to the work
 38 |       (an example is provided in the Appendix below).
 39 | 
 40 |       "Derivative Works" shall mean any work, whether in Source or Object
 41 |       form, that is based on (or derived from) the Work and for which the
 42 |       editorial revisions, annotations, elaborations, or other modifications
 43 |       represent, as a whole, an original work of authorship. For the purposes
 44 |       of this License, Derivative Works shall not include works that remain
 45 |       separable from, or merely link (or bind by name) to the interfaces of,
 46 |       the Work and Derivative Works thereof.
 47 | 
 48 |       "Contribution" shall mean any work of authorship, including
 49 |       the original version of the Work and any modifications or additions
 50 |       to that Work or Derivative Works thereof, that is intentionally
 51 |       submitted to Licensor for inclusion in the Work by the copyright owner
 52 |       or by an individual or Legal Entity authorized to submit on behalf of
 53 |       the copyright owner. For the purposes of this definition, "submitted"
 54 |       means any form of electronic, verbal, or written communication sent
 55 |       to the Licensor or its representatives, including but not limited to
 56 |       communication on electronic mailing lists, source code control systems,
 57 |       and issue tracking systems that are managed by, or on behalf of, the
 58 |       Licensor for the purpose of discussing and improving the Work, but
 59 |       excluding communication that is conspicuously marked or otherwise
 60 |       designated in writing by the copyright owner as "Not a Contribution."
 61 | 
 62 |       "Contributor" shall mean Licensor and any individual or Legal Entity
 63 |       on behalf of whom a Contribution has been received by Licensor and
 64 |       subsequently incorporated within the Work.
 65 | 
 66 |    2. Grant of Copyright License. Subject to the terms and conditions of
 67 |       this License, each Contributor hereby grants to You a perpetual,
 68 |       worldwide, non-exclusive, no-charge, royalty-free, irrevocable
 69 |       copyright license to reproduce, prepare Derivative Works of,
 70 |       publicly display, publicly perform, sublicense, and distribute the
 71 |       Work and such Derivative Works in Source or Object form.
 72 | 
 73 |    3. Grant of Patent License. Subject to the terms and conditions of
 74 |       this License, each Contributor hereby grants to You a perpetual,
 75 |       worldwide, non-exclusive, no-charge, royalty-free, irrevocable
 76 |       (except as stated in this section) patent license to make, have made,
 77 |       use, offer to sell, sell, import, and otherwise transfer the Work,
 78 |       where such license applies only to those patent claims licensable
 79 |       by such Contributor that are necessarily infringed by their
 80 |       Contribution(s) alone or by combination of their Contribution(s)
 81 |       with the Work to which such Contribution(s) was submitted. If You
 82 |       institute patent litigation against any entity (including a
 83 |       cross-claim or counterclaim in a lawsuit) alleging that the Work
 84 |       or a Contribution incorporated within the Work constitutes direct
 85 |       or contributory patent infringement, then any patent licenses
 86 |       granted to You under this License for that Work shall terminate
 87 |       as of the date such litigation is filed.
 88 | 
 89 |    4. Redistribution. You may reproduce and distribute copies of the
 90 |       Work or Derivative Works thereof in any medium, with or without
 91 |       modifications, and in Source or Object form, provided that You
 92 |       meet the following conditions:
 93 | 
 94 |       (a) You must give any other recipients of the Work or
 95 |           Derivative Works a copy of this License; and
 96 | 
 97 |       (b) You must cause any modified files to carry prominent notices
 98 |           stating that You changed the files; and
 99 | 
100 |       (c) You must retain, in the Source form of any Derivative Works
101 |           that You distribute, all copyright, patent, trademark, and
102 |           attribution notices from the Source form of the Work,
103 |           excluding those notices that do not pertain to any part of
104 |           the Derivative Works; and
105 | 
106 |       (d) If the Work includes a "NOTICE" text file as part of its
107 |           distribution, then any Derivative Works that You distribute must
108 |           include a readable copy of the attribution notices contained
109 |           within such NOTICE file, excluding those notices that do not
110 |           pertain to any part of the Derivative Works, in at least one
111 |           of the following places: within a NOTICE text file distributed
112 |           as part of the Derivative Works; within the Source form or
113 |           documentation, if provided along with the Derivative Works; or,
114 |           within a display generated by the Derivative Works, if and
115 |           wherever such third-party notices normally appear. The contents
116 |           of the NOTICE file are for informational purposes only and
117 |           do not modify the License. You may add Your own attribution
118 |           notices within Derivative Works that You distribute, alongside
119 |           or as an addendum to the NOTICE text from the Work, provided
120 |           that such additional attribution notices cannot be construed
121 |           as modifying the License.
122 | 
123 |       You may add Your own copyright statement to Your modifications and
124 |       may provide additional or different license terms and conditions
125 |       for use, reproduction, or distribution of Your modifications, or
126 |       for any such Derivative Works as a whole, provided Your use,
127 |       reproduction, and distribution of the Work otherwise complies with
128 |       the conditions stated in this License.
129 | 
130 |    5. Submission of Contributions. Unless You explicitly state otherwise,
131 |       any Contribution intentionally submitted for inclusion in the Work
132 |       by You to the Licensor shall be under the terms and conditions of
133 |       this License, without any additional terms or conditions.
134 |       Notwithstanding the above, nothing herein shall supersede or modify
135 |       the terms of any separate license agreement you may have executed
136 |       with Licensor regarding such Contributions.
137 | 
138 |    6. Trademarks. This License does not grant permission to use the trade
139 |       names, trademarks, service marks, or product names of the Licensor,
140 |       except as required for reasonable and customary use in describing the
141 |       origin of the Work and reproducing the content of the NOTICE file.
142 | 
143 |    7. Disclaimer of Warranty. Unless required by applicable law or
144 |       agreed to in writing, Licensor provides the Work (and each
145 |       Contributor provides its Contributions) on an "AS IS" BASIS,
146 |       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 |       implied, including, without limitation, any warranties or conditions
148 |       of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 |       PARTICULAR PURPOSE. You are solely responsible for determining the
150 |       appropriateness of using or redistributing the Work and assume any
151 |       risks associated with Your exercise of permissions under this License.
152 | 
153 |    8. Limitation of Liability. In no event and under no legal theory,
154 |       whether in tort (including negligence), contract, or otherwise,
155 |       unless required by applicable law (such as deliberate and grossly
156 |       negligent acts) or agreed to in writing, shall any Contributor be
157 |       liable to You for damages, including any direct, indirect, special,
158 |       incidental, or consequential damages of any character arising as a
159 |       result of this License or out of the use or inability to use the
160 |       Work (including but not limited to damages for loss of goodwill,
161 |       work stoppage, computer failure or malfunction, or any and all
162 |       other commercial damages or losses), even if such Contributor
163 |       has been advised of the possibility of such damages.
164 | 
165 |    9. Accepting Warranty or Additional Liability. While redistributing
166 |       the Work or Derivative Works thereof, You may choose to offer,
167 |       and charge a fee for, acceptance of support, warranty, indemnity,
168 |       or other liability obligations and/or rights consistent with this
169 |       License. However, in accepting such obligations, You may act only
170 |       on Your own behalf and on Your sole responsibility, not on behalf
171 |       of any other Contributor, and only if You agree to indemnify,
172 |       defend, and hold each Contributor harmless for any liability
173 |       incurred by, or claims asserted against, such Contributor by reason
174 |       of your accepting any such warranty or additional liability.
175 | 
176 |    END OF TERMS AND CONDITIONS
177 | 
178 |    APPENDIX: How to apply the Apache License to your work.
179 | 
180 |       To apply the Apache License to your work, attach the following
181 |       boilerplate notice, with the fields enclosed by brackets "[]"
182 |       replaced with your own identifying information. (Don't include
183 |       the brackets!)  The text should be enclosed in the appropriate
184 |       comment syntax for the file format. We also recommend that a
185 |       file or class name and description of purpose be included on the
186 |       same "printed page" as the copyright notice for easier
187 |       identification within third-party archives.
188 | 
189 |    Copyright [yyyy] [name of copyright owner]
190 | 
191 |    Licensed under the Apache License, Version 2.0 (the "License");
192 |    you may not use this file except in compliance with the License.
193 |    You may obtain a copy of the License at
194 | 
195 |        http://www.apache.org/licenses/LICENSE-2.0
196 | 
197 |    Unless required by applicable law or agreed to in writing, software
198 |    distributed under the License is distributed on an "AS IS" BASIS,
199 |    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 |    See the License for the specific language governing permissions and
201 |    limitations under the License.
202 | 


--------------------------------------------------------------------------------
/public/color-picker.js:
--------------------------------------------------------------------------------
1 | 
2 | /*! Pickr 1.2.1 MIT | https://github.com/Simonwep/pickr */
3 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pickr=e():t.Pickr=e()}(window,function(){return function(t){var e={};function o(n){if(e[n])return e[n].exports;var i=e[n]={i:n,l:!1,exports:{}};return t[n].call(i.exports,i,i.exports,o),i.l=!0,i.exports}return o.m=t,o.c=e,o.d=function(t,e,n){o.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},o.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},o.t=function(t,e){if(1&e&&(t=o(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(o.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var i in t)o.d(n,i,function(e){return t[e]}.bind(null,i));return n},o.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return o.d(e,"a",e),e},o.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},o.p="",o(o.s=0)}([function(t,e,o){"use strict";o.r(e);var n={};function i(t){for(var e=1;et)){function o(o){const n=[.001,.01,.1][Number(o.shiftKey||2*o.ctrlKey)]*(o.deltaY<0?1:-1);let i=0,r=t.selectionStart;t.value=t.value.replace(/[\d.]+/g,(t,o)=>o<=r&&o+t.length>=r?(r=o,e(Number(t),n,i)):(i++,t)),t.focus(),t.setSelectionRange(r,r),o.preventDefault(),t.dispatchEvent(new Event("input"))}s(t,"focus",()=>s(window,"wheel",o,{passive:!1})),s(t,"blur",()=>a(window,"wheel",o))}const{min:f,max:m,floor:v,round:y}=Math;function b(t,e,o){e/=100,o/=100;let n=v(t=t/360*6),i=t-n,r=o*(1-e),s=o*(1-i*e),a=o*(1-(1-i)*e),c=n%6;return[255*[o,s,r,r,a,o][c],255*[a,o,o,s,r,r][c],255*[r,r,a,o,o,s][c]]}function g(t,e,o){let n=(2-(e/=100))*(o/=100)/2;return 0!==n&&(e=1===n?0:n<.5?e*o/(2*n):e*o/(2-2*n)),[t,100*e,100*n]}function _(t,e,o){let n,i,r;const s=f(t/=255,e/=255,o/=255),a=m(t,e,o),c=a-s;if(0===c)n=i=0;else{i=c/a;let r=((a-t)/6+c/2)/c,s=((a-e)/6+c/2)/c,l=((a-o)/6+c/2)/c;t===a?n=l-s:e===a?n=1/3+r-l:o===a&&(n=2/3+s-r),n<0?n+=1:n>1&&(n-=1)}return[360*n,100*i,100*(r=a)]}function w(t,e,o,n){return e/=100,o/=100,[..._(255*(1-f(1,(t/=100)*(1-(n/=100))+n)),255*(1-f(1,e*(1-n)+n)),255*(1-f(1,o*(1-n)+n)))]}function k(t,e,o){return e/=100,[t,2*(e*=(o/=100)<.5?o:1-o)/(o+e)*100,100*(o+e)]}function C(t){return _(...t.match(/.{2}/g).map(t=>parseInt(t,16)))}function A(t){t=t.match(/^[a-zA-Z]+$/)?function(t){if("black"===t.toLowerCase())return"#000000";const e=document.createElement("canvas").getContext("2d");return e.fillStyle=t,"#000000"===e.fillStyle?null:e.fillStyle}(t):t;const e={cmyk:/^cmyk[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)/i,rgba:/^((rgba)|rgb)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]*?([\d.]+|$)/i,hsla:/^((hsla)|hsl)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]*?([\d.]+|$)/i,hsva:/^((hsva)|hsv)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]*?([\d.]+|$)/i,hexa:/^#?(([\dA-Fa-f]{3,4})|([\dA-Fa-f]{6})|([\dA-Fa-f]{8}))$/i},o=t=>t.map(t=>/^(|\d+)\.\d+|\d+$/.test(t)?Number(t):void 0);let n;t:for(const i in e){if(!(n=e[i].exec(t)))continue;const r=!!n[2];switch(i){case"cmyk":{let[,t,e,r,s]=o(n);if(t>100||e>100||r>100||s>100)break t;return{values:w(t,e,r,s),type:i}}case"rgba":{let[,,,t,e,s,a]=o(n);if(t>255||e>255||s>255||a<0||a>1||r===!a)break t;return{values:[..._(t,e,s),a],a:a,type:i}}case"hexa":{let[,t]=n;4!==t.length&&3!==t.length||(t=t.split("").map(t=>t+t).join(""));const e=t.substring(0,6);let o=t.substring(6);return o=o?parseInt(o,16)/255:void 0,{values:[...C(e),o],a:o,type:i}}case"hsla":{let[,,,t,e,s,a]=o(n);if(t>360||e>100||s>100||a<0||a>1||r===!a)break t;return{values:[...k(t,e,s),a],a:a,type:i}}case"hsva":{let[,,,t,e,s,a]=o(n);if(t>360||e>100||s>100||a<0||a>1||r===!a)break t;return{values:[t,e,s,a],a:a,type:i}}}}return{values:null,type:null}}function S(t=0,e=0,o=0,n=1){const i=(t,e)=>(o=-1)=>e(~o?t.map(t=>Number(t.toFixed(o))):t),r={h:t,s:e,v:o,a:n,toHSVA(){const t=[r.h,r.s,r.v,r.a];return t.toString=i(t,t=>"hsva(".concat(t[0],", ").concat(t[1],"%, ").concat(t[2],"%, ").concat(r.a,")")),t},toHSLA(){const t=[...g(r.h,r.s,r.v),r.a];return t.toString=i(t,t=>"hsla(".concat(t[0],", ").concat(t[1],"%, ").concat(t[2],"%, ").concat(r.a,")")),t},toRGBA(){const t=[...b(r.h,r.s,r.v),r.a];return t.toString=i(t,t=>"rgba(".concat(t[0],", ").concat(t[1],", ").concat(t[2],", ").concat(r.a,")")),t},toCMYK(){const t=function(t,e,o){const n=b(t,e,o),i=n[0]/255,r=n[1]/255,s=n[2]/255;let a,c,l,p;return[100*(c=1===(a=f(1-i,1-r,1-s))?0:(1-i-a)/(1-a)),100*(l=1===a?0:(1-r-a)/(1-a)),100*(p=1===a?0:(1-s-a)/(1-a)),100*a]}(r.h,r.s,r.v);return t.toString=i(t,t=>"cmyk(".concat(t[0],"%, ").concat(t[1],"%, ").concat(t[2],"%, ").concat(t[3],"%)")),t},toHEXA(){const t=function(t,e,o){return b(t,e,o).map(t=>y(t).toString(16).padStart(2,"0"))}(r.h,r.s,r.v),e=r.a>=1?"":Number((255*r.a).toFixed(0)).toString(16).toUpperCase().padStart(2,"0");return e&&t.push(e),t.toString=()=>"#".concat(t.join("").toUpperCase()),t},clone:()=>S(r.h,r.s,r.v,r.a)};return r}const O=t=>Math.max(Math.min(t,1),0);function L(t){const e={options:Object.assign({lock:null,onchange:()=>0},t),_tapstart(t){s(document,["mouseup","touchend","touchcancel"],e._tapstop),s(document,["mousemove","touchmove"],e._tapmove),t.preventDefault(),e._tapmove(t)},_tapmove(t){const{options:{lock:n},cache:i}=e,{element:r,wrapper:s}=o,a=s.getBoundingClientRect();let c=0,l=0;if(t){const e=t&&t.touches&&t.touches[0];c=t?(e||t).clientX:0,l=t?(e||t).clientY:0,ca.left+a.width&&(c=a.left+a.width),la.top+a.height&&(l=a.top+a.height),c-=a.left,l-=a.top}else i&&(c=i.x*a.width,l=i.y*a.height);"h"!==n&&(r.style.left="calc(".concat(c/a.width*100,"% - ").concat(r.offsetWidth/2,"px)")),"v"!==n&&(r.style.top="calc(".concat(l/a.height*100,"% - ").concat(r.offsetHeight/2,"px)")),e.cache={x:c/a.width,y:l/a.height};const p=O(c/s.offsetWidth),u=O(l/s.offsetHeight);switch(n){case"v":return o.onchange(p);case"h":return o.onchange(u);default:return o.onchange(p,u)}},_tapstop(){a(document,["mouseup","touchend","touchcancel"],e._tapstop),a(document,["mousemove","touchmove"],e._tapmove)},trigger(){e._tapmove()},update(t=0,o=0){const{left:n,top:i,width:r,height:s}=e.options.wrapper.getBoundingClientRect();"h"===e.options.lock&&(o=t),e._tapmove({clientX:n+r*t,clientY:i+s*o})},destroy(){const{options:t,_tapstart:o}=e;a([t.wrapper,t.element],"mousedown",o),a([t.wrapper,t.element],"touchstart",o,{passive:!1})}},{options:o,_tapstart:n}=e;return s([o.wrapper,o.element],"mousedown",n),s([o.wrapper,o.element],"touchstart",n,{passive:!1}),e}function j(t={}){t=Object.assign({onchange:()=>0,className:"",elements:[]},t);const e=s(t.elements,"click",e=>{t.elements.forEach(o=>o.classList[e.target===o?"add":"remove"](t.className)),t.onchange(e)});return{destroy:()=>a(...e)}}var E=({components:t,strings:e,useAsButton:o,inline:n,appClass:i,theme:r,lockOpacity:s})=>{const a=t=>t?"":'style="display:none" hidden',c=u('\n      
\n\n '.concat(o?"":'','\n\n
\n
\n
\n \n
\n
\n\n
\n
\n
\n
\n\n
\n
\n
\n
\n\n
\n
\n
\n
\n
\n\n
\n\n
\n \n\n \n \n \n \n \n\n \n \n \n
\n
\n
\n ")),l=c.interaction;return l.options.find(t=>!t.hidden&&!t.classList.add("active")),l.type=()=>l.options.find(t=>t.classList.contains("active")),c};function P(t,e,o){return e in t?Object.defineProperty(t,e,{value:o,enumerable:!0,configurable:!0,writable:!0}):t[e]=o,t}class B{constructor(t){P(this,"_initializingActive",!0),P(this,"_recalc",!0),P(this,"_color",S()),P(this,"_lastColor",S()),P(this,"_swatchColors",[]),P(this,"_eventListener",{swatchselect:[],change:[],save:[],init:[]}),this.options=t=Object.assign({appClass:null,theme:"classic",useAsButton:!1,disabled:!1,comparison:!0,closeOnScroll:!1,outputPrecision:0,lockOpacity:!1,autoReposition:!0,components:{interaction:{}},strings:{},swatches:null,inline:!1,sliders:null,default:"#42445a",defaultRepresentation:null,position:"bottom-middle",adjustableNumbers:!0,showAlways:!1,closeWithKey:"Escape"},t);const{swatches:e,inline:o,components:n,theme:i,sliders:r,lockOpacity:s}=t;["nano","monolith"].includes(i)&&!r&&(t.sliders="h"),n.interaction||(n.interaction={});const{preview:a,opacity:c,hue:l,palette:p}=n;n.opacity=!s&&c,n.palette=p||a||c||l,o&&(t.showAlways=!0),this._preBuild(),this._buildComponents(),this._bindEvents(),this._finalBuild(),e&&e.length&&e.forEach(t=>this.addSwatch(t)),this._nanopop=function({el:t,reference:e,padding:o=8}){const n={start:"sme",middle:"mse",end:"ems"},i={top:"tbrl",right:"rltb",bottom:"btrl",left:"lrbt"},r=((t={})=>(e,o=t[e])=>{if(o)return o;const[n,i="middle"]=e.split("-"),r="top"===n||"bottom"===n;return t[e]={position:n,variant:i,isVertical:r}})();return{update(s){const{position:a,variant:c,isVertical:l}=r(s),p=e.getBoundingClientRect(),u=t.getBoundingClientRect(),h=(t=>{let e=0,o=0;for(;t=t.parentElement;)e+=t.scrollTop,o+=t.scrollLeft;return{top:e,left:o}})(t),d=t=>t?{t:p.top-u.height-o+h.top,b:p.bottom+o+h.top}:{r:p.right+o+h.left,l:p.left-u.width-o+h.left},f=t=>t?{s:p.left+p.width-u.width+h.left,m:-u.width/2+(p.left+p.width/2)+h.left,e:p.left+h.left}:{s:p.bottom-u.height+h.top,m:p.bottom-p.height/2-u.height/2+h.top,e:p.bottom-p.height+h.top};function m(e,o,n){const i="top"===n,r=i?u.height:u.width,s=window[i?"innerHeight":"innerWidth"]+(i?h.top:h.left);for(const a of e){const e=o[a];if(e-(i?h.top:h.left)>0&&e+r>/g).reduce((t,e,o,n)=>(t=t.querySelector(e),o{const{sliders:e}=t.options;let o="v",n="v";e&&e.match(/^[vh]+$/g)&&(e.length>1?[o,n]=e:o=n=e);const i={v:"h",h:"v"};return[i[o],i[n]]})(),i={palette:L({element:t._root.palette.picker,wrapper:t._root.palette.palette,onchange(o,n){if(!e.palette)return;const{_color:i,_root:r,options:s}=t;t._recalc&&(i.s=100*o,i.v=100-100*n,i.v<0&&(i.v=0),t._updateOutput());let a=i.toRGBA().toString(0);this.element.style.background=a,this.wrapper.style.background="\n linear-gradient(to top, rgba(0, 0, 0, ".concat(i.a,"), transparent),\n linear-gradient(to left, hsla(").concat(i.h,", 100%, 50%, ").concat(i.a,"), rgba(255, 255, 255, ").concat(i.a,"))\n "),s.comparison||(r.button.style.color=a,s.useAsButton||(r.preview.lastColor.style.color=a)),r.preview.currentColor.style.color=a,t.options.comparison||r.button.classList.remove("clear")}}),hue:L({lock:n,element:t._root.hue.picker,wrapper:t._root.hue.slider,onchange(o){e.hue&&e.palette&&(t._recalc&&(t._color.h=360*o),this.element.style.backgroundColor="hsl(".concat(t._color.h,", 100%, 50%)"),i.palette.trigger())}}),opacity:L({lock:o,element:t._root.opacity.picker,wrapper:t._root.opacity.slider,onchange(o){e.opacity&&e.palette&&(t._recalc&&(t._color.a=Math.round(100*o)/100),this.element.style.background="rgba(0, 0, 0, ".concat(t._color.a,")"),i.palette.trigger())}}),selectable:j({elements:t._root.interaction.options,className:"active",onchange(e){t._representation=e.target.getAttribute("data-type").toUpperCase(),t._updateOutput()}})};this._components=i}_bindEvents(){const{_root:t,options:e}=this,o=[s(t.interaction.clear,"click",()=>this._clearColor()),s([t.interaction.cancel,t.preview.lastColor],"click",()=>this.setHSVA(...this._lastColor.toHSVA())),s(t.interaction.save,"click",()=>{!this.applyColor()&&!e.showAlways&&this.hide()}),s(t.interaction.result,["keyup","input"],t=>{this._recalc=!1,this.setColor(t.target.value,!0)&&!this._initializingActive&&this._emit("change",this._color),t.stopImmediatePropagation()}),s([t.palette.palette,t.palette.picker,t.hue.slider,t.hue.picker,t.opacity.slider,t.opacity.picker],["mousedown","touchstart"],()=>this._recalc=!0)];if(!e.showAlways){const n=e.closeWithKey;o.push(s(t.button,"click",()=>this.isOpen()?this.hide():this.show()),s(document,"keyup",t=>this.isOpen()&&(t.key===n||t.code===n)&&this.hide()),s(document,["touchstart","mousedown"],e=>{this.isOpen()&&!h(e).some(e=>e===t.app||e===t.button)&&this.hide()},{capture:!0}))}if(e.adjustableNumbers){const e={rgba:[255,255,255,1],hsva:[360,100,100,1],hsla:[360,100,100,1],cmyk:[100,100,100,100]};d(t.interaction.result,(t,o,n)=>{const i=e[this.getColorRepresentation().toLowerCase()];if(i){const e=i[n],r=t+(e>=100?1e3*o:o);return r<=0?0:Number((r{n.isOpen()&&(e.closeOnScroll&&n.hide(),null===t?(t=setTimeout(()=>t=null,100),requestAnimationFrame(function e(){n._rePositioningPicker(),null!==t&&requestAnimationFrame(e)})):(clearTimeout(t),t=setTimeout(()=>t=null,100)))},{capture:!0}))}this._eventBindings=o}_rePositioningPicker(){const{options:t}=this;t.inline||this._nanopop.update(t.position)}_updateOutput(){const{_root:t,_color:e,options:o}=this;if(t.interaction.type()){const n="to".concat(t.interaction.type().getAttribute("data-type"));t.interaction.result.value="function"==typeof e[n]?e[n]().toString(o.outputPrecision):""}!this._initializingActive&&this._recalc&&this._emit("change",e)}_clearColor(t=!1){const{_root:e,options:o}=this;o.useAsButton||(e.button.style.color="rgba(0, 0, 0, 0.15)"),e.button.classList.add("clear"),o.showAlways||this.hide(),this._initializingActive||t||this._emit("save",null)}_emit(t,...e){this._eventListener[t].forEach(t=>t(...e,this))}_parseLocalColor(t){const{values:e,type:o,a:n}=A(t),{lockOpacity:i}=this.options,r=void 0!==n&&1!==n;return e&&3===e.length&&(e[3]=void 0),{values:!e||i&&r?null:e,type:o}}on(t,e){return"function"==typeof e&&"string"==typeof t&&t in this._eventListener&&this._eventListener[t].push(e),this}off(t,e){const o=this._eventListener[t];if(o){const t=o.indexOf(e);~t&&o.splice(t,1)}return this}addSwatch(t){const{values:e}=this._parseLocalColor(t);if(e){const{_swatchColors:t,_root:o}=this,n=S(...e),i=l('