master
Jonatan Nilsson 2024-02-13 23:54:45 +00:00
parent bd01209283
commit 9b2bf554b1
18 changed files with 760 additions and 0 deletions

64
.gitignore vendored Normal file
View File

@ -0,0 +1,64 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# Local development config file
config.json
package-lock.json
public/assets/app.js

1
.npmrc Normal file
View File

@ -0,0 +1 @@
package-lock=false

24
api/config.mjs Normal file
View File

@ -0,0 +1,24 @@
import Nconf from 'nconf-lite'
const nconf = new Nconf()
// Helper method for global usage.
nconf.inTest = () => nconf.get('NODE_ENV') === 'test'
// Load env
nconf.overrides({
NODE_ENV: (process.env.NODE_ENV || 'development').toLocaleLowerCase()
})
// Load empty overrides that can be overwritten later
nconf.overrides({})
nconf.defaults({
"frontend": {
"url": "http://streamer.filadelfia.is"
},
"jwtsecret": "w2bkdWAButfdfEkCs8dpE3L2n6QzCfhna0T4"
})
export default nconf

14
api/encoder/routes.mjs Normal file
View File

@ -0,0 +1,14 @@
export default class EncoderRoutes {
constructor(opts = {}) {
Object.assign(this, opts)
}
registerIo(server, ctx) {
ctx.socket.safeOn('encoder.status', this.status.bind(this))
}
status(ctx) {
ctx.socket.emit('encoder.status', { running: false })
}
}

59
api/io.mjs Normal file
View File

@ -0,0 +1,59 @@
import socket from 'socket.io-serveronly'
export default class SocketServer {
constructor(db, log, routes) {
this.db = db
this.log = log
this.routes = routes
}
init(server) {
this.io = new socket(server)
this.io.on('connection', this.onNewConnection.bind(this))
}
onNewConnection(socket) {
let ctx = {
io: this.io,
socket: socket,
log: this.log.child({ id: socket.id }),
db: this.db
}
ctx.log.info('New socket connection', { id: socket.id })
ctx.socket.safeOn = this.socketSafeOn.bind(this, ctx)
this.register(ctx)
ctx.socket.emit('data', { bla: 'test' })
}
register(ctx) {
let keys = Object.keys(this.routes)
for (let key of keys) {
if (this.routes[key].registerIo) {
this.routes[key].registerIo(this, ctx)
}
}
}
socketSafeOn(ctx, name, fn) {
ctx.socket.on(name, data => {
ctx.log.info('IO: ' + name)
let res;
try {
res = fn(ctx, data)
} catch (err) {
ctx.log.error(error, `Error processing ${name}`)
}
if (res && typeof(res.then) === 'function') {
res.then(
() => {},
error => ctx.log.error(error, `Error processing ${name}`)
)
}
})
}
}

61
api/serve.mjs Normal file
View File

@ -0,0 +1,61 @@
import path from 'path'
import { FileResponse } from 'flaska'
import fs from 'fs/promises'
import fsSync from 'fs'
export default class ServeHandler {
constructor(opts = {}) {
Object.assign(this, opts)
Object.assign(this, {
fs: this.fs || fs,
fsSync: this.fsSync || fsSync,
frontend: this.frontend || 'http://localhost:4000',
version: this.version || 'version',
})
this.index = fsSync.readFileSync(path.join(this.root, 'index.html'))
}
register(server) {
server.flaska.get('/::file', this.serve.bind(this))
}
/** GET: /::file */
serve(ctx) {
if (ctx.params.file.startsWith('api/')) {
return this.serveIndex(ctx)
}
let file = path.resolve(path.join(this.root, ctx.params.file ? ctx.params.file : 'index.html'))
if (!ctx.params.file
|| ctx.params.file === 'index.html') {
return this.serveIndex(ctx)
}
if (!file.startsWith(this.root)) {
return this.serveIndex(ctx)
}
return this.fs.stat(file)
.then(function(stat) {
ctx.headers['Cache-Control'] = 'no-store'
ctx.body = new FileResponse(file, stat)
})
.catch((err) => {
if (err.code === 'ENOENT') {
return this.serveIndex(ctx)
}
return Promise.reject(err)
})
}
async serveIndex(ctx) {
ctx.body = this.index
ctx.type = 'text/html; charset=utf-8'
}
serveErrorPage(ctx) {
}
}

139
api/server.mjs Normal file
View File

