commit c14a6fe69649cb3e34f6db407eccaec6f780d846
parent 79465aa0ec3fbc5c9ef8e5a7a29b20b512af5ef0
Author: Milan Pässler <me@pbb.lc>
Date: Mon, 3 Jun 2019 19:21:38 +0200
parent 79465aa0ec3fbc5c9ef8e5a7a29b20b512af5ef0
Author: Milan Pässler <me@pbb.lc>
Date: Mon, 3 Jun 2019 19:21:38 +0200
add sw + unified websocket
16 files changed, 402 insertions(+), 287 deletions(-)
M
|
105
+++++++++++++++++++++++++++-----------------------------------------------------
M
|
140
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
diff --git a/.gitignore b/.gitignore @@ -1,4 +1,3 @@ node_modules -main.es.js +main.js main.min.js -main.es.min.js
diff --git a/index.html b/index.html @@ -15,7 +15,6 @@ body { <body> <smarthome-layout></smarthome-layout> <noscript>JavaScript is required to use Smart Home</noscript> - <script nomodule src="main.min.js"></script> - <script type="module" src="main.es.js"></script> + <script type="module" src="main.js"></script> </body> </html>
diff --git a/rollup.config.js b/rollup.config.js @@ -9,33 +9,11 @@ export default [ file: pkg.module, format: "es", strict: true, - file: "main.es.js", - sourceMap: "inline", - }, - ], - plugins: [ - resolve({ - sourceMap: true - }), - ], - }, - { - input: "src/index.js", - output: [ - { - file: pkg.module, - format: "es", - strict: true, - file: "main.es.min.js", + file: "main.js", }, ], plugins: [ resolve(), - terser({ - mangle: { - module: true, - }, - }), ], }, { @@ -43,7 +21,7 @@ export default [ output: [ { file: pkg.module, - format: "iife", + format: "es", strict: true, file: "main.min.js", },
diff --git a/src/card.js b/src/card.js @@ -1,3 +1,5 @@ +"use strict"; + import { LitElement, html, css } from "lit-element"; class Card extends LitElement {
diff --git a/src/index.js b/src/index.js @@ -1,129 +1,10 @@ -import { LitElement, html, css } from "lit-element"; +"use strict"; -import "@authentic/mwc-top-app-bar"; -import "@authentic/mwc-card"; -import "@authentic/mwc-icon-button"; -import "@authentic/mwc-icon"; -import "@authentic/mwc-drawer"; +import "./layout.js"; -import "./lights.js"; -import "./power-meter.js"; -import "./settings.js"; -import "./spinner.js"; -import "./row.js"; - -const luciRedirect = document.createElement("script"); -luciRedirect.innerHTML = "window.location = `http://${window.location.host}/cgi-bin/luci/`;"; - -const pages = { - "#/lights": { name: "Lights", content: html`<smarthome-lights></smarthome-lights>`, icon: "lightbulb" }, - "#/powermeter": { name: "Power Meter", content: html`<smarthome-power-meter></smarthome-power-meter>`, icon: "power" }, - "#/luci": { name: "LuCI", content: html`<smarthome-spinner></smarthome-spinner> ${luciRedirect}`, icon: "router" }, - "#/settings": { name: "Settings", content: html`<smarthome-settings></smarthome-settings>`, icon: "settings" }, -}; - -const notFoundPage = { name: "Sorry", content: html`<h3>The page you tried to access does not exist</h3>` }; -const defaultPage = pages["#/lights"]; - -class SmartHomeLayout extends LitElement { - constructor() { - super(...arguments); - window.addEventListener("hashchange", this._handleHashChange.bind(this)); - this._handleHashChange(); - } - - async _handleHashChange() { - if (!window.location.hash) { - this.activePage = defaultPage; - } else { - this.activePage = pages[window.location.hash] || notFoundPage; - } - await this.requestUpdate(); - const drawer = this.shadowRoot.querySelector("mwc-drawer"); - drawer.open = false; - } - - _handleNavEvent(evt) { - const drawer = this.shadowRoot.querySelector("mwc-drawer"); - drawer.open = !drawer.open; - } - - render() { - return html` - <mwc-drawer type="modal"> - <div id="drawer-content"> - ${Object.entries(pages).map(([path, page]) => html` - <a href="${path}" class="${this.activePage === page ? "active" : "inactive"}"> - <mwc-icon>${page.icon}</mwc-icon> - <span>${page.name}</span> - <mwc-ripple></mwc-ripple> - </a> - `)} - </div> - </mwc-drawer> - <mwc-top-app-bar type="fixed" @MDCTopAppBar:nav="${this._handleNavEvent}"> - <div id="title-container" slot="title"> - <span>${this.activePage.name}</span> - </div> - <mwc-icon-button slot="navigationIcon" icon="menu"></mwc-icon-button> - </mwc-top-app-bar> - <div class="top-app-bar-adjust">${this.activePage.content}</div> - `; - } - - static get styles() { - return css` - :host { - font-family: sans-serif; - --mdc-theme-on-primary: white; - --mdc-theme-primary: #43a047; - --mdc-theme-on-secondary: white; - --mdc-theme-secondary: #616161; - background-color: #ccc; - } - mwc-top-app-bar { - position: fixed; - } - .top-app-bar-adjust { - margin-top: 56px; - } - @media (min-width: 600px) { - .top-app-bar-adjust { - margin-top: 64px; - } - #title-container { - width: 100vw; - padding-left: calc((100% - 450px) / 2 + 20px); - } - mwc-icon-button { - position: absolute; - padding-left: calc(((100% - 450px) / 2) - 75px); - z-index: 10; - } - } - - #drawer-content { - display: flex; - flex-direction: column; - } - #drawer-content a { - text-decoration: none; - color: black; - display: flex; - flex-direction: row; - margin: 0; - align-items: center; - border-bottom: 1px solid #ccc; - } - #drawer-content a mwc-icon { - padding: .7em; - } - - #drawer-content a.active { - background-color: #ddd; - } - `; - } +const sw = navigator.serviceWorker; +if (sw) { + sw.register('sw.js', { + scope: './' + }); } - -customElements.define("smarthome-layout", SmartHomeLayout);
diff --git a/src/layout.js b/src/layout.js @@ -0,0 +1,131 @@ +"use strict"; + +import { LitElement, html, css } from "lit-element"; + +import "@authentic/mwc-top-app-bar"; +import "@authentic/mwc-card"; +import "@authentic/mwc-icon-button"; +import "@authentic/mwc-icon"; +import "@authentic/mwc-drawer"; + +import "./lights.js"; +import "./power-meter.js"; +import "./settings.js"; +import "./spinner.js"; +import "./row.js"; + +const luciRedirect = document.createElement("script"); +luciRedirect.innerHTML = "window.location = `http://${window.location.host}/cgi-bin/luci/`;"; + +const pages = { + "#/lights": { name: "Lights", content: html`<smarthome-lights></smarthome-lights>`, icon: "lightbulb" }, + "#/powermeter": { name: "Power Meter", content: html`<smarthome-power-meter></smarthome-power-meter>`, icon: "power" }, + "#/luci": { name: "LuCI", content: html`<smarthome-spinner></smarthome-spinner> ${luciRedirect}`, icon: "router" }, + "#/settings": { name: "Settings", content: html`<smarthome-settings></smarthome-settings>`, icon: "settings" }, +}; + +const notFoundPage = { name: "Sorry", content: html`<h3>The page you tried to access does not exist</h3>` }; +const defaultPage = pages["#/lights"]; + +class SmartHomeLayout extends LitElement { + constructor() { + super(...arguments); + window.addEventListener("hashchange", this._handleHashChange.bind(this)); + this._handleHashChange(); + } + + async _handleHashChange() { + if (!window.location.hash) { + this.activePage = defaultPage; + } else { + this.activePage = pages[window.location.hash] || notFoundPage; + } + await this.requestUpdate(); + const drawer = this.shadowRoot.querySelector("mwc-drawer"); + drawer.open = false; + } + + _handleNavEvent(evt) { + const drawer = this.shadowRoot.querySelector("mwc-drawer"); + drawer.open = !drawer.open; + } + + render() { + return html` + <mwc-drawer type="modal"> + <div id="drawer-content"> + ${Object.entries(pages).map(([path, page]) => html` + <a href="${path}" class="${this.activePage === page ? "active" : "inactive"}"> + <mwc-icon>${page.icon}</mwc-icon> + <span>${page.name}</span> + <mwc-ripple></mwc-ripple> + </a> + `)} + </div> + </mwc-drawer> + <mwc-top-app-bar type="fixed" @MDCTopAppBar:nav="${this._handleNavEvent}"> + <div id="title-container" slot="title"> + <span>${this.activePage.name}</span> + </div> + <mwc-icon-button slot="navigationIcon" icon="menu"></mwc-icon-button> + </mwc-top-app-bar> + <div class="top-app-bar-adjust">${this.activePage.content}</div> + `; + } + + static get styles() { + return css` + :host { + font-family: sans-serif; + --mdc-theme-on-primary: white; + --mdc-theme-primary: #43a047; + --mdc-theme-on-secondary: white; + --mdc-theme-secondary: #616161; + background-color: #ccc; + } + mwc-top-app-bar { + position: fixed; + } + .top-app-bar-adjust { + margin-top: 56px; + } + @media (min-width: 600px) { + .top-app-bar-adjust { + margin-top: 64px; + } + #title-container { + width: 100vw; + padding-left: calc((100% - 450px) / 2 + 20px); + } + mwc-icon-button { + position: absolute; + padding-left: calc(((100% - 450px) / 2) - 75px); + z-index: 10; + } + } + + #drawer-content { + display: flex; + flex-direction: column; + } + #drawer-content a { + text-decoration: none; + color: black; + display: flex; + flex-direction: row; + margin: 0; + align-items: center; + border-bottom: 1px solid #ccc; + } + #drawer-content a mwc-icon { + padding: .7em; + } + + #drawer-content a.active { + background-color: #ddd; + } + `; + } +} + +customElements.define("smarthome-layout", SmartHomeLayout);
diff --git a/src/lights.js b/src/lights.js @@ -1,4 +1,7 @@ +"use strict"; + import { LitElement, html, css } from "lit-element"; +import { state } from "./state.js"; import { Switch } from "@authentic/mwc-switch"; import "@authentic/mwc-circular-progress"; @@ -10,31 +13,7 @@ import { Row } from "./row.js"; class Lights extends LitElement { constructor() { super(...arguments); - this._initWebSocket(); - this.connected = false; - this.switches = []; - } - - _initWebSocket() { - this.ws = new WebSocket(`ws://${window.location.host}/relay/ws`); - this.ws.onopen = async () => { - this.connected = true; - this.requestUpdate(); - }; - this.ws.onclose = () => { - this.connected = false; - this.requestUpdate(); - this._initWebSocket(); - }; - this.ws.onmessage = (msg) => { - clearInterval(this.timeout); - this.timeout = setTimeout(() => { - this.ws.onclose(); - }, 4000); - if (!msg.data.length) return; // keepalive - this.switches = JSON.parse(msg.data); - this.requestUpdate(); - }; + state.subscribe(this.requestUpdate.bind(this)); } _clickHandler(evt) { @@ -42,19 +21,19 @@ class Lights extends LitElement { const row = evt.composedPath().filter(el => el instanceof Row)[0]; const sw = row.querySelector("mwc-switch"); sw.checked = !sw.checked; - this.ws.send(`set ${Number(sw.id)} ${sw.checked ? "on" : "off"}`); + state.ws.send(`set ${Number(sw.id)} ${sw.checked ? "on" : "off"}`); return true; } render() { return html` - ${this.connected ? html` + ${state.data.lights ? html` <smarthome-card> - ${this.switches.map(sw => html` + ${state.data.lights.map(sw => html` <smarthome-row @click="${this._clickHandler}"> <span slot="left">${sw.name}</span> <mwc-ripple slot="center"></mwc-ripple> - <mwc-switch slot="right" .id="${sw.id}" ?disabled="${!this.connected}" ?checked="${sw.value}"></mwc-switch> + <mwc-switch slot="right" .id="${sw.id}" ?disabled="${!state.connected}" ?checked="${sw.value}"></mwc-switch> </smarthome-row> `)} </smarthome-card>
diff --git a/src/power-meter.js b/src/power-meter.js @@ -1,92 +1,48 @@ +"use strict"; + import { LitElement, html, css } from "lit-element"; +import { state } from "./state.js"; import "@authentic/mwc-circular-progress"; import "@authentic/mwc-icon"; import "./card.js"; import "./spinner.js"; -const counters = [ - { id: 60, name: "Kueche" }, - { id: 50, name: "Sonstiges" }, -]; - const round = (num, places) => { const factor = Math.pow(10, places); return Math.round(num * factor) / factor; }; +const pad = (string, amount) => Array(amount).join('0').substr(0, amount - String(string).length) + string; + +const formatDate = (date) => ( + '' + + date.getFullYear() + + '/' + + pad(date.getMonth() + 1, 2) + + '/' + + pad(date.getDate(), 2) + + ' ' + + date.getHours() + + ':' + + pad(date.getMinutes(), 2) +); + class PowerMeter extends LitElement { constructor() { super(...arguments); - this.loaded = false; - this.connected = false; - this._loadData(); - this._initWebSocket(); - } - - _initWebSocket() { - this.ws = new WebSocket(`ws://${window.location.host}/gosdm/ws`); - this.ws.onopen = async () => { - this.connected = true; - this.requestUpdate(); - }; - this.ws.onclose = () => { - this.connected = false; - this.requestUpdate(); - this._initWebSocket(); - }; - this.ws.onmessage = (msg) => { - clearInterval(this.timeout); - this.timeout = setTimeout(() => { - this.ws.onclose(); - }, 2000); - - const m = JSON.parse(msg.data); - if (!m.IEC61850) return; - - const d = this.data[m.DeviceId]; - - if (m.IEC61850 === "Frequency") { - d.Frequency = m.Value; - } else if (m.IEC61850 === "VoltageL1") { - d.Voltage.L1 = m.Value; - } else if (m.IEC61850 === "CosphiL1") { - d.Cosphi.L1 = m.Value; - } else if (m.IEC61850 === "PowerL1") { - d.Power.L1 = m.Value; - } else if (m.IEC61850 === "Import") { - d.TotalImport = m.Value; - } - - this.requestUpdate(); - }; - } - - async _loadData() { - const newData = {}; - await Promise.all(counters.map(async (counter) => { - const d = await fetch(`http://${window.location.host}/gosdm/last/${counter.id}`) - .then(resp => resp.json()); - - newData[d.ModbusDeviceId] = { - ...d, - name: counter.name, - }; - })); - - this.data = newData; - this.loaded = true; - this.requestUpdate(); + state.subscribe(this.requestUpdate.bind(this)); } render() { return html` - ${this.loaded ? html` + ${state.data.powermeter ? html` <div id="connection-status"> - ${this.connected ? html` - <mwc-icon>cloud_queue</mwc-icon> Connected + ${state.connected ? html` + <p><mwc-icon>cloud_queue</mwc-icon> Connected</p> ` : html` - <mwc-icon>cloud_off</mwc-icon> Disconnected + <p><mwc-icon>cloud_off</mwc-icon> Disconnected</p> + <p class="lastupdate">last updated ${formatDate(state.data.lastUpdated)}</p> `} </div> <smarthome-card> @@ -99,7 +55,7 @@ class PowerMeter extends LitElement { <div class="table-column">Frequency</div> <div class="table-column">Import</div> </div> - ${Object.entries(this.data).map(([_, d]) => html` + ${Object.entries(state.data.powermeter).map(([_, d]) => html` <div class="table-row"> <div class="table-column">${d.name}</div> <div class="table-column">${round(d.Voltage.L1, 2)} V</div> @@ -128,14 +84,23 @@ class PowerMeter extends LitElement { --mdc-theme-secondary: #616161; } #connection-status mwc-icon { - padding: .5em; + padding-right: .5em; } #connection-status { display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + #connection-status>p { + display: flex; flex-direction: row; justify-content: center; align-items: center; } + #connection-status>p.lastupdate { + font-size: 1em; + } .table { display: flex; flex-direction: row;
diff --git a/src/row.js b/src/row.js @@ -1,3 +1,5 @@ +"use strict"; + import { LitElement, html, css } from "lit-element"; import "@authentic/mwc-ripple";
diff --git a/src/settings.js b/src/settings.js @@ -1,3 +1,5 @@ +"use strict"; + import { LitElement, html, css } from "lit-element"; import "@authentic/mwc-ripple";
diff --git a/src/spinner.js b/src/spinner.js @@ -1,3 +1,5 @@ +"use strict"; + import { LitElement, html, css } from "lit-element"; import "@authentic/mwc-circular-progress";
diff --git a/src/state.js b/src/state.js @@ -0,0 +1,53 @@ +"use strict"; + +class State { + constructor() { + this.connected = false; + this.data = JSON.parse(localStorage.getItem("data") || "{}"); + this.data.lastUpdated = new Date(this.data.lastUpdated); + this._subscribers = []; + this._initWS(); + } + + subscribe(callback) { + this._subscribers.push(callback); + this._updateSubscribers(); + } + + _setData(newData) { + this.data = newData; + this.data.lastUpdated = new Date(); + localStorage.setItem("data", JSON.stringify(this.data)); + this._updateSubscribers(); + } + + _updateSubscribers() { + for (let sub of this._subscribers) { + sub(); + } + } + + _initWS() { + this.ws = new WebSocket(`ws://192.168.1.1/relay/ws`); + this.ws.onclose = () => { + this.connected = false; + this._initWS(); + this._updateSubscribers(); + }; + this.ws.onopen = () => { + this.connected = true; + this._updateSubscribers(); + }; + this.ws.onmessage = (msg) => { + clearInterval(this._timeout); + this._timeout = setTimeout(() => { + this.ws.close(); + this.ws.onclose(); + }, 2000); + if (!msg.data.length) return; // keepalive + this._setData(JSON.parse(msg.data)); + }; + } +} + +export const state = new State();
diff --git a/sw.js b/sw.js @@ -0,0 +1,44 @@ +'use strict'; + +let preCache = [ + './', + './main.js', + './favicon-512x512.png', + './manifest.json', + 'https://fonts.googleapis.com/icon?family=Material+Icons', + 'https://fonts.gstatic.com/s/materialicons/v47/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2', +]; + +const CACHE = 'cache-v2'; + +self.addEventListener('install', function (evt) { + self.skipWaiting(); + evt.waitUntil(caches.open(CACHE).then(function (cache) { + cache.addAll(preCache); + })); +}); + +self.addEventListener('fetch', function (evt) { + evt.respondWith(fromCache(evt.request).then(function (match) { + if (match) { + return match; + } else { + return fetch(evt.request); + } + })); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(clients.claim()); + event.waitUntil(clients.claim().then(function () { + return caches.keys().then(function (cacheNames) { + return Promise.all(cacheNames.filter(c => c !== CACHE).map(c => caches.delete(c))); + }); + })); +}); + +function fromCache (request) { + return caches.open(CACHE).then(function (cache) { + return cache.match(request); + }); +}
diff --git a/websocket-relay/package.json b/websocket-relay/package.json @@ -1,6 +1,7 @@ { "dependencies": { "modbus-tcp": "^0.4.13", + "node-fetch": "^2.6.0", "ws": "^7.0.0" } }
diff --git a/websocket-relay/server.js b/websocket-relay/server.js @@ -1,29 +1,46 @@ "use strict"; -const net = require('net'); +const net = require("net"); const modbus = require("modbus-tcp"); -const WebSocket = require('ws'); +const WebSocket = require("ws"); +const fetch = require("node-fetch"); -const names = [ - "Deckenbeleuchtung", - "Bett", - "Kueche", - "Bad", -]; +const state = {}; + +function broadcastState() { + wss.broadcast(JSON.stringify(state)); +} -const ws = new WebSocket.Server({ port: 8080 }); +const wss = new WebSocket.Server({ port: 8080 }); -ws.broadcast = function broadcast(data) { - ws.clients.forEach(function each(client) { +wss.broadcast = function broadcast(data) { + wss.clients.forEach(function each(client) { if (client.readyState === WebSocket.OPEN) { client.send(data); } }); }; -ws.on('connection', function connection(ws) { +wss.on('connection', function connection(ws) { + ws.send(JSON.stringify(state)); +}); + +setInterval(function sendKeepalive() { + wss.broadcast(""); +}, 2000); + +/* lights */ + +const lights = [ + "Deckenbeleuchtung", + "Bett", + "Kueche", + "Bad", +]; + +wss.on('connection', function connection(ws) { ws.on('message', function incoming(message) { - let input = message.split(' '); + const input = String(message).split(' '); if (input[0] == 'set' && input[1] < 9 && (input[2] == 'on' || input[2] == 'off')) { let val = 0; @@ -31,20 +48,14 @@ ws.on('connection', function connection(ws) { if (input[2] == 'on') { val = 1; } - + setRelay(input[1], val); } - }); - - readRealys(ws); + }); }); -setInterval(function sendKeepalive() { - ws.broadcast(""); -}, 2000); - function mapToNames(data) { - return names.map((name, id) => { + return lights.map((name, id) => { return { name, id: id + 1, @@ -53,34 +64,95 @@ function mapToNames(data) { }); } -function readRealys(wss) { - let client = new modbus.Client(); - let socket = new net.Socket(); +function setRelay(id, val) { + const client = new modbus.Client(); + const socket = new net.Socket(); socket.connect({'host': '192.168.1.1', 'port': 502 }); client.pipe(socket); + client.writeSingleCoil(10, '10'+id, val); + client.readCoils(10, 101, 108, function (err, data){ - wss.send(JSON.stringify(mapToNames(data))); - }); + state.lights = mapToNames(data); + broadcastState(); + }) socket.end(); } -function setRelay(id, val) { - let client = new modbus.Client(); - let socket = new net.Socket(); +function readRelays(ws) { + const client = new modbus.Client(); + const socket = new net.Socket(); socket.connect({'host': '192.168.1.1', 'port': 502 }); client.pipe(socket); - client.writeSingleCoil(10, '10'+id, val); - client.readCoils(10, 101, 108, function (err, data){ - ws.broadcast(JSON.stringify(mapToNames(data))); - }) + state.lights = mapToNames(data); + }); socket.end(); } + +readRelays(); + +/* powermeter */ + +const counters = [ + { id: 60, name: "Kueche" }, + { id: 50, name: "Sonstiges" }, +]; + +let timeout; +let gosdm; + +async function initWS() { + state.powermeter = {}; + await Promise.all(counters.map(async function(counter) { + const d = await fetch(`http://127.0.0.1/gosdm/last/${counter.id}`) + .then(resp => resp.json()); + + state.powermeter[d.ModbusDeviceId] = { + ...d, + name: counter.name, + }; + })); + broadcastState(); + + gosdm = new WebSocket(`ws://127.0.0.1/gosdm/ws`); + gosdm.onclose = () => { + state.powermeter = undefined; + broadcastState(); + initWS(); + }; + gosdm.onmessage = (msg) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + gosdm.close(); + }, 5000); + + const m = JSON.parse(msg.data); + if (!m.IEC61850) return; + + const d = state.powermeter[m.DeviceId]; + + if (m.IEC61850 === "Frequency") { + d.Frequency = m.Value; + } else if (m.IEC61850 === "VoltageL1") { + d.Voltage.L1 = m.Value; + } else if (m.IEC61850 === "CosphiL1") { + d.Cosphi.L1 = m.Value; + } else if (m.IEC61850 === "PowerL1") { + d.Power.L1 = m.Value; + } else if (m.IEC61850 === "Import") { + d.TotalImport = m.Value; + } + + broadcastState(); + }; +} + +initWS();
diff --git a/websocket-relay/yarn.lock b/websocket-relay/yarn.lock @@ -12,6 +12,11 @@ modbus-tcp@^0.4.13: resolved "https://registry.yarnpkg.com/modbus-tcp/-/modbus-tcp-0.4.13.tgz#3e0e93af516d78a19f0f62686e64558e6ae6426b" integrity sha512-TytYObBTQhEMzjfFgGK3iESUoneH9WJRWHoZkt7PBuNnnx2nSOW0tOLC1YeQKNi/Qv7Xv0IjbVhxOoHyXOaV9g== +node-fetch@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" + integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== + ws@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/ws/-/ws-7.0.0.tgz#79351cbc3f784b3c20d0821baf4b4ff809ffbf51"