Compare commits

...

10 Commits

Author SHA1 Message Date
8fcab2701e push to docker hub 2025-06-03 02:05:45 -05:00
3b1ec0ac3f deploy 🤤 2025-06-03 01:39:51 -05:00
e42d5241c0
Merge pull request #5 from CorySanin/modulize
modulize it
2025-06-03 01:38:42 -05:00
5670557392 updated build action 2025-06-03 01:37:36 -05:00
4a2644c071 complete ts conversion 2025-06-03 01:23:20 -05:00
c6242bb109 create module versions of sunrise-sunset and weather 2025-06-02 18:13:25 -05:00
6e7aa7464e chore: bump deps 2022-08-12 19:31:45 -05:00
acccc9f458 Add parameter support 2022-01-06 01:33:30 -06:00
025d560f89 Set the client width 2022-01-04 22:05:32 -06:00
1ada31a00c Add weather clock 2021-12-29 03:22:04 -06:00
29 changed files with 3940 additions and 2671 deletions

View File

@ -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

View File

@ -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"]

View File

@ -5,7 +5,6 @@ body {
padding: 0;
overflow: hidden;
background-color: #000;
cursor: none;
}
iframe {

View File

@ -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,9 +26,18 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
modulebtns.forEach(function (btn) {
btn.addEventListener('click', function (e) {
let mname = btn.querySelector('.module-name').innerText;
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',
@ -36,9 +46,27 @@ document.addEventListener('DOMContentLoaded', function () {
},
body: JSON.stringify({
type: 'module',
body: mname
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);
});
});

View File

@ -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));

View 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"
}
]
}

View File

@ -0,0 +1,5 @@
{
"name": "Contrast",
"description": "This design is all business.",
"icon": "icon.png"
}

108
modules/weather/clock.js Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View 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>

View 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
View 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;
}

View 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);
});

5180
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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
View File

@ -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;

View File

@ -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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
const Version: string = 'dev';
export default Version;

131
src/weather.ts Normal file
View 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 };

View File

@ -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;
}
}

View File

@ -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
View 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"]
}

View File

@ -1 +0,0 @@
module.exports = 1;

View File

@ -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);

View File

@ -25,10 +25,28 @@
<h2>Module</h2>
<div class="tablelist" id="modules">
<% modules.forEach(function(module) { %>
<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>

View File

@ -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;