@ -0,0 +1,139 @@
import { Flaska, QueryHandler, JsonHandler, HttpError } from 'flaska'
import config from './config.mjs'
import StaticRoutes from './static_routes.mjs'
import ServeHandler from './serve.mjs'
import SocketServer from './io.mjs'
import EncoderRoutes from './encoder/routes.mjs'
export default class Server {
constructor(http, port, core, opts = {}) {
Object.assign(this, opts)
Object.assign(this, {
http, port, core,
})
let localUtil = new this.core.sc.Util(import.meta.url)
this.flaskaOptions = {
appendHeaders: {
'Content-Security-Policy': `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'; connect-src 'self' https://media.nfp.is/; media-src 'self' https://cdn.nfp.is/`,
},
nonce: [],
log: this.core.log,
}
this.jsonHandler = JsonHandler
this.routes = {
static: new StaticRoutes(),
encoder: new EncoderRoutes(),
}
this.routes.serve = new ServeHandler({
root: localUtil.getPathFromRoot('../public'),
version: this.core.app.running,
frontend: config.get('frontend:url'),
})
}
runCreateServer() {
// Create our server
this.flaska = new Flaska(this.flaskaOptions, this.http)
// configure our server
if (config.get('NODE_ENV') === 'development') {
this.flaska.devMode()
}
this.flaska.onerror((err, ctx) => {
if (err instanceof HttpError && err.status !== 500) {
ctx.status = err.status
ctx.log.warn(err.message)
} else {
ctx.log.error(err.inner || err)
if (err.extra) {
ctx.log.error({ extra: err.extra }, 'Database parameters')
}
ctx.status = 500
}
ctx.body = {
status: ctx.status,
message: err.message,
}
})
this.flaska.before(function(ctx) {
ctx.state.started = new Date().getTime()
ctx.req.ip = ctx.req.headers['x-forwarded-for'] || ctx.req.connection.remoteAddress
ctx.log = ctx.log.child({
id: Math.random().toString(36).substring(2, 14),
})
ctx.db = this.pool
}.bind(this))
this.flaska.before(QueryHandler())
let healthChecks = 0
let healthCollectLimit = 60 * 60 * 12
this.flaska.after(function(ctx) {
if (ctx.aborted && ctx.status === 200) {
ctx.status = 299
}
let ended = new Date().getTime()
var requestTime = ended - ctx.state.started
let status = ''
let level = 'info'
if (ctx.status >= 400) {
status = ctx.status + ' '
level = 'warn'
}
if (ctx.status >= 500) {
level = 'error'
}
if (ctx.url === '/health' || ctx.url === '/api/health') {
healthChecks++
if (healthChecks >= healthCollectLimit) {
ctx.log[level]({
duration: Math.round(ended),
status: ctx.status,
}, `<-- ${status}${ctx.method} ${ctx.url} {has happened ${healthChecks} times}`)
healthChecks = 0
}
return
}
ctx.log[level]({
duration: requestTime,
status: ctx.status,
ip: ctx.req.ip,
}, (ctx.aborted ? '[ABORT]' : '<--') + ` ${status}${ctx.method} ${ctx.url}`)
})
}
runRegisterRoutes() {
let keys = Object.keys(this.routes)
for (let key of keys) {
if (this.routes[key].register) {
this.routes[key].register(this)
}
}
}
runCreateSocket() {
this.io = new SocketServer(this.db, this.core.log, this.routes)
this.io.init(this.flaska.server)
}
runStartListen() {
return this.flaska.listenAsync(this.port).then(() => {
this.core.log.info('Server is listening on port ' + this.port)
this.runCreateSocket()
})
}
run() {
this.runCreateServer()
this.runRegisterRoutes()
return this.runStartListen()
}
}

17
api/static_routes.mjs Normal file
View File

@ -0,0 +1,17 @@
import config from './config.mjs'
export default class StaticRoutes {
constructor(opts = {}) {
Object.assign(this, { })
}
register(server) {
server.flaska.get('/api/health', this.health.bind(this))
}
health(ctx) {
ctx.body = {
environment: config.get('NODE_ENV'),
}
}
}

78
app/api/client.js Normal file
View File

