Compare commits
10 Commits
4ba7f21af8
...
8fcab2701e
Author | SHA1 | Date | |
---|---|---|---|
8fcab2701e | |||
3b1ec0ac3f | |||
e42d5241c0 | |||
5670557392 | |||
4a2644c071 | |||
c6242bb109 | |||
6e7aa7464e | |||
acccc9f458 | |||
025d560f89 | |||
1ada31a00c |
78
.github/workflows/docker-image.yml
vendored
78
.github/workflows/docker-image.yml
vendored
@ -4,66 +4,94 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build_multi_arch_image:
|
||||
name: Build multi-arch Docker image.
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DH_REGISTRY: docker.io
|
||||
GH_REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
REPOSITORY: ${{ github.event.repository.name }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set environment variables
|
||||
run: echo "GIT_BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
install: true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
registry: ${{ env.DH_REGISTRY }}
|
||||
username: ${{ env.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.GH_REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
- name: Set version variable
|
||||
run: |
|
||||
sed -i "s/const Version.*/const Version: string = '${{ github.head_ref }}.${{ github.sha }}';/" src/version.ts
|
||||
|
||||
- name: Extract metadata for release Docker image
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_USERNAME }}/${{ env.REPOSITORY }}
|
||||
${{ env.GH_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
- name: Extract metadata for develop Docker image
|
||||
if: "!startsWith(github.ref, 'refs/tags/v')"
|
||||
id: meta-develop
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.GH_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push master
|
||||
if: ${{ github.ref == 'refs/heads/master' }}
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: ${{ github.ref == 'refs/heads/master' && github.event_name == 'push' }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
${{ secrets.DOCKER_USERNAME }}/${{ env.REPOSITORY }}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Build and push release Docker image
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
target: deploy
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
"COMMIT_SHA=${{ github.sha }}"
|
||||
"BRANCH=${{ env.GIT_BRANCH }}"
|
||||
cache-from: type=gha,scope=${{ github.workflow }}
|
||||
cache-to: type=gha,mode=max,scope=${{ github.workflow }}
|
||||
|
||||
- name: Build and push develop Docker image
|
||||
if: "!startsWith(github.ref, 'refs/tags/v')"
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
target: deploy
|
||||
push: true
|
||||
tags: ${{ steps.meta-develop.outputs.tags }}
|
||||
labels: ${{ steps.meta-develop.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
@ -1,4 +1,5 @@
|
||||
FROM public.ecr.aws/docker/library/node:16-alpine3.14
|
||||
FROM node:lts-alpine AS base
|
||||
FROM base AS deploy
|
||||
|
||||
WORKDIR /usr/src/showpiece
|
||||
|
||||
@ -13,4 +14,4 @@ RUN npm run build
|
||||
USER node
|
||||
|
||||
EXPOSE 8080
|
||||
CMD [ "node", "index.js"]
|
||||
CMD [ "npm", "run", "start"]
|
||||
|
@ -5,7 +5,6 @@ body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: #000;
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
iframe {
|
||||
|
@ -1,5 +1,6 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var modulebtns = document.querySelectorAll('#modules button');
|
||||
var modulebtns = document.querySelectorAll('#modules button.module-btn');
|
||||
var expandbtns = document.querySelectorAll('#modules button.expand-btn');
|
||||
var urltxt = document.getElementById('urltxt');
|
||||
var submitbtn = document.getElementById('submitbtn');
|
||||
|
||||
@ -25,20 +26,47 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
});
|
||||
|
||||
modulebtns.forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
let mname = btn.querySelector('.module-name').innerText;
|
||||
fetch('api/load', {
|
||||
method: 'POST',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'module',
|
||||
body: mname
|
||||
})
|
||||
});
|
||||
function loadModule(event) {
|
||||
var mname = event.currentTarget.querySelector('.module-path').innerText;
|
||||
var inputs = event.currentTarget.parentElement.querySelectorAll('form input');
|
||||
var params = {};
|
||||
inputs.forEach(el => {
|
||||
if (el.type === 'checkbox') {
|
||||
params[el.name] = el.checked;
|
||||
}
|
||||
else {
|
||||
params[el.name] = el.value;
|
||||
}
|
||||
});
|
||||
fetch('api/load', {
|
||||
method: 'POST',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'module',
|
||||
body: `${mname}/?${new URLSearchParams(params).toString()}`
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
modulebtns.forEach(function (btn) {
|
||||
btn.addEventListener('click', loadModule);
|
||||
});
|
||||
|
||||
function toggleExpand(event) {
|
||||
const CLPSD = 'collapsed';
|
||||
var parent = event.currentTarget.parentElement;
|
||||
if (parent.classList.contains(CLPSD)) {
|
||||
parent.classList.remove(CLPSD);
|
||||
}
|
||||
else {
|
||||
parent.classList.add(CLPSD);
|
||||
}
|
||||
}
|
||||
|
||||
expandbtns.forEach(function (btn) {
|
||||
btn.addEventListener('click', toggleExpand);
|
||||
});
|
||||
});
|
29
index.js
29
index.js
@ -1,29 +0,0 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = fs.promises;
|
||||
const express = require('express');
|
||||
const json5 = require('json5');
|
||||
const client = require('./client');
|
||||
const server = require('./server');
|
||||
|
||||
|
||||
const configpath = process.env.CONFIG || path.join('config', 'config.json5');
|
||||
|
||||
(async () => {
|
||||
const configfile = json5.parse(await fsp.readFile(configpath));
|
||||
const port = process.env.PORT || configfile.port || 8080;
|
||||
const config ={
|
||||
port,
|
||||
controlport: process.env.CONTROLPORT || configfile.controlport || port,
|
||||
weather: configfile.weather
|
||||
};
|
||||
const c = new client(config);
|
||||
const s = new server(config);
|
||||
const app = (config.clientonly) ? express() : s.middleware();
|
||||
|
||||
app.set('trust proxy', 1);
|
||||
app.use('/', c.middleware());
|
||||
app.use('/assets/', express.static('assets'));
|
||||
|
||||
app.listen(config.port, () => console.log(`Showpiece listening to port ${config.port}`));
|
||||
})().catch(err => console.error(err));
|
13
modules/analog/module.json
Normal file
13
modules/analog/module.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Swanky Analog",
|
||||
"description": "This face has a pulse",
|
||||
"icon": "icon.png",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Color",
|
||||
"param": "color",
|
||||
"type": "color",
|
||||
"default": "#03A9F4"
|
||||
}
|
||||
]
|
||||
}
|
5
modules/contrast/module.json
Normal file
5
modules/contrast/module.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Contrast",
|
||||
"description": "This design is all business.",
|
||||
"icon": "icon.png"
|
||||
}
|
108
modules/weather/clock.js
Normal file
108
modules/weather/clock.js
Normal file
@ -0,0 +1,108 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const minute = 60000;
|
||||
const weatherInterval = 180000;
|
||||
const time = document.getElementById('time');
|
||||
const tempcontainer = document.getElementById('temps');
|
||||
const temp = document.getElementById('temp');
|
||||
const high = document.getElementById('high');
|
||||
const low = document.getElementById('low');
|
||||
const weatherdiv = document.getElementById('weatherdiv');
|
||||
var sun = null;
|
||||
var metric = true;
|
||||
var clock24h = true;
|
||||
var currentWeather = null;
|
||||
|
||||
function getParams() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
var string = urlParams.get('metric');
|
||||
metric = string === null || string === 'true';
|
||||
string = urlParams.get('24h');
|
||||
clock24h = string === null || string === 'true';
|
||||
}
|
||||
|
||||
function getHours(hours, mod) {
|
||||
const h = hours % mod;
|
||||
if (h === 0) {
|
||||
return mod;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function updateTime() {
|
||||
var d = new Date();
|
||||
var hours = d.getHours();
|
||||
var suffix = '';
|
||||
if (!clock24h) {
|
||||
suffix = ' ' + (Math.floor(hours / 12) ? 'PM' : 'AM');
|
||||
}
|
||||
|
||||
emptyElement(time).appendChild(document.createTextNode(`${('' + getHours(hours, clock24h ? 24 : 12)).padStart(clock24h ? 2 : 1, '0')}:${('' + d.getMinutes()).padStart(2, '0')}${suffix}`));
|
||||
resizeTemp();
|
||||
setDayNight(d);
|
||||
setTimeout(updateTime, minute - (d.getTime() % minute));
|
||||
}
|
||||
|
||||
function emptyElement(e) {
|
||||
while (e.firstChild) {
|
||||
e.removeChild(e.lastChild);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
function formatTemp(t) {
|
||||
if (metric) {
|
||||
t = (t - 32) / 1.8;
|
||||
}
|
||||
return `${Math.round(t)}°`;
|
||||
}
|
||||
|
||||
function resizeTemp() {
|
||||
tempcontainer.style.width = `${time.offsetWidth}px`;
|
||||
}
|
||||
|
||||
function setDayNight(d = new Date()) {
|
||||
if (sun) {
|
||||
document.body.classList.forEach(function (c) {
|
||||
document.body.classList.remove(c);
|
||||
});
|
||||
document.body.classList.add((d >= sun.sunrise && d < sun.sunset) ? 'day' : 'night');
|
||||
}
|
||||
}
|
||||
|
||||
function getWeather() {
|
||||
fetch('/api/all-weather')
|
||||
.then(resp => resp.json())
|
||||
.then(weather => {
|
||||
currentWeather = weather.list[0];
|
||||
weatherdiv.classList.forEach(function (c) {
|
||||
weatherdiv.classList.remove(c);
|
||||
});
|
||||
currentWeather.weather.forEach(function (w) {
|
||||
weatherdiv.classList.add(w.main);
|
||||
});
|
||||
emptyElement(temp).appendChild(document.createTextNode(formatTemp(currentWeather.main.temp)));
|
||||
emptyElement(high).appendChild(document.createTextNode(formatTemp(currentWeather.main.temp_max) + '⭎'));
|
||||
emptyElement(low).appendChild(document.createTextNode(formatTemp(currentWeather.main.temp_min) + '⭏'));
|
||||
});
|
||||
}
|
||||
|
||||
function getSun() {
|
||||
fetch('/api/sun')
|
||||
.then(resp => resp.json())
|
||||
.then(s => {
|
||||
sun = {
|
||||
sunrise: new Date(s.sunrise),
|
||||
sunset: new Date(s.sunset)
|
||||
}
|
||||
setDayNight();
|
||||
});
|
||||
}
|
||||
|
||||
getParams();
|
||||
getSun();
|
||||
updateTime();
|
||||
getWeather();
|
||||
window.addEventListener('resize', resizeTemp);
|
||||
setInterval(getWeather, weatherInterval);
|
||||
setInterval(getSun, weatherInterval);
|
||||
});
|
BIN
modules/weather/icon.png
Normal file
BIN
modules/weather/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
32
modules/weather/index.html
Normal file
32
modules/weather/index.html
Normal file
@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
|
||||
|
||||
<head>
|
||||
<title>Weather Clock</title>
|
||||
<link rel="stylesheet" href="/assets/css/reset.css">
|
||||
<link rel="stylesheet" href="style.css?v1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="clock.js"></script>
|
||||
<script src="https://pagecdn.io/lib/pixi/6.2.0/browser/pixi.min.js" crossorigin="anonymous" ></script>
|
||||
<script src="weather-fx.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="weatherdiv">
|
||||
<div id="container">
|
||||
<div class="center">
|
||||
<span id="time">
|
||||
</span>
|
||||
</div>
|
||||
<div id="temps">
|
||||
<span id="temp"></span>
|
||||
<div class="f-right">
|
||||
<span id="high"></span>
|
||||
<span id="low"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
19
modules/weather/module.json
Normal file
19
modules/weather/module.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Weather Glass",
|
||||
"description": "See live weather effects",
|
||||
"icon": "icon.png",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Metric Units",
|
||||
"param": "metric",
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "24 Hour Clock",
|
||||
"param": "24h",
|
||||
"type": "bool",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
71
modules/weather/style.css
Normal file
71
modules/weather/style.css
Normal file
@ -0,0 +1,71 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fjalla+One&family=Inconsolata:wght@200&display=swap');
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
font-family: 'Fjalla One', sans-serif;
|
||||
font-family: 'Inconsolata', monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #0277BD;
|
||||
transition: background-color 10s;
|
||||
}
|
||||
|
||||
body.day {
|
||||
background-color: #039BE5;
|
||||
}
|
||||
|
||||
body.night {
|
||||
background-color: #311B92;
|
||||
}
|
||||
|
||||
body * {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
.f-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#container {
|
||||
margin: auto;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 21vh;
|
||||
}
|
||||
|
||||
#time {
|
||||
font-size: 20vh;
|
||||
line-height: 17.5vh;
|
||||
}
|
||||
|
||||
#temps {
|
||||
margin: auto;
|
||||
font-size: 4vh;
|
||||
}
|
||||
|
||||
#weatherdiv{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
canvas{
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
136
modules/weather/weather-fx.js
Normal file
136
modules/weather/weather-fx.js
Normal file
@ -0,0 +1,136 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const weatherdiv = document.getElementById('weatherdiv');
|
||||
const pixi = new PIXI.Application({ transparent: true });
|
||||
weatherdiv.appendChild(pixi.view);
|
||||
const sprites = [];
|
||||
const snowflakeG = new PIXI.Graphics();
|
||||
snowflakeG.lineStyle(0);
|
||||
snowflakeG.beginFill(0xFFFFFF, 1);
|
||||
snowflakeG.drawCircle(0, 0, 3);
|
||||
snowflakeG.endFill();
|
||||
const drizzleG = new PIXI.Graphics();
|
||||
drizzleG.lineStyle(3, 0xFFFFFF, .6);
|
||||
drizzleG.lineTo(-3, 15);
|
||||
drizzleG.closePath();
|
||||
const rainG = new PIXI.Graphics();
|
||||
rainG.lineStyle(3, 0xFFFFFF, .7);
|
||||
rainG.lineTo(-7.5, 37.5);
|
||||
rainG.closePath();
|
||||
const fogG = new PIXI.Graphics();
|
||||
fogG.lineStyle(0);
|
||||
fogG.beginFill(0x607D8B, .5);
|
||||
fogG.drawCircle(0, 0, 50);
|
||||
fogG.endFill();
|
||||
const cloudMaxOpacity = .8;
|
||||
const cloudG = new PIXI.Graphics();
|
||||
cloudG.lineStyle(0);
|
||||
cloudG.beginFill(0xECEFF1, 1);
|
||||
cloudG.drawCircle(0, 0, 50);
|
||||
cloudG.endFill();
|
||||
var lastFrame = (new Date()).getTime();
|
||||
var lastGen = {
|
||||
snow: lastFrame,
|
||||
rain: lastFrame,
|
||||
fog: lastFrame,
|
||||
cloud: lastFrame
|
||||
};
|
||||
let count = 0;
|
||||
|
||||
pixi.ticker.add((delta) => {
|
||||
count++;
|
||||
let t = (new Date()).getTime();
|
||||
let weather = weatherdiv.classList;
|
||||
if (weather.contains('Snow')) {
|
||||
if (t - lastGen.snow >= 100000 / pixi.view.width) {
|
||||
let g = new PIXI.Graphics(snowflakeG.geometry);
|
||||
pixi.stage.addChild(g);
|
||||
sprites.push({
|
||||
type: 'snow',
|
||||
x: Math.random() * pixi.view.width,
|
||||
y: -10,
|
||||
speedx: -.4,
|
||||
speedy: 2,
|
||||
graphic: g
|
||||
});
|
||||
lastGen.snow = t;
|
||||
}
|
||||
}
|
||||
if (weather.contains('Rain') || weather.contains('Drizzle') || weather.contains('Thunderstorm')) {
|
||||
let drizzle = weather.contains('Drizzle');
|
||||
if (t - lastGen.rain >= (drizzle ? 50000 : 20000) / pixi.view.width) {
|
||||
let g = new PIXI.Graphics((drizzle ? drizzleG : rainG).geometry);
|
||||
pixi.stage.addChild(g);
|
||||
let variance = Math.random() / 2 + 1;
|
||||
sprites.push({
|
||||
type: 'rain',
|
||||
x: Math.random() * pixi.view.width,
|
||||
y: -20,
|
||||
mag: drizzle ? 1.5 : 3,
|
||||
speedx: -1.6 * (drizzle ? 1 : 3) * variance,
|
||||
speedy: 8 * (drizzle ? 1 : 3) * variance,
|
||||
graphic: g
|
||||
});
|
||||
lastGen.rain = t;
|
||||
}
|
||||
}
|
||||
if (weather.contains('Clouds') || weather.contains('Fog') || weather.contains('Mist') || weather.contains('Haze') || weather.contains('Dust') || weather.contains('Smoke')) {
|
||||
let fog = weather.contains('Fog') || weather.contains('Haze') || weather.contains('Dust');
|
||||
if (t - lastGen.cloud >= 300000 / pixi.view.width && true) {
|
||||
let g = new PIXI.Graphics((fog ? fogG : cloudG).geometry);
|
||||
g.alpha = 0;
|
||||
pixi.stage.addChild(g);
|
||||
let variance = Math.random() * .8 + .3;
|
||||
sprites.push({
|
||||
type: 'cloud',
|
||||
x: Math.random() * pixi.view.width,
|
||||
y: Math.random() * (pixi.view.height * .20) + 30,
|
||||
speedx: .5 * variance * (Math.random() >= .5 ? 1 : -1),
|
||||
speedy: 0,
|
||||
dying: false,
|
||||
countdown: 0,
|
||||
graphic: g
|
||||
});
|
||||
lastGen.cloud = t;
|
||||
}
|
||||
}
|
||||
sprites.forEach(function (s, index) {
|
||||
s.graphic.x = s.x;
|
||||
s.graphic.y = s.y;
|
||||
if (s.type === 'snow') {
|
||||
s.speedx = Math.min(Math.max(s.speedx + (Math.floor(Math.random() * 2) ? .04 : -.04), -1), 1);
|
||||
}
|
||||
else if (s.type === 'cloud') {
|
||||
if (s.dying) {
|
||||
s.graphic.alpha = Math.min((s.countdown -= delta / 100), cloudMaxOpacity);
|
||||
}
|
||||
else {
|
||||
s.graphic.alpha = Math.min((s.countdown += delta / 100), cloudMaxOpacity);
|
||||
s.dying = s.countdown >= 5;
|
||||
}
|
||||
}
|
||||
s.x += s.speedx * delta;
|
||||
let wrapTolerance = (s.type === 'snow' || s.type === 'rain') ? 3 : 50;
|
||||
if (s.x < -wrapTolerance) {
|
||||
s.x = pixi.view.width - s.x;
|
||||
}
|
||||
else if (s.x > pixi.view.width + wrapTolerance) {
|
||||
s.x = -wrapTolerance;
|
||||
}
|
||||
s.y += s.speedy * delta;
|
||||
if (s.y > pixi.view.height + 15 || s.graphic.alpha <= 0) {
|
||||
s.graphic.destroy();
|
||||
sprites.splice(index, 1);
|
||||
}
|
||||
});
|
||||
lastFrame = t;
|
||||
});
|
||||
|
||||
function resizeCanvas() {
|
||||
pixi.renderer.resize(window.innerWidth, window.innerHeight);
|
||||
pixi.view.width = window.innerWidth;
|
||||
pixi.view.height = window.innerHeight;
|
||||
}
|
||||
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
});
|
5198
package-lock.json
generated
5198
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "showpiece",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.1",
|
||||
"description": "A simple digital signage solution built on the web",
|
||||
"main": "index.js",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/CorySanin/showpiece.git"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node --experimental-strip-types src/index.ts",
|
||||
"build": "tailwindcss -i ./styles/control.css -o ./assets/css/control.css",
|
||||
"watch": "tailwindcss -i ./styles/control.css -o ./assets/css/control.css --watch"
|
||||
},
|
||||
@ -17,15 +19,22 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cicciosgamino/openweather-apis": "^5.0.1",
|
||||
"ejs": "3.1.6",
|
||||
"express": "^4.17.2",
|
||||
"express-ws": "5.0.2",
|
||||
"json5": "^2.2.0",
|
||||
"phin": "3.6.1"
|
||||
"dayjs": "1.11.13",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "5.1.0",
|
||||
"express-ws": "^5.0.2",
|
||||
"json5": "^2.2.3",
|
||||
"ky": "^1.8.1",
|
||||
"openweathermap-ts": "1.2.10",
|
||||
"node-cache": "5.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.0.7"
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-ws": "3.0.5",
|
||||
"@types/node": "^22.10.5",
|
||||
"@tailwindcss/cli": "4.1.8",
|
||||
"typescript": "5.8.3",
|
||||
"tailwindcss": "4.1.8"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/CorySanin/showpiece/issues"
|
||||
|
116
server.js
116
server.js
@ -1,116 +0,0 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = fs.promises;
|
||||
const express = require('express');
|
||||
const ExpressWS = require('express-ws');
|
||||
const version = require('./version');
|
||||
|
||||
const VIEWOPTIONS = {
|
||||
outputFunctionName: 'echo'
|
||||
};
|
||||
const MODULES = 'modules';
|
||||
|
||||
class Server {
|
||||
constructor(options = {}) {
|
||||
const app = this._app = express();
|
||||
const controlapp = this._controlapp = express();
|
||||
controlapp.use(express.json());
|
||||
const clients = this._clients = [];
|
||||
this._current = {
|
||||
type: 'none',
|
||||
body: null
|
||||
}
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('view options', VIEWOPTIONS);
|
||||
controlapp.set('view engine', 'ejs');
|
||||
controlapp.set('view options', VIEWOPTIONS);
|
||||
ExpressWS(app);
|
||||
|
||||
app.ws('/control', (ws, req) => {
|
||||
clients.push(ws);
|
||||
console.log(`new client connected: ${req.ip}`);
|
||||
this.send(this._current, [ws]);
|
||||
this.send({
|
||||
type: 'version',
|
||||
body: version
|
||||
}, [ws]);
|
||||
ws.on('close', (ws) => {
|
||||
clients.splice(clients.indexOf(ws), 1);
|
||||
console.log(`client disconnected: ${req.ip}`);
|
||||
});
|
||||
});
|
||||
|
||||
controlapp.post('/api/load', (req, res) => {
|
||||
let body = req.body;
|
||||
if ('type' in body) {
|
||||
this.send(this._current = body);
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
controlapp.get('/', async (req, res) => {
|
||||
res.redirect(`${req.baseUrl}/load`);
|
||||
});
|
||||
|
||||
controlapp.get('/load', async (req, res) => {
|
||||
res.render('load', {
|
||||
modules: await this.getModules()
|
||||
}, (err, html) => {
|
||||
if (!err) {
|
||||
res.send(html);
|
||||
}
|
||||
else {
|
||||
res.send(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.use(`/${MODULES}/`, express.static(MODULES));
|
||||
|
||||
if (options.port !== options.controlport) {
|
||||
controlapp.use('/assets/', express.static('assets'));
|
||||
controlapp.use(`/${MODULES}/`, express.static(MODULES));
|
||||
controlapp.listen(options.controlport, () => console.log(`Showpiece listening to port ${options.controlport}`));
|
||||
}
|
||||
else {
|
||||
app.use('/control/', controlapp);
|
||||
}
|
||||
}
|
||||
|
||||
send(payload, clients = this._clients) {
|
||||
clients.forEach((ws, index) => {
|
||||
try {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
catch {
|
||||
clients.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getModules = async () => {
|
||||
let dirs = (await fsp.readdir(MODULES, { withFileTypes: true })).filter(dirent => !dirent.isFile());
|
||||
let result = [];
|
||||
for (const index in dirs) {
|
||||
const dir = dirs[index];
|
||||
let obj = {
|
||||
name: dir.name
|
||||
};
|
||||
try {
|
||||
let icon = path.join(MODULES, dir.name, 'icon.png');
|
||||
await fsp.stat(icon);
|
||||
obj.icon = `/${icon}`;
|
||||
}
|
||||
catch {
|
||||
obj.icon = null;
|
||||
}
|
||||
result.push(obj);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
middleware() {
|
||||
return this._app;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Server;
|
@ -1,21 +1,24 @@
|
||||
const express = require('express');
|
||||
const weather = require('./weather');
|
||||
const sun = require('./sunrise-sunset');
|
||||
const version = require('./version');
|
||||
import express from 'express';
|
||||
import SunriseSunset from './sunrise-sunset.ts';
|
||||
import Weather from './weather.ts';
|
||||
import Version from './version.ts';
|
||||
import type { AppOptions } from './index.ts';
|
||||
|
||||
const VIEWOPTIONS = {
|
||||
outputFunctionName: 'echo'
|
||||
};
|
||||
|
||||
class Client {
|
||||
constructor(options = {}) {
|
||||
const app = this._app = express();
|
||||
private app: express.Application;
|
||||
constructor(options: AppOptions = {}) {
|
||||
const app: express.Application = this.app = express();
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('view options', VIEWOPTIONS);
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
app.get('/', (_, res) => {
|
||||
res.render('client', {}, (err, html) => {
|
||||
if (!err) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.send(html);
|
||||
}
|
||||
else {
|
||||
@ -26,7 +29,7 @@ class Client {
|
||||
|
||||
app.get('/client.js', (req, res) => {
|
||||
res.render('clientjs', {
|
||||
version
|
||||
Version
|
||||
}, (err, js) => {
|
||||
if (!err) {
|
||||
res.type('text/javascript').send(js);
|
||||
@ -38,30 +41,30 @@ class Client {
|
||||
});
|
||||
|
||||
if(options.weather && options.weather.key){
|
||||
let w = new weather();
|
||||
w.prepareApi(options.weather).then(async () => {
|
||||
let results = await w.getAllWeather();
|
||||
let s = new sun({
|
||||
latitude: results.coord.lat,
|
||||
longitude: results.coord.lon
|
||||
const w = new Weather(options.weather);
|
||||
w.getAllWeather().then((resp) => {
|
||||
const s = new SunriseSunset({
|
||||
latitude: resp.city.coord.lat,
|
||||
longitude: resp.city.coord.lon
|
||||
});
|
||||
app.get('/api/sun', async (req, res) => {
|
||||
app.get('/api/sun', async (_, res) => {
|
||||
res.send(await s.getSunrise());
|
||||
});
|
||||
});
|
||||
app.get('/api/weather', async (req, res) => {
|
||||
app.get('/api/weather', async (_, res) => {
|
||||
res.send(await w.getSimpleWeather());
|
||||
});
|
||||
|
||||
app.get('/api/all-weather', async (req, res) => {
|
||||
app.get('/api/all-weather', async (_, res) => {
|
||||
res.send(await w.getAllWeather());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
middleware() {
|
||||
return this._app;
|
||||
middleware(): express.Application {
|
||||
return this.app;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Client;
|
||||
export default Client;
|
||||
export { Client };
|
32
src/index.ts
Normal file
32
src/index.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import path from 'path';
|
||||
import fsp from 'fs/promises';
|
||||
import express from 'express';
|
||||
import json5 from 'json5';
|
||||
import Client from './client.ts';
|
||||
import Server from './server.ts';
|
||||
import type { WeatherConfig } from "./weather.ts";
|
||||
|
||||
interface AppOptions {
|
||||
port?: number;
|
||||
controlport?: number;
|
||||
modulesDir?: string;
|
||||
weather?: WeatherConfig;
|
||||
clientonly?: boolean
|
||||
}
|
||||
|
||||
const configpath = process.env.CONFIG || path.join('config', 'config.json5');
|
||||
|
||||
const configfile = json5.parse((await fsp.readFile(configpath)).toString());
|
||||
configfile.port = process.env.PORT || configfile.port || 8080;
|
||||
configfile.controlport = process.env.CONTROLPORT || configfile.controlport || configfile.port;
|
||||
const c = new Client(configfile);
|
||||
const s = new Server(configfile);
|
||||
const app = (configfile?.clientonly) ? express() : s.middleware();
|
||||
app.set('trust proxy', 1);
|
||||
app.use('/', c.middleware());
|
||||
(app as express.Application).use('/assets/', express.static('assets'));
|
||||
process.on('SIGTERM', app.listen(configfile.port, () => {
|
||||
console.log(`Showpiece listening to port ${configfile.port}`);
|
||||
}).close);
|
||||
|
||||
export type { AppOptions }
|
160
src/server.ts
Normal file
160
src/server.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import * as http from "http";
|
||||
import path from 'path';
|
||||
import fsp from 'fs/promises';
|
||||
import json5 from 'json5';
|
||||
import express from 'express';
|
||||
import ExpressWS from 'express-ws';
|
||||
import Version from './version.ts';
|
||||
import type WebSocket from 'ws';
|
||||
import type { AppOptions } from './index.ts';
|
||||
|
||||
const VIEWOPTIONS = {
|
||||
outputFunctionName: 'echo'
|
||||
};
|
||||
|
||||
interface Payload {
|
||||
type: 'none' | 'version';
|
||||
body: null | string;
|
||||
}
|
||||
|
||||
interface Scene extends Payload {
|
||||
type: 'none';
|
||||
body: null | string;
|
||||
}
|
||||
|
||||
interface ModuleParam {
|
||||
name: string,
|
||||
param: string,
|
||||
type: "color" | "bool",
|
||||
default?: string | boolean
|
||||
}
|
||||
|
||||
interface Module {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
parameters?: ModuleParam[]
|
||||
path: string;
|
||||
}
|
||||
|
||||
class Server {
|
||||
private app: ExpressWS.Application;
|
||||
private current: Scene;
|
||||
private clients: WebSocket[];
|
||||
private modulesDir: string;
|
||||
private webservers: http.Server[] = [];
|
||||
constructor(options: AppOptions = {}) {
|
||||
this.modulesDir = options.modulesDir || process.env['MODULESDIR'] || path.join('.', 'modules');
|
||||
const app = express();
|
||||
const controlapp = express();
|
||||
controlapp.use(express.json());
|
||||
const clients: WebSocket[] = this.clients = [];
|
||||
this.current = {
|
||||
type: 'none',
|
||||
body: null
|
||||
}
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('view options', VIEWOPTIONS);
|
||||
controlapp.set('view engine', 'ejs');
|
||||
controlapp.set('view options', VIEWOPTIONS);
|
||||
this.app = ExpressWS(app).app;
|
||||
|
||||
this.app.ws('/control', (ws, req) => {
|
||||
clients.push(ws);
|
||||
console.log(`new client connected: ${req.ip}`);
|
||||
this.send(this.current, [ws]);
|
||||
this.send({
|
||||
type: 'version',
|
||||
body: Version
|
||||
}, [ws]);
|
||||
ws.on('close', (_) => {
|
||||
clients.splice(clients.indexOf(ws), 1);
|
||||
console.log(`client disconnected: ${req.ip}`);
|
||||
});
|
||||
});
|
||||
|
||||
controlapp.post('/api/load', (req, res) => {
|
||||
let body = req.body;
|
||||
if ('type' in body) {
|
||||
this.send(this.current = body);
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
controlapp.get('/', async (req, res) => {
|
||||
res.redirect(`${req.baseUrl}/load`);
|
||||
});
|
||||
|
||||
controlapp.get('/load', async (req, res) => {
|
||||
res.render('load', {
|
||||
modules: await this.getModules()
|
||||
}, (err, html) => {
|
||||
if (!err) {
|
||||
res.send(html);
|
||||
}
|
||||
else {
|
||||
res.send(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.use(`/modules/`, express.static(this.modulesDir));
|
||||
|
||||
if (options.port !== options.controlport) {
|
||||
controlapp.use('/assets/', express.static('assets'));
|
||||
controlapp.use(`/modules/`, express.static(this.modulesDir));
|
||||
this.webservers.push(controlapp.listen(options.controlport, () => console.log(`Showpiece listening to port ${options.controlport}`)));
|
||||
}
|
||||
else {
|
||||
app.use('/control/', controlapp);
|
||||
}
|
||||
}
|
||||
|
||||
send(payload: Payload, clients: WebSocket[] = this.clients) {
|
||||
clients.forEach((ws, index) => {
|
||||
try {
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
catch {
|
||||
clients.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getModules (): Promise<Module[]> {
|
||||
const dirs = (await fsp.readdir(this.modulesDir, { withFileTypes: true })).filter(dirent => !dirent.isFile());
|
||||
const result: Module[] = [];
|
||||
for (const index in dirs) {
|
||||
const dir = dirs[index];
|
||||
let obj: Module = {
|
||||
name: dir.name,
|
||||
path: dir.name
|
||||
};
|
||||
try {
|
||||
obj = json5.parse((await fsp.readFile(path.join(this.modulesDir, dir.name, 'module.json'))).toString());
|
||||
if (obj.icon) {
|
||||
obj.icon = `/${path.join(this.modulesDir, dir.name, obj.icon)}`;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
console.error(`Module metadata for ${dir.name} could not be loaded`);
|
||||
}
|
||||
obj.path = dir.name;
|
||||
result.push(obj);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
middleware(): ExpressWS.Application {
|
||||
return this.app;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.webservers.forEach(s => {
|
||||
s.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Server;
|
||||
export { Server };
|
||||
export type { Payload, Scene, Module, ModuleParam };
|
60
src/sunrise-sunset.ts
Normal file
60
src/sunrise-sunset.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import ky from 'ky';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface Location {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
interface SunriseResponse {
|
||||
results: SunriseResults | "";
|
||||
status: "OK" | "INVALID_REQUEST";
|
||||
tzid?: string;
|
||||
}
|
||||
|
||||
interface SunriseResults {
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
solar_noon: string;
|
||||
day_length: number;
|
||||
civil_twilight_begin: string;
|
||||
civil_twilight_end: string;
|
||||
nautical_twilight_begin: string;
|
||||
nautical_twilight_end: string;
|
||||
astronomical_twilight_begin: string;
|
||||
astronomical_twilight_end: string;
|
||||
}
|
||||
|
||||
class SunriseSunset {
|
||||
private location: Location
|
||||
private state: SunriseResults;
|
||||
private expiration: dayjs.Dayjs;
|
||||
|
||||
constructor(location: Location) {
|
||||
this.location = location;
|
||||
this.invalidateCache();
|
||||
}
|
||||
|
||||
invalidateCache(): void {
|
||||
this.expiration = dayjs().subtract(1, 'day');
|
||||
}
|
||||
|
||||
async getSunrise(): Promise<SunriseResults> {
|
||||
const now = dayjs();
|
||||
if (now.isAfter(this.expiration)) {
|
||||
console.log('Calling Sunrise API');
|
||||
const resp: SunriseResponse = await ky.get(`https://api.sunrise-sunset.org/json?lat=${this.location.latitude}&lng=${this.location.longitude}&date=today&formatted=0`).json();
|
||||
this.expiration = now.add(1, 'day').set('hour', 0).set('minute', 0).set('second', 0).set('millisecond', 0);
|
||||
this.state = resp.results as SunriseResults;
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return `${this.location.latitude},${this.location.longitude}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default SunriseSunset;
|
||||
export { SunriseSunset };
|
||||
export type { Location, SunriseResponse, SunriseResults };
|
3
src/version.ts
Normal file
3
src/version.ts
Normal file
@ -0,0 +1,3 @@
|
||||
const Version: string = 'dev';
|
||||
|
||||
export default Version;
|
131
src/weather.ts
Normal file
131
src/weather.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import OpenWeatherMap from 'openweathermap-ts';
|
||||
import NodeCache from "node-cache";
|
||||
import type { Location } from './sunrise-sunset.ts';
|
||||
import type { ThreeHourResponse, CurrentResponse, CountryCode } from 'openweathermap-ts/dist/types/index.js';
|
||||
|
||||
const CACHEPERIOD = 5;
|
||||
const MINUTES = 60;
|
||||
|
||||
interface CityName {
|
||||
cityName: string;
|
||||
state: string;
|
||||
countryCode: CountryCode;
|
||||
}
|
||||
|
||||
interface WeatherConfig {
|
||||
key: string;
|
||||
lang?: string;
|
||||
coordinates?: number[] | string | Location;
|
||||
zip?: number;
|
||||
country?: CountryCode;
|
||||
cityid?: number;
|
||||
city?: CityName;
|
||||
}
|
||||
|
||||
type LocationType = 'coordinates' | 'zip' | 'cityid' | 'city' | null;
|
||||
|
||||
function parseCoords(coords: number[] | string | Location): number[] {
|
||||
if (typeof coords == 'string') {
|
||||
return coords.replace(/\s/g, '').split(',').map(Number.parseFloat);
|
||||
}
|
||||
else if (typeof coords == 'object' && !('length' in coords)) {
|
||||
return [coords.latitude, coords.longitude];
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
class Weather {
|
||||
private openWeather: OpenWeatherMap.default;
|
||||
private cache: NodeCache;
|
||||
private locationType: LocationType;
|
||||
|
||||
constructor(options: WeatherConfig) {
|
||||
this.cache = new NodeCache({
|
||||
stdTTL: CACHEPERIOD * MINUTES,
|
||||
useClones: false
|
||||
});
|
||||
this.locationType = null;
|
||||
this.openWeather = new OpenWeatherMap.default({
|
||||
apiKey: options.key
|
||||
});
|
||||
if ('city' in options && 'cityName' in options.city && 'state' in options.city && 'countryCode' in options.city) {
|
||||
this.openWeather.setCityName(options.city);
|
||||
this.locationType = 'city';
|
||||
}
|
||||
if ('cityid' in options) {
|
||||
this.openWeather.setCityId(options.cityid);
|
||||
this.locationType = 'cityid';
|
||||
}
|
||||
if ('zip' in options && 'country' in options) {
|
||||
this.openWeather.setZipCode(options.zip, options.country)
|
||||
this.locationType = 'zip';
|
||||
}
|
||||
if ('coordinates' in options) {
|
||||
const coords = parseCoords(options.coordinates);
|
||||
if (coords.length >= 2) {
|
||||
this.openWeather.setGeoCoordinates(coords[0], coords[1]);
|
||||
this.locationType = 'coordinates';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentWeather(): Promise<CurrentResponse> {
|
||||
let returnable = this.cache.get('current') as undefined | CurrentResponse;
|
||||
if (returnable === undefined) {
|
||||
switch (this.locationType) {
|
||||
case 'city':
|
||||
returnable = await this.openWeather.getCurrentWeatherByCityName();
|
||||
break;
|
||||
case 'cityid':
|
||||
returnable = await this.openWeather.getCurrentWeatherByCityId();
|
||||
break;
|
||||
case 'zip':
|
||||
returnable = await this.openWeather.getCurrentWeatherByZipcode();
|
||||
break;
|
||||
case 'coordinates':
|
||||
returnable = await this.openWeather.getCurrentWeatherByGeoCoordinates();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Can't fetch weather for location type '${this.locationType}'`);
|
||||
}
|
||||
this.cache.set('current', returnable);
|
||||
}
|
||||
return returnable;
|
||||
}
|
||||
|
||||
async getThreeHourForecast(): Promise<ThreeHourResponse> {
|
||||
let returnable = this.cache.get('forecast') as undefined | ThreeHourResponse;
|
||||
if (returnable === undefined) {
|
||||
switch (this.locationType) {
|
||||
case 'city':
|
||||
returnable = await this.openWeather.getThreeHourForecastByCityName();
|
||||
break;
|
||||
case 'cityid':
|
||||
returnable = await this.openWeather.getThreeHourForecastByCityId();
|
||||
break;
|
||||
case 'zip':
|
||||
returnable = await this.openWeather.getThreeHourForecastByZipcode();
|
||||
break;
|
||||
case 'coordinates':
|
||||
returnable = await this.openWeather.getThreeHourForecastByGeoCoordinates();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Can't fetch weather for location type '${this.locationType}'`);
|
||||
}
|
||||
this.cache.set('forecast', returnable);
|
||||
}
|
||||
return returnable;
|
||||
}
|
||||
|
||||
getSimpleWeather(): Promise<CurrentResponse> {
|
||||
return this.getSimpleWeather();
|
||||
}
|
||||
|
||||
getAllWeather(): Promise<ThreeHourResponse> {
|
||||
return this.getThreeHourForecast();
|
||||
}
|
||||
}
|
||||
|
||||
export default Weather;
|
||||
export { Weather };
|
||||
export type { WeatherConfig, CityName };
|
@ -1,6 +1,4 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
body,
|
||||
html {
|
||||
@ -81,6 +79,12 @@ input[type="submit"]:active,
|
||||
background-color: theme('colors.indigo.800');
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
background-color: theme('colors.indigo.700');
|
||||
color: theme('colors.indigo.50');
|
||||
@ -117,12 +121,31 @@ input[type="submit"]:active,
|
||||
transition: margin .25s ease;
|
||||
}
|
||||
|
||||
.tablelist>button {
|
||||
.tablelist>div {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tablelist>div>button.expand-btn {
|
||||
position: absolute;
|
||||
right: 1em;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
padding: 1em;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tablelist>div {
|
||||
border: 1px solid theme('colors.indigo.300');
|
||||
border-bottom: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tablelist>div>button.module-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
height: 100px;
|
||||
border: 1px solid theme('colors.indigo.300');
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
line-height: 80px;
|
||||
padding: 5px;
|
||||
@ -132,31 +155,54 @@ input[type="submit"]:active,
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tablelist>button:hover,
|
||||
.tablelist>button:focus {
|
||||
.tablelist>div>button.module-btn:hover,
|
||||
.tablelist>div>button.module-btn:focus {
|
||||
color: #fff;
|
||||
background-color: theme('colors.indigo.600');
|
||||
}
|
||||
|
||||
.tablelist>button:active,
|
||||
.tablelist>button.active {
|
||||
.tablelist>div>button.module-btn:active,
|
||||
.tablelist>div>button.module-btn.active {
|
||||
color: #fff;
|
||||
background-color: theme('colors.indigo.800');
|
||||
}
|
||||
|
||||
.tablelist>button:first-of-type {
|
||||
.tablelist>div:first-of-type,
|
||||
.tablelist>div:first-of-type>button.module-btn {
|
||||
border-radius: .75em .75em 0 0;
|
||||
}
|
||||
|
||||
.tablelist>button:nth-last-of-type(1) {
|
||||
border-bottom-left-radius: .75em;
|
||||
border-bottom-right-radius: .75em;
|
||||
.tablelist>div:nth-last-of-type(1),
|
||||
.tablelist>div.collapsed:nth-last-of-type(1)>button.module-btn {
|
||||
border-radius: 0 0 .75em .75em;
|
||||
}
|
||||
|
||||
.tablelist>button:nth-of-type(n + 2) {
|
||||
.tablelist>div:nth-last-of-type(1) {
|
||||
border-bottom: 1px solid theme('colors.indigo.300');
|
||||
}
|
||||
|
||||
.tablelist>div:nth-of-type(n + 2)>button {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.tablelist>div.collapsed form {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tablelist>div.collapsed .collapse,
|
||||
.tablelist>div .expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tablelist>div.collapsed .expand,
|
||||
.tablelist>div .collapse{
|
||||
display: initial;
|
||||
}
|
||||
|
||||
.tablelist form {
|
||||
padding: .5em .8em;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 675px) {
|
||||
#sidebar {
|
||||
left: -300px;
|
||||
@ -168,7 +214,7 @@ input[type="submit"]:active,
|
||||
}
|
||||
|
||||
@media screen and (max-width: 320px) {
|
||||
.tablelist>button img {
|
||||
.tablelist>div>button.module-btn img {
|
||||
display: none;
|
||||
}
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
const phin = require('phin');
|
||||
|
||||
const CACHEPERIOD = 5;
|
||||
const MINUTES = 60000;
|
||||
const DAY = 86400000;
|
||||
|
||||
function tomorrow() {
|
||||
let d = new Date();
|
||||
d = new Date(d.getTime() + DAY);
|
||||
d.setHours(0);
|
||||
d.setMinutes(0);
|
||||
d.setSeconds(0);
|
||||
d.setMilliseconds(0);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function createWeatherApi(options) {
|
||||
const AsyncWeather = (await import('@cicciosgamino/openweather-apis')).AsyncWeather;
|
||||
const weather = await (new AsyncWeather())
|
||||
weather.setLang(options.lang || 'en');
|
||||
weather.setUnits('metric');
|
||||
if ('coordinates' in options) {
|
||||
let coords = options.coordinates;
|
||||
if (typeof coords == 'string') {
|
||||
coords = coords.replace(/\s/g, '').split(',').map(Number.parseFloat);
|
||||
}
|
||||
else if (typeof coords == 'object' && !('length' in coords)) {
|
||||
coords = [coords['latitude'], coords['longitude']];
|
||||
}
|
||||
if (typeof coords == 'object' && 'length' in coords && coords.length >= 2) {
|
||||
weather.setCoordinates(coords[0], coords[1]);
|
||||
}
|
||||
}
|
||||
if ('zip' in options && 'country' in options) {
|
||||
weather.setZipCodeAndCountryCode(options.zip, options.country);
|
||||
}
|
||||
if ('cityid' in options) {
|
||||
weather.setCityId(options.cityid);
|
||||
}
|
||||
if ('city' in options) {
|
||||
weather.setCity(options.city);
|
||||
}
|
||||
if ('key' in options) {
|
||||
weather.setApiKey(options.key);
|
||||
}
|
||||
return weather;
|
||||
}
|
||||
|
||||
class SunriseSunset {
|
||||
constructor(location) {
|
||||
this._location = location;
|
||||
this._state = {
|
||||
expiration: new Date()
|
||||
}
|
||||
}
|
||||
|
||||
getSunrise = async () => {
|
||||
let now = new Date();
|
||||
if (now >= this._state.expiration) {
|
||||
console.log('Calling API');
|
||||
let resp = await phin({
|
||||
url: `https://api.sunrise-sunset.org/json?lat=${this._location.latitude}&lng=${this._location.longitude}&date=today&formatted=0`,
|
||||
parse: 'json'
|
||||
});
|
||||
resp.body.results.expiration = tomorrow();
|
||||
this._state = resp.body.results;
|
||||
}
|
||||
return this._state;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SunriseSunset;
|
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "NodeNext",
|
||||
"target": "ESNext",
|
||||
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "node16",
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["**/*.spec.ts"]
|
||||
}
|
@ -1 +0,0 @@
|
||||
module.exports = 1;
|
@ -1,20 +1,39 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const loc = window.location;
|
||||
const server = `${loc.protocol === 'https:' ? 'wss' : 'ws'}://${loc.host}/control`;
|
||||
var width = 0;
|
||||
var height = 0;
|
||||
|
||||
(function () {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
width = parseInt(urlParams.get('w')) || 0;
|
||||
height = parseInt(urlParams.get('h')) || 0;
|
||||
})();
|
||||
|
||||
function createIFrame() {
|
||||
var iframe = document.createElement('iframe');
|
||||
if (width) {
|
||||
iframe.style.width = `${width}px`;
|
||||
}
|
||||
if (height) {
|
||||
iframe.style.height = `${height}px`;
|
||||
}
|
||||
return iframe;
|
||||
}
|
||||
|
||||
function handlemessage(data) {
|
||||
const msg = JSON.parse(data.data);
|
||||
if (msg.type === 'module') {
|
||||
msg.type = 'url';
|
||||
msg.body = `/modules/${msg.body}/`
|
||||
msg.body = `/modules/${msg.body}`
|
||||
}
|
||||
if (msg.type === 'version') {
|
||||
if (msg.body !== <%= version %>) {
|
||||
if (msg.body !== '<%= Version %>') {
|
||||
location.reload(true);
|
||||
}
|
||||
}
|
||||
else if (msg.type === 'url') {
|
||||
var iframe = document.createElement('iframe');
|
||||
var iframe = createIFrame();
|
||||
iframe.src = msg.body;
|
||||
iframe.style['z-index'] = -100;
|
||||
document.body.append(iframe);
|
||||
|
@ -25,10 +25,28 @@
|
||||
<h2>Module</h2>
|
||||
<div class="tablelist" id="modules">
|
||||
<% modules.forEach(function(module) { %>
|
||||
<button>
|
||||
<% if (module.icon) { %><img alt="<%= module.name %> module" class="max-h-full inline-block" src="<%= module.icon %>" /><% } %>
|
||||
<span class="module-name ml-2"><%= module.name %></span>
|
||||
</button>
|
||||
<div class="collapsed">
|
||||
<button class="module-btn">
|
||||
<% if (module.icon) { %><img alt="<%= module.name %> module" class="max-h-full inline-block" src="<%= module.icon %>" /><% } %>
|
||||
<span class="module-path hidden"><%= module.path %></span>
|
||||
<span class="module-name ml-2"><%= module.name %></span>
|
||||
</button>
|
||||
<% if (module.parameters) { %>
|
||||
<button class="expand-btn"><span class="expand">⬍</span><span class="collapse">⬆</span></button>
|
||||
<form>
|
||||
<div class="form-grid">
|
||||
<% module.parameters.forEach(function(param) { %>
|
||||
<label><%= param.name %></label>
|
||||
<% if (param.type === 'color') { %>
|
||||
<input type="color" name="<%= param.param %>" value="<%= param.default || '' %>">
|
||||
<% } else if (param.type === 'bool' || param.type === 'boolean') { %>
|
||||
<div><input type="checkbox" name="<%= param.param %>" <% if (param.default) { echo("checked") } %>></div>
|
||||
<% } %>
|
||||
<% }); %>
|
||||
</div>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
|
69
weather.js
69
weather.js
@ -1,69 +0,0 @@
|
||||
const CACHEPERIOD = 5;
|
||||
const MINUTES = 60000;
|
||||
|
||||
async function createWeatherApi(options) {
|
||||
const AsyncWeather = (await import('@cicciosgamino/openweather-apis')).AsyncWeather;
|
||||
const weather = await (new AsyncWeather())
|
||||
weather.setLang(options.lang || 'en');
|
||||
weather.setUnits('metric');
|
||||
if ('coordinates' in options) {
|
||||
let coords = options.coordinates;
|
||||
if (typeof coords == 'string') {
|
||||
coords = coords.replace(/\s/g, '').split(',').map(Number.parseFloat);
|
||||
}
|
||||
else if (typeof coords == 'object' && !('length' in coords)) {
|
||||
coords = [coords['latitude'], coords['longitude']];
|
||||
}
|
||||
if (typeof coords == 'object' && 'length' in coords && coords.length >= 2) {
|
||||
weather.setCoordinates(coords[0], coords[1]);
|
||||
}
|
||||
}
|
||||
if ('zip' in options && 'country' in options) {
|
||||
weather.setZipCodeAndCountryCode(options.zip, options.country);
|
||||
}
|
||||
if ('cityid' in options) {
|
||||
weather.setCityId(options.cityid);
|
||||
}
|
||||
if ('city' in options) {
|
||||
weather.setCity(options.city);
|
||||
}
|
||||
if ('key' in options) {
|
||||
weather.setApiKey(options.key);
|
||||
}
|
||||
return weather;
|
||||
}
|
||||
|
||||
class Weather {
|
||||
constructor() {
|
||||
this._state = {
|
||||
expiration: new Date()
|
||||
}
|
||||
this._simplestate = {
|
||||
expiration: new Date()
|
||||
}
|
||||
}
|
||||
|
||||
prepareApi = async (options) => {
|
||||
this._weather = await createWeatherApi(options);
|
||||
}
|
||||
|
||||
getSimpleWeather = async () => {
|
||||
let now = new Date();
|
||||
if (now >= this._simplestate.expiration) {
|
||||
this._simplestate.weather = await this._weather.getSmartJSON();
|
||||
this._simplestate.expiration = new Date(now.getTime() + CACHEPERIOD * MINUTES);
|
||||
}
|
||||
return this._simplestate.weather;
|
||||
}
|
||||
|
||||
getAllWeather = async () => {
|
||||
let now = new Date();
|
||||
if (now >= this._state.expiration) {
|
||||
this._state.weather = await this._weather.getAllWeather();
|
||||
this._state.expiration = new Date(now.getTime() + CACHEPERIOD * MINUTES);
|
||||
}
|
||||
return this._state.weather;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Weather;
|
Loading…
x
Reference in New Issue
Block a user