@ -0,0 +1,78 @@
const m = require('mithril')
var io = require('./socket')
var socket = io()
class Client {
constructor(socket) {
this.socket = socket
this.isConnected = false
this.socket.on('disconnect', this.disconnected.bind(this))
this.socket.on('connect', this.connected.bind(this))
this.components = new Map()
}
registerComponent(component) {
this.components.set(component, [])
if (component.ioInit) {
component.ioInit()
}
if (this.isConnected && component.ioConnected) {
component.ioConnected()
}
}
safeGetComponent(component) {
if (typeof(component) === 'string') {
throw new Error('io.on was called without specifying active component')
}
let arr = this.components.get(component)
if (!arr) {
throw new Error('Registered component was missing but was attempting to add socket listener')
}
return arr
}
on(component, name, fn) {
let arr = this.safeGetComponent(component)
arr.push([name, fn])
this.socket.on(name, (data) => {
Promise.resolve().then(() => {
return fn(data)
})
.catch(err => {
console.error('Error running io handler for ' + name, err)
})
})
}
emit(name, data) {
this.socket.emit(name, data)
}
unregisterComponent(component) {
let arr = this.safeGetComponent(component)
for (let item of arr) {
this.socket.off(item[0], item[1])
}
this.components.delete(component)
}
disconnected() {
this.isConnected = false
m.redraw()
}
connected() {
this.isConnected = true
for (let component of this.components) {
if (component[0].ioConnected) {
component[0].ioConnected()
}
}
m.redraw()
}
}
module.exports = new Client(socket)

8
app/api/socket.js Normal file

File diff suppressed because one or more lines are too long

21
app/index.js Normal file
View File

@ -0,0 +1,21 @@
const m = require('mithril')
const Status = require('./page_status')
window.m = m
m.route.setOrig = m.route.set
m.route.set = function(path, data, options){
m.route.setOrig(path, data, options)
window.scrollTo(0, 0)
}
m.route.linkOrig = m.route.link
m.route.link = function(vnode){
m.route.linkOrig(vnode)
window.scrollTo(0, 0)
}
m.route.prefix = ''
m.route(document.getElementById('main'), '/', {
'/': Status,
})

30
app/page_status.js Normal file
View File

@ -0,0 +1,30 @@
const m = require('mithril')
const client = require('./api/client')
const Status = {
oninit: function(vnode) {
client.registerComponent(this)
},
onremove: function(vnode) {
client.unregisterComponent(this)
},
ioInit: function() {
client.on(this, 'encoder.status', status => {
console.log('status', status)
})
},
ioConnected: function() {
client.emit('encoder.status')
},
view: function(vnode) {
return [
m('div', `Hello world, connection status: ${client.isConnected}`),
]
},
}
module.exports = Status

103
arduino/arduino.ino Normal file
View File

@ -0,0 +1,103 @@
/*
Blink
The circuit:
* VFD Data to digital pin 2 (blue)
* VFD Clock to digital pin 3 (yellow)
* VFD Chip select to digital pin 4 (green)
* VFD VCC (power) to 5V
* VFD Ground (power) to Ground
*/
// include the library code:
#include <SPI_VFD.h>
// initialize the library with the numbers of the interface pins
SPI_VFD vfd(2, 3, 4);
// | 1 2|
// |12345678901234567890|
String vfd_line_top = "Booting up... ";
String vfd_line_bottom = "Waiting for stream. ";
unsigned int vfd_line_position = 0;
bool vfd_line_print = false;
unsigned long millis_last_incoming_ping = 0;
unsigned long millis_last_outgoing_ping = 0;
// the setup function runs once when you press reset or power the board
void setup() {
// initialize digital pin LED_BUILTIN as an output.
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, LOW);
Serial.begin(9600);
// set up the VFD's number of columns and rows:
vfd.begin(20, 2);
update_display();
}
void update_display() {
vfd.setCursor(0, 0);
vfd.print(vfd_line_top);
vfd.setCursor(0, 1);
vfd.print(vfd_line_bottom);
}
void check_serial() {
while (Serial.available() > 0) {
millis_last_incoming_ping = millis();
int incomingByte = Serial.read();
if (incomingByte == 0 || incomingByte == '^') {
incomingByte = vfd_line_position = 0;
}
if (incomingByte < 32) {
continue;
}
if (vfd_line_position < 20) {
vfd_line_top.setCharAt(vfd_line_position, incomingByte);
} else {
vfd_line_bottom.setCharAt(vfd_line_position - 20, incomingByte);
}
vfd_line_position = (vfd_line_position + 1) % 40;
vfd_line_print = vfd_line_position == 0;
if (vfd_line_print) {
break;
}
}
}
// the loop function runs over and over again forever
void loop() {
// We've been running for over 50 days and wrapped to zero. Reset all our timers
if (millis() < 10000 && (millis_last_outgoing_ping > 10000 || millis_last_incoming_ping > 10000)) {
millis_last_outgoing_ping = 0;
millis_last_incoming_ping = 0;
}
check_serial();
// If it's been 10 seconds since last incoming data
if (millis() - millis_last_incoming_ping > 30000) {
// |12345678901234567890|
vfd_line_top = "Error stream is down";
vfd_line_bottom = "Verify or restart...";
vfd_line_print = true;
millis_last_incoming_ping = millis();
}
// Send outgoing ping every 5 seconds if we can.
if (millis() - millis_last_outgoing_ping > 30000 && Serial.availableForWrite()) {
Serial.write('^');
millis_last_outgoing_ping = millis();
}
// If we have something to print, print it
if (vfd_line_print) {
vfd_line_print = false;
update_display();
}
delay(100);
}

38
index.mjs Normal file
View File

@ -0,0 +1,38 @@
import fs from 'fs'
import { pathToFileURL } from 'url'
import config from './api/config.mjs'
export function start(http, port, ctx) {
config.sources[1].store = ctx.config
return import('./api/server.mjs')
.then(function(module) {
let server = new module.default(http, port, ctx)
return server.run()
})
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
import('service-core').then(core => {
const port = 4510
var core = new core.ServiceCore('filadelfia_streamer', import.meta.url, port, '')
let config = {
frontend: {
url: 'http://localhost:' + port
}
}
try {
config = JSON.parse(fs.readFileSync('./config.json'))
} catch {}
config.port = port
core.setConfig(config)
core.init({ start }).then(function() {
return core.run()
})
})
}

49
package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "church_streamer",
"version": "1.0.0",
"description": "",
"main": "index.mjs",
"scripts": {
"start": "node index.mjs",
"test": "echo \"Error: no test specified\" && exit 1",
"build": "esbuild app/index.js --bundle --outfile=public/assets/app.js",
"dev:build": "eltro --watch build --npm build",
"dev:server": "eltro --watch server --npm server",
"dev:build:old": "npm-watch build",
"dev:server:old": "npm-watch server",
"server": "node index.mjs | bunyan"
},
"watch": {
"server": {
"patterns": [
"api"
],
"extensions": "js,mjs",
"quiet": true,
"inherit": true
},
"build": {
"patterns": [
"app"
],
"extensions": "js",
"quiet": true,
"inherit": true
}
},
"author": "Jonatan Nilsson",
"license": "WTFPL",
"dependencies": {
"bunyan-lite": "^1.2.1",
"flaska": "^1.3.4",
"nconf-lite": "^2.0.0",
"serialport": "^12.0.0",
"socket.io-serveronly": "^2.3.0"
},
"devDependencies": {
"eltro": "^1.4.4",
"esbuild": "^0.19.5",
"mithril": "^2.2.2",
"service-core": "^3.0.0-beta.17"
}
}

25
public/assets/app.css Normal file
View File

@ -0,0 +1,25 @@
[hidden] { display: none !important; }
:root { --bg: #fff; --bg-component: #f3f7ff; --bg-component-half: #f3f7ff77; --bg-component-alt: #ffd99c; --color: #031131; --color-alt: #7a9ad3; --main: #18597d; --main-fg: #fff; --error: red; --error-bg: hsl(0, 75%, 80%); } /* Box sizing rules */
*, *::before, *::after { box-sizing: border-box;
}
/* Remove default margin */
body, h1, h2, h3, h4, p, figure, blockquote, dl, dd { margin: 0; }
body { min-height: 100vh; text-rendering: optimizeSpeed; line-height: 1.5; font-size: 16px; font-family: 'Inter var', Helvetica, Arial, sans-serif; font-variation-settings: "slnt" 0; font-feature-settings: "case", "frac", "tnum", "ss02", "calt", "ccmp", "kern"; background: var(--bg); color: var(--color); display: flex; flex-direction: column; }
.italic { font-variation-settings: "slnt" 10deg; }
input, button, textarea, select { font: inherit; }
h1 { font-size: 1.88rem; }
h2 { font-size: 1.66rem; }
h3 { font-size: 1.44rem; }
h4 { font-size: 1.22rem; }
h5 { font-size: 1.0rem; }
.row { display: flex; }
.column { display: flex; flex-direction: column; }
.filler { flex-grow: 2; }

13
public/assets/font.css Normal file

File diff suppressed because one or more lines are too long

16
public/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Filadelfia Streamer</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/assets/favicon.png">
<link rel="stylesheet" type="text/css" href="/assets/font.css">
<link rel="stylesheet" type="text/css" href="/assets/app.css">
</head>
<body>
<main id="main"></main>
<script type="text/javascript" src="/assets/app.js?v=2"></script>
</body>
</html>