signage: bring in from external repo

This is b28e6f07aa48f1e2f01eb37bffa180f97a7b03bd from
https://code.hackerspace.pl/q3k/love2d-signage/. We only keep code
commited by inf and q3k, and we're both now licensing this code under
the ISC license, as per COPYING in the root of hscloud.

Change-Id: Ibeee2e6923605e4b1a17a1d295867c056863ef59
Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1335
Reviewed-by: informatic <informatic@hackerspace.pl>
Reviewed-by: q3k <q3k@hackerspace.pl>
diff --git a/hswaw/machines/tv/common.nix b/hswaw/machines/tv/common.nix
index c1b8e7c..228d780 100644
--- a/hswaw/machines/tv/common.nix
+++ b/hswaw/machines/tv/common.nix
@@ -1,6 +1,6 @@
 # RPi4 as TV kiosk.
 
-{ config, pkgs, ... }:
+{ config, pkgs, workspace, ... }:
 
 let
   nixos-hardware = builtins.fetchGit {
@@ -32,12 +32,6 @@
   networking.domain = "waw.hackerspace.pl";
   time.timeZone = "Europe/Warsaw";
 
-  nixpkgs.overlays = [
-    (self: super: {
-      signage = self.callPackage ./signage.nix {};
-    })
-  ];
-
   sound.enable = true;
   services.pipewire = {
     enable = true;
@@ -107,7 +101,7 @@
        '';
     in pkgs.writeScriptBin "signage-wrapped" ''
       #!/usr/bin/env bash
-      SIGNAGE_CONFIG=${config} ${signage}/bin/signage
+      SIGNAGE_CONFIG=${config} ${workspace.hswaw.signage.prod}/bin/signage
     '')
     firefox foot wayvnc
     vim rxvt-unicode-unwrapped.terminfo
diff --git a/hswaw/machines/tv/signage.nix b/hswaw/machines/tv/signage.nix
deleted file mode 100644
index 449b0a3..0000000
--- a/hswaw/machines/tv/signage.nix
+++ /dev/null
@@ -1,40 +0,0 @@
-{ love, curl, fetchFromGitHub, fetchgit, stdenv, lib, ... }:
-let
-  love12 = (love.overrideAttrs (oa: {
-    version = "12.0-dev";
-    src = fetchFromGitHub {
-      owner = "love2d";
-      repo = "love";
-      rev = "d586d1847446f5212d5f7e9efb94e50fcfba7d77";
-      sha256 = "sha256-gTpVtyqXV6/GsezuCpooaY+x5tPfOF9p1b83v4kKR4E=";
-    };
-    makeFlags = [
-      "CPPFLAGS=-DHTTPS_BACKEND_CURL"
-    ];
-    buildInputs = oa.buildInputs ++ [ curl ];
-    NIX_LDFLAGS = "-lcurl";
-    enableParallelBuilding = true;
-  }));
-
-  signage = stdenv.mkDerivation {
-    name = "signage";
-    src = fetchgit {
-      url = "https://code.hackerspace.pl/q3k/love2d-signage";
-      rev = "6c14716222e28b004861b3926560bf21d519fb00";
-      sha256 = "sha256-dfZ6Q320+ukMt9Q2igcARBM72LRbW5ltEvxrngSW8fQ=";
-    };
-
-    installPhase = ''
-      mkdir -p $out/share/signage
-      cp -rv $src/* $out/share/signage/
-
-      mkdir -p $out/bin/
-      cat <<EOF >$out/bin/signage
-      #!/usr/bin/env bash
-      ${love12}/bin/love $out/share/signage
-      EOF
-      chmod +x $out/bin/signage
-    '';
-  };
-
-in signage
diff --git a/hswaw/signage/README b/hswaw/signage/README
new file mode 100644
index 0000000..8b5491b
--- /dev/null
+++ b/hswaw/signage/README
@@ -0,0 +1,24 @@
+love2d signage
+===
+
+As used in digital signage displays in HSWAW.
+
+Originally hosted on code.hackerspace.pl/informatic/love2d-signage, now in hscloud.
+
+
+Building & Running
+---
+
+    $ nix-build -A hswaw.signage.prod
+    $ result/bin/signage
+
+If the result doesn't run and complains about OpenGL not being available, try building the 'local' target instead, which will use your host `<nixpkgs>`:
+
+    $ nix-build -A hswaw.signage.local
+
+For non-NixOS systems you'll need something like `nixgl` to work around Nixpkgs-GL-on-Non-NixOS badness anyways.
+
+Custom configs
+---
+
+You can either modify config.lua and rebuild, or specify a custom config at runtime by settin gthe `SIGNAGE_CONFIG` environment variable.
diff --git a/hswaw/signage/config.lua b/hswaw/signage/config.lua
new file mode 100644
index 0000000..38bb780
--- /dev/null
+++ b/hswaw/signage/config.lua
@@ -0,0 +1,13 @@
+return {
+  displayTime = 2,
+  transitionTime = 0.5,
+  showProgress = true,
+  nodes = {
+    {'nodes.weather'}, 
+    {'nodes.newdash', displayTime=10},
+    {'nodes.misery', displayTime = 7},
+  },
+  environment = os.getenv('ENV') or 'prod',
+  renderWidth = 1280,
+  renderHeight = 720,
+}
diff --git a/hswaw/signage/core/node-manager.lua b/hswaw/signage/core/node-manager.lua
new file mode 100644
index 0000000..69af96b
--- /dev/null
+++ b/hswaw/signage/core/node-manager.lua
@@ -0,0 +1,114 @@
+local NodeManager = class('NodeManager', {
+  state = 'running',
+  stateTime = 0,
+  currentNode = nil,
+})
+
+function NodeManager:init(config)
+  self.config = config
+  self.nodes = {}
+  self.nodeConfs = {}
+
+  self.currentNode = nil
+
+  print('Initializing NodeManager')
+end
+
+function NodeManager:load()
+  self:configChanged()
+end
+
+function NodeManager:resize(w, h)
+  self.secondaryCanvas = love.graphics.newCanvas(w, h)
+end
+
+function NodeManager:configChanged()
+  local cnt = {}
+  local newNodes = {}
+  local newNodeConfs = {}
+
+  for _, c_ in ipairs(self.config.nodes) do
+    local nodeConfig = lume.clone(c_)
+    local hash = inspect(nodeConfig)
+    if cnt[hash] == nil then cnt[hash] = 0 end
+    cnt[hash] = cnt[hash] + 1
+    hash = hash .. '-' .. tostring(cnt[hash])
+
+    local nodeName = nodeConfig[1]
+    table.remove(nodeConfig, 1)
+
+    if self.nodeConfs[hash] then
+      print('Using existing node:', self.nodeConfs[hash], hash)
+      newNodes[#newNodes + 1] = self.nodeConfs[hash]
+    else
+      print('Creating new node.', nodeName, inspect(nodeConfig))
+      local status, err = pcall(function()
+        newNodes[#newNodes + 1] = require(nodeName)(nodeConfig)
+      end)
+      if err then
+        print("Error occured while loading", nodeName, err)
+        return
+      end
+    end
+
+    newNodeConfs[hash] = newNodes[#newNodes]
+  end
+
+  self.nodes = newNodes
+  self.nodeConfs = newNodeConfs
+end
+
+function NodeManager:render()
+  if not self.currentNode then self.currentNode = self.nodes[1] end
+  if not self.currentNode then return end
+
+  -- love.graphics.print('state: ' .. self.state .. '; counter: ' .. tostring(self.stateTime), 50, 50)
+
+  self.currentNode:render()
+
+  if self.state == 'transitioning' and self.currentNode ~= self.nextNode and self.nextNode then
+    self.secondaryCanvas:renderTo(function()
+      self.nextNode:render()
+    end)
+    love.graphics.setColor(1.0, 1.0, 1.0, 1.0 * (self.stateTime / self.config.transitionTime))
+    love.graphics.draw(self.secondaryCanvas, 0, 0)
+  end
+
+  love.graphics.setColor(1.0, 1.0, 1.0, 0.3)
+
+  if self.config.showProgress and self.state == 'running' then
+    local stateTime
+    stateTime = self.currentNode.displayTime or self.config.displayTime
+    local h = 5
+    love.graphics.rectangle("fill", 0, love.graphics.getHeight() - h, (self.stateTime / stateTime) * love.graphics.getWidth(), h)
+  end
+end
+
+function NodeManager:update(dt)
+  if not self.currentNode then self.currentNode = self.nodes[1] end
+  if not self.currentNode then return end
+
+  self.stateTime = self.stateTime + dt
+
+  if self.state == 'transitioning' and self.stateTime >= self.config.transitionTime then
+    self.stateTime = 0
+    self.state = 'running'
+    self.currentNode:afterExit()
+    -- self.currentNode, self.nextNode = self.nextNode, self.nodes[(lume.find(self.nodes, self.nextNode) or 1) % #self.nodes + 1]
+    self.currentNode = self.nextNode
+    self.currentNode:afterEnter()
+  elseif self.state == 'running' and self.stateTime >= (self.currentNode.displayTime or self.config.displayTime) then
+    self.stateTime = 0
+    self.state = 'transitioning'
+    self.currentNode:beforeExit()
+    self.nextNode = self.nodes[(lume.find(self.nodes, self.currentNode) or 1) % #self.nodes + 1]
+    self.nextNode:beforeEnter()
+  end
+
+  self.currentNode:update(dt)
+  if self.state == 'transitioning' and self.currentNode ~= self.nextNode and self.nextNode then
+    self.nextNode:update(dt)
+  end
+end
+
+return NodeManager
diff --git a/hswaw/signage/core/node.lua b/hswaw/signage/core/node.lua
new file mode 100644
index 0000000..42bba62
--- /dev/null
+++ b/hswaw/signage/core/node.lua
@@ -0,0 +1,16 @@
+local Node = class('Node', {})
+
+function Node:init(opts)
+    for i, n in pairs(opts) do
+        self[i] = n
+    end
+end
+
+function Node:update(dt) end
+
+function Node:beforeEnter() end
+function Node:afterEnter() end
+function Node:beforeExit() end
+function Node:afterExit() end
+
+return Node
diff --git a/hswaw/signage/core/thread-node.lua b/hswaw/signage/core/thread-node.lua
new file mode 100644
index 0000000..e8498dd
--- /dev/null
+++ b/hswaw/signage/core/thread-node.lua
@@ -0,0 +1,40 @@
+local ThreadNode = Node:extend('ThreadNode', {
+  threadFile = nil,
+  threadChannel = nil,
+  updateInterval = 5,
+  lastUpdate = 0,
+  state = nil,
+})
+
+function ThreadNode:update(dt)
+  if not self.state then self:checkUpdate() end
+end
+
+function ThreadNode:checkUpdate()
+  if self.threadFile and self.threadChannel then
+    if self.lastUpdate < love.timer.getTime() - self.updateInterval or
+        (not self.state and self.lastUpdate < love.timer.getTime() - 5) then
+      self.lastUpdate = love.timer.getTime()
+      print(self.threadChannel, "Updating...")
+
+      local updateThread = love.thread.newThread(self.threadFile)
+      updateThread:start()
+    end
+
+    local v = love.thread.getChannel(self.threadChannel):pop()
+    if v then
+      self:onUpdate(v)
+    end
+  end
+end
+
+function ThreadNode:afterExit()
+  print('exit')
+  self:checkUpdate()
+end
+
+function ThreadNode:onUpdate(v)
+  self.state = v
+end
+
+return ThreadNode
diff --git a/hswaw/signage/default.nix b/hswaw/signage/default.nix
new file mode 100644
index 0000000..0ac8c66
--- /dev/null
+++ b/hswaw/signage/default.nix
@@ -0,0 +1,43 @@
+{ pkgs, ... }: let
+
+  signageForPkgs = pkgs: with { inherit (pkgs) love fetchFromGitHub stdenv curl; }; let
+    # Build LÖVE2D 12, currently still in development. This gives us https
+    # support.
+    love12 = (love.overrideAttrs (oa: {
+      version = "12.0-dev";
+      src = fetchFromGitHub {
+        owner = "love2d";
+        repo = "love";
+        rev = "d586d1847446f5212d5f7e9efb94e50fcfba7d77";
+        sha256 = "sha256-gTpVtyqXV6/GsezuCpooaY+x5tPfOF9p1b83v4kKR4E=";
+      };
+      makeFlags = [
+        "CPPFLAGS=-DHTTPS_BACKEND_CURL"
+      ];
+      buildInputs = oa.buildInputs ++ [ curl ];
+      NIX_LDFLAGS = "-lcurl";
+      enableParallelBuilding = true;
+    }));
+
+    signage = stdenv.mkDerivation {
+      name = "signage";
+      src = ./.;
+
+      installPhase = ''
+        mkdir -p $out/share/signage
+        cp -rv $src/* $out/share/signage/
+
+        mkdir -p $out/bin/
+        cat <<EOF >$out/bin/signage
+        #!/usr/bin/env bash
+        ${love12}/bin/love $out/share/signage
+        EOF
+        chmod +x $out/bin/signage
+      '';
+    };
+  in signage;
+
+in {
+  prod = signageForPkgs pkgs;
+  local = signageForPkgs (import <nixpkgs> {});
+}
diff --git a/hswaw/signage/fonts/Lato-Light.ttf b/hswaw/signage/fonts/Lato-Light.ttf
new file mode 100644
index 0000000..0809b8e
--- /dev/null
+++ b/hswaw/signage/fonts/Lato-Light.ttf
Binary files differ
diff --git a/hswaw/signage/fonts/Lato-Regular.ttf b/hswaw/signage/fonts/Lato-Regular.ttf
new file mode 100644
index 0000000..adbfc46
--- /dev/null
+++ b/hswaw/signage/fonts/Lato-Regular.ttf
Binary files differ
diff --git a/hswaw/signage/fonts/Lato-Thin.ttf b/hswaw/signage/fonts/Lato-Thin.ttf
new file mode 100644
index 0000000..0f84dc1
--- /dev/null
+++ b/hswaw/signage/fonts/Lato-Thin.ttf
Binary files differ
diff --git a/hswaw/signage/fonts/weathericons-regular-webfont.ttf b/hswaw/signage/fonts/weathericons-regular-webfont.ttf
new file mode 100644
index 0000000..948f0a5
--- /dev/null
+++ b/hswaw/signage/fonts/weathericons-regular-webfont.ttf
Binary files differ
diff --git a/hswaw/signage/main.lua b/hswaw/signage/main.lua
new file mode 100644
index 0000000..e2c1bbc
--- /dev/null
+++ b/hswaw/signage/main.lua
@@ -0,0 +1,104 @@
+class   = require('vendor.30log')
+inspect = require('vendor.inspect')
+lume    = require('vendor.lume')
+
+Node        = require('core.node')
+ThreadNode  = require('core.thread-node')
+NodeManager = require('core.node-manager')
+
+local config = nil
+if os.getenv("SIGNAGE_CONFIG") ~= nil then
+  local f = loadfile(os.getenv("SIGNAGE_CONFIG"))
+  if f ~= nil then
+    config = f()
+  else
+    error("SIGNAGE_CONFIG given but could not be loaded")
+  end
+else
+  config = require('config')
+end
+
+local push = require('vendor.push')
+
+local lurker = require('vendor.lurker')
+lurker.quiet = true
+lurker.interval = 3
+
+local debugGraph = require('vendor.debugGraph')
+local fpsGraph = debugGraph:new('fps', 0, 0)
+local memGraph = debugGraph:new('mem', 0, 30)
+
+local gameWidth, gameHeight = config.renderWidth, config.renderHeight
+local windowWidth, windowHeight = love.window.getDesktopDimensions()
+windowWidth, windowHeight = windowWidth*.5, windowHeight*.5 --make the window a bit smaller than the screen itself
+
+if config.environment == 'dev' then
+  push:setupScreen(gameWidth, gameHeight, windowWidth, windowHeight, {fullscreen = false, resizable = true})
+else
+  push:setupScreen(gameWidth, gameHeight, gameWidth, gameHeight, {fullscreen = true})
+end
+
+function love.resize(w, h)
+  push:resize(w, h)
+  manager:resize(w, h)
+end
+
+function love.load()
+  manager = NodeManager(config)
+  manager:load()
+  manager:resize(push:getWidth(), push:getHeight())
+
+  love.mouse.setVisible( false )
+end
+
+function getw() return push:getWidth() end
+function geth() return push:getHeight() end
+
+function love.draw()
+  push:start()
+
+  -- Patch love.graphics.getWidth/Height to account for push
+  oldw, oldh = love.graphics.getWidth, love.graphics.getHeight
+  love.graphics.getWidth, love.graphics.getHeight = getw, geth
+
+  manager:render()
+
+  -- Draw graphs
+  love.graphics.setColor(1.0, 1.0, 1.0, 0.5)
+
+  -- love.graphics.setNewFont(10)
+  -- love.graphics.print(inspect(state), 0, 60, 0)
+
+  fpsGraph:draw()
+  memGraph:draw()
+
+  love.graphics.getWidth, love.graphics.getHeight = oldw, oldh
+  push:finish()
+end
+
+function love.keypressed( key, scancode, isrepeat )
+  if key == "right" then
+    -- Cycle to next state
+    manager.stateTime = 2137
+  end
+end
+
+function love.update(dt)
+  manager:update(dt)
+
+  -- Update the graphs
+  fpsGraph:update(dt)
+  memGraph:update(dt)
+  lurker.update()
+end
+
+function lurker.preswap(f)
+  if f == 'config.lua' then
+    print('config reloaded, notifying nodemanager')
+    package.loaded['config'] = nil
+    manager.config = require('config');
+    manager:configChanged();
+  elseif f:match('_thread.lua') then
+    return false
+  end
+end
diff --git a/hswaw/signage/nodes/at.lua b/hswaw/signage/nodes/at.lua
new file mode 100644
index 0000000..9adf98c
--- /dev/null
+++ b/hswaw/signage/nodes/at.lua
@@ -0,0 +1,46 @@
+local node = ThreadNode:extend('nodes.at', {
+  threadFile = 'nodes/at_thread.lua',
+  threadChannel = 'at',
+
+  api = 'http://at.hackerspace.pl/api',
+})
+
+local bigFont = love.graphics.newFont('fonts/Lato-Light.ttf', 80)
+local textFont = love.graphics.newFont('fonts/Lato-Light.ttf', 50)
+local smallFont = love.graphics.newFont('fonts/Lato-Light.ttf', 30)
+
+function node:render()
+  love.graphics.setColor( 0, 0, 0 )
+  love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
+
+  if self.state then
+    love.graphics.setColor( 1.0, 1.0, 1.0, 0.4 )
+    love.graphics.setFont(bigFont)
+    love.graphics.printf('Currently at hackerspace:', 50, 100, love.graphics.getWidth() - 100, 'center')
+
+    usersList = (table.concat(lume.map(self.state.users, function(v) return v.login end), ', ') or 'nobody...') .. '\n'
+
+    if self.state.unknown > 0 then
+      usersList = usersList .. '\n...and ' .. tostring(self.state.unknown) .. ' unknown creatures'
+    end
+
+    if self.state.kektops > 0 then
+      usersList = usersList .. '\n...and ' .. tostring(self.state.kektops) .. ' kektops'
+    end
+
+    if self.state.esps > 0 then
+      usersList = usersList .. '\n...and ' .. tostring(self.state.esps) .. ' ESPs'
+    end
+
+    love.graphics.setColor( 1.0, 1.0, 1.0 )
+    love.graphics.setFont(textFont)
+    love.graphics.printf(usersList, 50, 220, love.graphics.getWidth() - 100, 'center')
+  else
+    love.graphics.setColor( 1.0, 1.0, 1.0, 0.4 )
+
+    love.graphics.setFont(smallFont)
+    love.graphics.printf("Loading at...", 0, love.graphics.getHeight() - 200, love.graphics.getWidth(), 'center')
+  end
+end
+
+return node
diff --git a/hswaw/signage/nodes/at_thread.lua b/hswaw/signage/nodes/at_thread.lua
new file mode 100644
index 0000000..1bc782d
--- /dev/null
+++ b/hswaw/signage/nodes/at_thread.lua
@@ -0,0 +1,14 @@
+local socket = require("socket")
+local http = require("socket.http")
+local json = require("vendor.json")
+local lume = require("vendor.lume")
+
+local miseryURL = 'http://at.hackerspace.pl/api'
+
+local r, c, h = http.request(miseryURL)
+if c == 200 then
+  love.thread.getChannel('at'):push(json.decode(r))
+  print("Update finished")
+else
+  print("Update failed")
+end
diff --git a/hswaw/signage/nodes/countdown.lua b/hswaw/signage/nodes/countdown.lua
new file mode 100644
index 0000000..c5f9530
--- /dev/null
+++ b/hswaw/signage/nodes/countdown.lua
@@ -0,0 +1,55 @@
+local node = Node:extend('nodes.countdown', {
+  target = 1498780800,
+  description = 'to get the fuck out of here',
+  precision = 3,
+})
+
+local textFont = love.graphics.newFont('fonts/Lato-Thin.ttf', 100)
+local smallFont = love.graphics.newFont('fonts/Lato-Light.ttf', 60)
+
+function node:init(config)
+  node.super.init(self, config)
+end
+
+function timefmt(time, precision)
+  precision = precision or 3
+
+  local p = {
+    {60, "seconds"},
+    {60, "minutes"},
+    {24, "hours"},
+    {7, "days"},
+    {nil, "weeks"},
+  }
+  local parts = {}
+  local v
+  for i, e in ipairs(p) do
+    if e[1] == nil then
+      v = time
+    else
+      v = time % e[1]
+      time = math.floor(time / e[1])
+    end
+
+    if v ~= 0 then
+      table.insert(parts, 1, tostring(v) .. " " .. e[2])
+    end
+  end
+
+  return table.concat(lume.slice(parts, 1, precision), " ")
+end
+
+function node:render()
+  love.graphics.setColor( 0, 0, 0 )
+  love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
+
+  love.graphics.setColor( 1.0, 1.0, 1.0 )
+
+  love.graphics.setFont(textFont);
+  love.graphics.printf(timefmt(math.abs(self.target - os.time()), self.precision), 0, 0.3*love.graphics.getHeight(), love.graphics.getWidth(), 'center');
+
+  love.graphics.setFont(smallFont);
+  love.graphics.printf(self.description, 0, 0.7*love.graphics.getHeight(), love.graphics.getWidth(), 'center');
+end
+
+return node
diff --git a/hswaw/signage/nodes/cube.lua b/hswaw/signage/nodes/cube.lua
new file mode 100644
index 0000000..ab9570b
--- /dev/null
+++ b/hswaw/signage/nodes/cube.lua
@@ -0,0 +1,64 @@
+local node = Node:extend('nodes.at', {
+})
+-- local papa = love.graphics.newImage("papa.png")
+local h = 25.0
+local v = {
+	{-h, -h, -h},
+	{ h, -h, -h},
+	{ h,  h, -h},
+	{-h,  h, -h},
+	{-h, -h,  h},
+	{ h, -h,  h},
+	{ h,  h,  h},
+	{-h,  h,  h}
+}
+
+local c = {
+	{0, 1, 31},
+	{1, 2, 31},
+	{2, 3, 31},
+	{3, 0, 31},
+	{0, 4, 34},
+	{1, 5, 34},
+	{2, 6, 34},
+	{3, 7, 34},
+	{4, 5, 32},
+	{5, 6,32},
+	{6, 7,32},
+	{7, 4,32}
+}
+
+local E = 100 * math.tan(2*math.pi/3)
+
+function to2d(p) return p[1] * E / (p[3]+E), p[2] * E / (p[3]+E) end
+
+function node:render()
+  love.graphics.setColor( 0, 0, 0 )
+  love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
+  love.graphics.setColor( 1.0, 1.0, 1.0 )
+  local w = love.graphics.getWidth() / 2
+  local h = love.graphics.getHeight() / 2
+  local scl = 6
+	for _, p in ipairs(c) do
+		x1, y1 = to2d(v[p[1]+1])
+		x2, y2 = to2d(v[p[2]+1])
+    love.graphics.line(x1 * scl + w, y1 * scl + h, x2 * scl + w, y2 * scl + h)
+	end
+end
+
+function node:update(dt)
+  for _, vec in ipairs(v) do
+    angle = -dt/3
+    nv0 = math.cos(angle) * vec[1] + math.sin(angle) * vec[3]
+    nv2 = -math.sin(angle) * vec[1] + math.cos(angle) * vec[3]
+    vec[1] = nv0
+    vec[3] = nv2
+    angle = dt
+    nv1 = math.cos(angle) * vec[2] - math.sin(angle) * vec[3]
+    nv2 = math.sin(angle) * vec[2] + math.cos(angle) * vec[3]
+    vec[2] = nv1
+    vec[3] = nv2
+  end
+end
+
+return node
diff --git a/hswaw/signage/nodes/currency.lua b/hswaw/signage/nodes/currency.lua
new file mode 100644
index 0000000..a0444d5
--- /dev/null
+++ b/hswaw/signage/nodes/currency.lua
@@ -0,0 +1,64 @@
+local node = ThreadNode:extend('node.currency', {
+  threadFile = 'nodes/currency_thread.lua',
+  threadChannel = 'currency',
+
+  updateInterval = 10,
+  state = {
+    values = {},
+    changes = {},
+  },
+})
+
+local inspect = require('vendor.inspect')
+
+local textFont = love.graphics.newFont('fonts/Lato-Light.ttf', 90)
+local headFont = love.graphics.newFont('fonts/Lato-Regular.ttf', 90)
+local smallFont = love.graphics.newFont('fonts/Lato-Light.ttf', 30)
+
+function node:render()
+  love.graphics.setColor( 0, 0, 0 )
+  love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
+
+  if self.state.values and self.state.values[2] then
+    local pad = 20
+    love.graphics.setColor( 1.0, 1.0, 1.0 )
+    love.graphics.setFont(headFont)
+    love.graphics.printf("BTCPLN", 0, 180, love.graphics.getWidth()/2, 'right')
+    love.graphics.printf("TRYIDR", 0, 350, love.graphics.getWidth()/2, 'right')
+    love.graphics.setFont(textFont)
+    if self.state.changes[1] then
+      love.graphics.setColor( 0, 1.0, 0 )
+    else
+      love.graphics.setColor( 1.0, 0, 0 )
+    end
+    love.graphics.printf(self.state.values[1], love.graphics.getWidth()/2 + 2*pad, 180, love.graphics.getWidth()/2 - 2*pad, 'left')
+
+    if self.state.changes[2] then
+      love.graphics.setColor( 0, 1.0, 0 )
+    else
+      love.graphics.setColor( 1.0, 0, 0 )
+    end
+    love.graphics.printf(self.state.values[2], love.graphics.getWidth()/2 + 2*pad, 350, love.graphics.getWidth()/2 - 2*pad, 'left')
+  else
+    love.graphics.setColor( 1.0, 1.0, 1.0, 0.4 )
+
+    love.graphics.setFont(smallFont)
+    love.graphics.printf("Loading currency...", 0, love.graphics.getHeight() - 200, love.graphics.getWidth(), 'center')
+  end
+end
+
+function node:onUpdate(v)
+  for n in ipairs(v.values) do
+    if self.state.values[n] then
+      if self.state.values[n] ~= v.values[n] then
+        self.state.changes[n] = self.state.values[n] > v.values[n]
+      end
+    else
+      self.state.changes[n] = true
+    end
+  end
+
+  self.state.values = v.values
+end
+
+return node
diff --git a/hswaw/signage/nodes/currency_thread.lua b/hswaw/signage/nodes/currency_thread.lua
new file mode 100644
index 0000000..77b98dc
--- /dev/null
+++ b/hswaw/signage/nodes/currency_thread.lua
@@ -0,0 +1,22 @@
+local socket = require("socket")
+local http = require("socket.http")
+local json = require("vendor.json")
+local lume = require("vendor.lume")
+
+local btcURL = 'http://www.bitmarket.pl/json/BTCPLN/ticker.json'
+local tryURL = 'http://api.fixer.io/latest?base=TRY'
+
+local r, c, h = http.request(btcURL)
+if c == 200 then
+  btcpln = json.decode(r)['last']
+  local r, c, h = http.request(tryURL)
+  if c == 200 then
+    tryidr = json.decode(r)['rates']['IDR']
+  end
+  love.thread.getChannel('currency'):push({
+    values = {math.floor(btcpln), math.floor(tryidr)},
+  })
+  print("Update finished")
+else
+  print("Update failed")
+end
diff --git a/hswaw/signage/nodes/misery.lua b/hswaw/signage/nodes/misery.lua
new file mode 100644
index 0000000..c20ec1a
--- /dev/null
+++ b/hswaw/signage/nodes/misery.lua
@@ -0,0 +1,34 @@
+local node = ThreadNode:extend('node.misery', {
+  threadFile = 'nodes/misery_thread.lua',
+  threadChannel = 'misery',
+
+  updateInterval = 10,
+})
+
+local inspect = require('vendor.inspect')
+
+local textFont = love.graphics.newFont('fonts/Lato-Light.ttf', 50)
+local smallFont = love.graphics.newFont('fonts/Lato-Light.ttf', 30)
+
+function node:render()
+  love.graphics.setColor( 0, 0, 0 )
+  love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
+
+  if self.state then
+    love.graphics.setColor( 1.0, 1.0, 1.0 )
+    love.graphics.setFont(textFont)
+    love.graphics.printf(self.state.entry, 50, 180, love.graphics.getWidth() - 100, 'center')
+
+    love.graphics.setColor( 1.0, 1.0, 1.0, 0.8 )
+    love.graphics.setFont(smallFont)
+    local description = 'added by ' .. self.state.author .. ' on ' .. os.date('%Y/%m/%d %X', self.state.added)
+    love.graphics.printf(description, 200, love.graphics.getHeight() - 100, love.graphics.getWidth() - 400, 'center')
+  else
+    love.graphics.setColor( 1.0, 1.0, 1.0, 0.4 )
+
+    love.graphics.setFont(smallFont)
+    love.graphics.printf("Loading misery...", 0, love.graphics.getHeight() - 200, love.graphics.getWidth(), 'center')
+  end
+end
+
+return node
diff --git a/hswaw/signage/nodes/misery_thread.lua b/hswaw/signage/nodes/misery_thread.lua
new file mode 100644
index 0000000..fcfc31c
--- /dev/null
+++ b/hswaw/signage/nodes/misery_thread.lua
@@ -0,0 +1,14 @@
+local socket = require("socket")
+local https = require("https")
+local json = require("vendor.json")
+local lume = require("vendor.lume")
+
+local miseryURL = 'https://oodviewer.q3k.me/randomterm.json/_,'
+
+local c, r, h = https.request(miseryURL)
+if c == 200 then
+  love.thread.getChannel('misery'):push(json.decode(r))
+  print("Update finished")
+else
+  print("Update failed: " .. tostring(c) .. ": " .. tostring(r))
+end
diff --git a/hswaw/signage/nodes/newdash.lua b/hswaw/signage/nodes/newdash.lua
new file mode 100644
index 0000000..2e0f651
--- /dev/null
+++ b/hswaw/signage/nodes/newdash.lua
@@ -0,0 +1,123 @@
+local node = ThreadNode:extend('nodes.newdash', {
+  threadFile = 'nodes/newdash_thread.lua',
+  threadChannel = 'newdash',
+
+  updateInterval = 60,
+})
+
+local weatherFont = love.graphics.newFont('fonts/weathericons-regular-webfont.ttf', 165)
+local tempFont = love.graphics.newFont('fonts/Lato-Light.ttf', 120)
+local timeFont = love.graphics.newFont('fonts/Lato-Thin.ttf', 330)
+local dateFont = love.graphics.newFont('fonts/Lato-Light.ttf', 90)
+local headerFont = love.graphics.newFont('fonts/Lato-Regular.ttf', 40)
+local valueFont = love.graphics.newFont('fonts/Lato-Light.ttf', 45)
+local atFont = love.graphics.newFont('fonts/Lato-Light.ttf', 35)
+
+local weatherGlyphs = {
+  snow = "",
+  mist = "",
+  clear = "",
+  -- clouds = "",
+  clouds = "", -- x---DDD
+  drizzle = "",
+}
+
+function node:spejsiotData(node_id, endpoint, parameter)
+  if self.state.spejsiot[node_id] and self.state.spejsiot[node_id]["$online"] and self.state.spejsiot[node_id][endpoint] and self.state.spejsiot[node_id][endpoint][parameter] ~= nil then
+    return self.state.spejsiot[node_id][endpoint][parameter]
+  else
+    return nil
+  end
+end
+
+function node:renderIOTState(node_id, endpoint, parameter, x, y)
+  local rawValue =  self:spejsiotData(node_id, endpoint, parameter)
+  if rawValue == true then
+    love.graphics.setColor( 0, 1.0, 0 )
+    love.graphics.printf("ON", x, y, 400, 'left')
+  elseif rawValue == false then
+    love.graphics.setColor( 1.0, 0, 0 )
+    love.graphics.printf("OFF", x, y, 400, 'left')
+  elseif rawValue == nil then
+    love.graphics.setColor( 1.0, 0, 0 )
+    love.graphics.printf("OFFLINE", x, y, 400, 'left')
+  else
+    love.graphics.setColor( 1.0, 1.0, 1.0 )
+    love.graphics.printf(rawValue, x, y, 400, 'left')
+  end
+end
+
+function node:render()
+  love.graphics.setColor( 0, 0, 0 )
+  love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
+
+  if self.state then
+    love.graphics.setColor( 1.0, 1.0, 1.0 )
+
+    if weatherGlyphs[self.state.weather] then
+      love.graphics.setFont(weatherFont)
+      love.graphics.print(weatherGlyphs[self.state.weather], 100, 340)
+    else
+      love.graphics.setFont(atFont)
+      love.graphics.print(self.state.weather, 100, 370)
+    end
+
+    love.graphics.setFont(tempFont)
+    love.graphics.printf(string.format("%d°", self.state.temperature), 350, 390, 270, 'center')
+
+    love.graphics.setFont(headerFont)
+    love.graphics.printf("Ambient:", 720, 380, 160, 'right')
+    love.graphics.printf("Exhaust:", 720, 440, 160, 'right')
+    love.graphics.printf("Pope:", 720, 500, 160, 'right')
+
+    love.graphics.setFont(valueFont)
+
+    if self:spejsiotData("d106e1", "environment", "degree") then
+      love.graphics.printf(string.format(
+        "%d° / %d%%RH",
+        self:spejsiotData("d106e1", "environment", "degree"),
+        self:spejsiotData("d106e1", "environment", "humidity")
+        ), 900, 378, 400, 'left')
+    else
+      love.graphics.printf("?!", 900, 378, 400, 'left')
+    end
+
+    self:renderIOTState("c0dbe7", "relay", "on", 900, 438)
+    self:renderIOTState("0eac42", "spin and blink", "on", 900, 498)
+
+    love.graphics.setColor( 1.0, 1.0, 1.0 )
+    love.graphics.setFont(headerFont)
+    love.graphics.printf("at hackerspace:", 50, 593, 300, 'left')
+    love.graphics.setFont(atFont)
+    users = {}
+    if self.state.at then
+      users = lume.map(self.state.at.users, function(v) return v.login end)
+      if self.state.at.unknown > 0 then
+        users[#users + 1] = string.format("%d unknown creatures", self.state.at.unknown)
+      end
+      if self.state.at.kektops > 0 then
+        users[#users + 1] = string.format("%d kektops", self.state.at.kektops)
+      end
+      if self.state.at.esps > 0 then
+        users[#users + 1] = string.format("%d ESPs", self.state.at.esps)
+      end
+    end
+
+    usersList = (table.concat(users, ', ') or 'nobody...')
+
+    love.graphics.printf(usersList, 350, 598, 900, 'left')
+  else
+    love.graphics.setColor(1.0, 1.0, 1.0, 0.5)
+    love.graphics.setFont(valueFont)
+    love.graphics.printf("Loading...", 0, 530, love.graphics.getWidth(), "center")
+  end
+
+  love.graphics.setColor( 1.0, 1.0, 1.0 )
+  love.graphics.setFont(timeFont)
+  love.graphics.printf(os.date("%H:%M"), 50, -10, 850, 'center')
+  love.graphics.setFont(dateFont)
+  love.graphics.printf(os.date("%Y\n%m/%d"), 960, 80, 270, 'center')
+
+end
+
+return node
diff --git a/hswaw/signage/nodes/newdash_thread.lua b/hswaw/signage/nodes/newdash_thread.lua
new file mode 100644
index 0000000..ab256e5
--- /dev/null
+++ b/hswaw/signage/nodes/newdash_thread.lua
@@ -0,0 +1,36 @@
+local socket = require("socket")
+local https = require("https")
+local json = require("vendor.json")
+
+local weatherURL = 'https://openweathermap.org/data/2.5/weather?id=6695624&units=metric&appid=439d4b804bc8187953eb36d2a8c26a02'
+local spejsiotURL = 'https://spejsiot.waw.hackerspace.pl/api/1/devices'
+local atURL = 'https://at.hackerspace.pl/api'
+local spejsiotData = {}
+local atData = nil
+
+local c, r, h = https.request(weatherURL)
+if c == 200 then
+  local data = json.decode(r)
+
+  local c, r, h = https.request(spejsiotURL)
+  if c == 200 then
+    spejsiotData = json.decode(r)
+  end
+
+  local c, r, h = https.request(atURL)
+
+  if c == 200 then
+    atData = json.decode(r)
+  end
+
+  love.thread.getChannel('newdash'):push({
+    weather = data.weather[1].main:lower(),
+    temperature = data.main.temp,
+    lastUpdate = data.dt,
+    spejsiot = spejsiotData,
+    at = atData,
+  })
+  print("Update finished")
+else
+  print("Update failed")
+end
diff --git a/hswaw/signage/nodes/screen1.lua b/hswaw/signage/nodes/screen1.lua
new file mode 100644
index 0000000..e9b1ffb
--- /dev/null
+++ b/hswaw/signage/nodes/screen1.lua
@@ -0,0 +1,16 @@
+local node = Node:extend('nodes.screen1', {})
+
+function node:render()
+  love.graphics.setColor( 0, 0, 0 )
+  love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
+
+  for x = 0, love.graphics.getWidth() / 50, 1 do
+    for y = 0, love.graphics.getHeight() / 50, 1 do
+      local r = math.sin(x +  love.timer.getTime() * 2) + math.cos(y + love.timer.getTime() * math.sin(x));
+      love.graphics.setColor(1.0, 1.0, 1.0, (r + 2) * 1.0)
+      love.graphics.circle("line", x * 50, y * 50, r * 10)
+    end
+  end
+end
+
+return node
diff --git a/hswaw/signage/nodes/shadertoy.lua b/hswaw/signage/nodes/shadertoy.lua
new file mode 100644
index 0000000..871ef12
--- /dev/null
+++ b/hswaw/signage/nodes/shadertoy.lua
@@ -0,0 +1,117 @@
+local node = Node:extend('nodes.shadertoy', {})
+
+local smallFont = love.graphics.newFont('fonts/Lato-Light.ttf', 20)
+
+function node:init(config)
+  node.super.init(self, config)
+
+  self.path = self.path or "test.glsl"
+  self.resolution = self.resolution or {1280, 720}
+
+  self:loadShader()
+end
+
+function node:beforeEnter()
+  self:loadShader()
+end
+
+function node:loadShader()
+    local iSystem = {}
+    local header = ""
+    local ender=[[
+    vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 pixel_coords) {
+      vec2 fragCoord = texture_coords * iResolution.xy;
+      mainImage( color, fragCoord );
+      return color;
+    }
+    ]]
+    local file = io.open(self.path, "r")
+    local shaderData = file:read("*all")
+
+    shaderData = string.gsub(shaderData,"texture2D","Texel")
+    shaderData = string.gsub(shaderData,"iTime","iGlobalTime")
+    shaderData = string.gsub(shaderData,"precision highp float;","")
+
+    if string.find(shaderData,"iGlobalTime") then
+      iSystem.iGlobalTime=0
+      if not string.find(shaderData,"number iGlobalTime") then
+        header="extern number iGlobalTime;\n"..header
+      end
+    end
+
+    -- TODO
+    -- if string.find(shaderData,"iChannel") then
+    --   iSystem.iChannel={}
+    --   for k,v in pairs(shaderChannel) do
+    --     header="extern Image iChannel"..k..";\n"..header
+    --   end
+    -- end
+
+    if string.find(shaderData,"iMouse") then
+      iSystem.iMouse = {0, 0, -1, -1}
+      header = "extern vec4 iMouse;\n"..header
+    end
+
+    if string.find(shaderData,"iResolution") then
+      iSystem.iResolution = {self.resolution[1], self.resolution[2],1}
+      header = "extern vec3 iResolution;\n"..header
+    end
+
+    shaderData = header..shaderData
+    if not string.find(shaderData,"vec4 effect") then
+      shaderData = shaderData.."\n"..ender
+    end
+
+    print('Shader loaded')
+
+    self.shaderLoadError = nil
+    shaderLoaded, self.shader = pcall(love.graphics.newShader, shaderData)
+    if not shaderLoaded then
+      print('Shader load failed:', self.shader)
+      self.shaderLoadError = self.shader
+      self.shader = nil
+    else
+      print(shaderLoaded, self.shader)
+      if iSystem.iResolution then
+        self.shader:send("iResolution",iSystem.iResolution)
+      end
+
+      self.iSystem = iSystem
+      self.canvas = love.graphics.newCanvas(self.resolution[1], self.resolution[2])
+      self.renderCanvas = love.graphics.newCanvas(self.resolution[1], self.resolution[2])
+    end
+end
+
+function node:update(dt)
+  if self.shader ~= nil then
+    if self.iSystem.iGlobalTime then
+      self.iSystem.iGlobalTime=self.iSystem.iGlobalTime+dt
+      self.shader:send("iGlobalTime", self.iSystem.iGlobalTime)
+    end
+  end
+end
+
+function node:render()
+  love.graphics.setColor( 0, 0, 0 )
+  love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
+
+  if self.shaderLoadError ~= nil then
+    print('render!')
+    love.graphics.setColor(1.0, 0.0, 0.0, 1.0)
+    love.graphics.setFont(smallFont)
+    love.graphics.printf(self.shaderLoadError, 0, 0.1*love.graphics.getHeight(), love.graphics.getWidth(), 'left');
+  elseif self.shader ~= nil then
+    oldCanvas = love.graphics.getCanvas( )
+    love.graphics.setColor( 1.0, 1.0, 1.0 )
+    self.canvas:renderTo(function ()
+      love.graphics.setShader(self.shader)
+      love.graphics.draw(self.renderCanvas)
+      love.graphics.setShader()
+    end)
+    love.graphics.setCanvas(oldCanvas)
+
+    love.graphics.draw(self.canvas,0,0,math.pi,love.graphics.getWidth() / self.resolution[1], love.graphics.getHeight() / self.resolution[2], self.resolution[1], self.resolution[2])
+  end
+end
+
+return node
diff --git a/hswaw/signage/nodes/time.lua b/hswaw/signage/nodes/time.lua
new file mode 100644
index 0000000..48b5110
--- /dev/null
+++ b/hswaw/signage/nodes/time.lua
@@ -0,0 +1,25 @@
+local node = Node:extend('nodes.time', {})
+
+local textFont = love.graphics.newFont('fonts/Lato-Thin.ttf', 400)
+local smallFont = love.graphics.newFont('fonts/Lato-Light.ttf', 60)
+
+function node:init(config)
+  node.super.init(self, config)
+  self.timeFormat = self.timeFormat or '%H:%M'
+  self.dateFormat = self.dateFormat or '%Y/%m/%d'  
+end
+
+function node:render()
+  love.graphics.setColor( 0, 0, 0 )
+  love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
+
+  love.graphics.setColor( 1.0, 1.0, 1.0 )
+
+  love.graphics.setFont(textFont);
+  love.graphics.printf(os.date(self.timeFormat), 0, 0.14*love.graphics.getHeight(), love.graphics.getWidth(), 'center');
+
+  love.graphics.setFont(smallFont);
+  love.graphics.printf(os.date(self.dateFormat), 0, 0.8*love.graphics.getHeight(), love.graphics.getWidth(), 'center');
+end
+
+return node
diff --git a/hswaw/signage/nodes/weather.lua b/hswaw/signage/nodes/weather.lua
new file mode 100644
index 0000000..b29b96e
--- /dev/null
+++ b/hswaw/signage/nodes/weather.lua
@@ -0,0 +1,88 @@
+local node = ThreadNode:extend('nodes.weather', {
+  threadFile = 'nodes/weather_thread.lua',
+  threadChannel = 'weather',
+
+  updateInterval = 5 * 60,
+})
+
+local weatherFont = love.graphics.newFont('fonts/weathericons-regular-webfont.ttf', 400)
+local textFont = love.graphics.newFont('fonts/Lato-Thin.ttf', 300)
+local smallFont = love.graphics.newFont('fonts/Lato-Light.ttf', 30)
+
+local weatherGlyphs = {}
+
+local weatherGlyphsSet = {
+  day = {
+    snow = "",
+    mist = "",
+    clear = "",
+    -- clouds = "",
+    clouds = "", -- x---DDD
+    drizzle = "",
+  },
+  night = {
+    snow = "",
+    mist = "",
+    clear = "",
+    clouds = "",
+    drizzle = "",
+    
+  }
+}
+
+function node:timeOfDay()
+  local sunRise = tonumber(os.date("%H%M", self.state.sunRise)) 
+  local sunSet = tonumber(os.date("%H%M", self.state.sunSet))
+  local now = tonumber(os.date("%H%M"))
+  if sunRise == nil or sunSet == nil then
+    return weatherGlyphsSet["day"] -- smth gone wrong. assume daylight
+  end
+  if now < sunSet and now > sunRise then
+     print('day')
+     return weatherGlyphsSet["day"]
+  else
+     print('night')
+     return weatherGlyphsSet["night"]
+  end
+end
+
+function node:beforeEnter()
+  if self.state then
+    weatherGlyphs = self:timeOfDay()
+  else
+    weatherGlyphs = weatherGlyphsSet["day"] -- do not know sunraise and sunset yet. assume daylight
+  end
+end
+
+function node:render()
+  love.graphics.setColor( 0, 0, 0 )
+  love.graphics.rectangle("fill", 0, 0, love.graphics.getWidth(), love.graphics.getHeight())
+
+  if self.state then
+    love.graphics.setColor( 1.0, 1.0, 1.0 )
+
+    if weatherGlyphs[self.state.weather] then
+      love.graphics.setFont(weatherFont)
+      love.graphics.print(weatherGlyphs[self.state.weather], 120, 35)
+    else
+      love.graphics.setFont(smallFont)
+      love.graphics.print(self.state.weather, 150, love.graphics.getHeight()/2 - 60)
+    end
+
+    love.graphics.setFont(textFont)
+    love.graphics.printf(tostring(math.floor(self.state.temperature + 0.5)) .. "°", 600, 150, 650, 'center')
+
+    love.graphics.setFont(smallFont)
+    love.graphics.printf(os.date("Last update: %Y/%m/%d %H:%M", self.state.lastUpdate), 0, love.graphics.getHeight() - 60, love.graphics.getWidth(), 'center')
+    if self.state.insideTemperature then
+      love.graphics.printf("Room: " .. tostring(self.state.insideTemperature) .. "° / " .. tostring(self.state.insideHumidity) .. "%RH", 0, love.graphics.getHeight() - 100, love.graphics.getWidth(), 'center')
+    end
+  else
+    love.graphics.setColor( 1.0, 1.0, 1.0, 0.4 )
+
+    love.graphics.setFont(smallFont)
+    love.graphics.printf("Loading weather...", 0, love.graphics.getHeight() - 200, love.graphics.getWidth(), 'center')
+  end
+end
+
+return node
diff --git a/hswaw/signage/nodes/weather_thread.lua b/hswaw/signage/nodes/weather_thread.lua
new file mode 100644
index 0000000..5585e15
--- /dev/null
+++ b/hswaw/signage/nodes/weather_thread.lua
@@ -0,0 +1,32 @@
+local socket = require("socket")
+local https = require("https")
+local json = require("vendor.json")
+
+local weatherURL = 'https://openweathermap.org/data/2.5/weather?id=6695624&units=metric&appid=439d4b804bc8187953eb36d2a8c26a02'
+--local insideURL = 'https://dht01.waw.hackerspace.pl/'
+--local insideData = {}
+
+local c, r, h = https.request(weatherURL)
+if c == 200 then
+  local data = json.decode(r)
+
+  --local r, c, h = http.request(insideURL)
+  --if c == 200 then
+  --  for n in string.gmatch(string.gsub(r, ",", "."), ":%s*(%S+)[*%%]") do
+  --    insideData[#insideData+1] = n
+  --  end
+  --end
+
+  love.thread.getChannel('weather'):push({
+    weather = data.weather[1].main:lower(),
+    temperature = data.main.temp,
+    lastUpdate = data.dt,
+    sunRise = data.sys.sunrise,
+    sunSet = data.sys.sunset,
+    insideTemperature = nil,
+    insideHumidity = nil,
+  })
+  print("Update finished")
+else
+  print("Update failed")
+end
diff --git a/hswaw/signage/vendor/30log.lua b/hswaw/signage/vendor/30log.lua
new file mode 100644
index 0000000..0434ba5
--- /dev/null
+++ b/hswaw/signage/vendor/30log.lua
@@ -0,0 +1,31 @@
+local next, assert, pairs, type, tostring, setmetatable, baseMt, _instances, _classes, _class = next, assert, pairs, type, tostring, setmetatable, {}, setmetatable({},{__mode = 'k'}), setmetatable({},{__mode = 'k'})
+local function assert_call_from_class(class, method) assert(_classes[class], ('Wrong method call. Expected class:%s.'):format(method)) end; local function assert_call_from_instance(instance, method) assert(_instances[instance], ('Wrong method call. Expected instance:%s.'):format(method)) end
+local function bind(f, v) return function(...) return f(v, ...) end end
+local default_filter = function() return true end
+local function deep_copy(t, dest, aType) t = t or {}; local r = dest or {}; for k,v in pairs(t) do if aType ~= nil and type(v) == aType then r[k] = (type(v) == 'table') and ((_classes[v] or _instances[v]) and v or deep_copy(v)) or v elseif aType == nil then r[k] = (type(v) == 'table') and k~= '__index' and ((_classes[v] or _instances[v]) and v or deep_copy(v)) or v end; end return r end
+local function instantiate(call_init,self,...) assert_call_from_class(self, 'new(...) or class(...)'); local instance = {class = self}; _instances[instance] = tostring(instance); deep_copy(self, instance, 'table')
+	instance.__index, instance.__subclasses, instance.__instances, instance.mixins = nil, nil, nil, nil; setmetatable(instance,self); if call_init and self.init then if type(self.init) == 'table' then deep_copy(self.init, instance) else self.init(instance, ...) end end; return instance
+end
+local function extend(self, name, extra_params)
+	assert_call_from_class(self, 'extend(...)'); local heir = {}; _classes[heir] = tostring(heir); self.__subclasses[heir] = true; deep_copy(extra_params, deep_copy(self, heir))
+	heir.name, heir.__index, heir.super, heir.mixins = extra_params and extra_params.name or name, heir, self, {}; return setmetatable(heir,self)
+end
+baseMt = { __call = function (self,...) return self:new(...) end, __tostring = function(self,...)
+	if _instances[self] then return ("instance of '%s' (%s)"):format(rawget(self.class,'name') or '?', _instances[self]) end; return _classes[self] and ("class '%s' (%s)"):format(rawget(self,'name') or '?', _classes[self]) or self end
+}; _classes[baseMt] = tostring(baseMt); setmetatable(baseMt, {__tostring = baseMt.__tostring})
+local class = {isClass = function(t) return not not _classes[t] end, isInstance = function(t) return not not _instances[t] end}
+_class = function(name, attr) local c = deep_copy(attr); _classes[c] = tostring(c)
+	c.name, c.__tostring, c.__call, c.new, c.create, c.extend, c.__index, c.mixins, c.__instances, c.__subclasses = name or c.name, baseMt.__tostring, baseMt.__call, bind(instantiate, true), bind(instantiate, false), extend, c, setmetatable({},{__mode = 'k'}), setmetatable({},{__mode = 'k'}), setmetatable({},{__mode = 'k'})
+	c.subclasses = function(self, filter, ...) assert_call_from_class(self, 'subclasses(class)'); filter = filter or default_filter; local subclasses = {}; for class in pairs(_classes) do if class ~= baseMt and class:subclassOf(self) and filter(class,...) then subclasses[#subclasses + 1] = class end end; return subclasses end
+	c.instances = function(self, filter, ...) assert_call_from_class(self, 'instances(class)'); filter = filter or default_filter; local instances = {}; for instance in pairs(_instances) do if instance:instanceOf(self) and filter(instance, ...) then instances[#instances + 1] = instance end end; return instances end
+	c.subclassOf = function(self, superclass) assert_call_from_class(self, 'subclassOf(superclass)'); assert(class.isClass(superclass), 'Wrong argument given to method "subclassOf()". Expected a class.'); local super = self.super; while super do if super == superclass then return true end; super = super.super end; return false end
+	c.classOf = function(self, subclass) assert_call_from_class(self, 'classOf(subclass)'); assert(class.isClass(subclass), 'Wrong argument given to method "classOf()". Expected a class.'); return subclass:subclassOf(self) end
+	c.instanceOf = function(self, fromclass) assert_call_from_instance(self, 'instanceOf(class)'); assert(class.isClass(fromclass), 'Wrong argument given to method "instanceOf()". Expected a class.'); return ((self.class == fromclass) or (self.class:subclassOf(fromclass))) end
+	c.cast = function(self, toclass) assert_call_from_instance(self, 'instanceOf(class)'); assert(class.isClass(toclass), 'Wrong argument given to method "cast()". Expected a class.'); setmetatable(self, toclass); self.class = toclass; return self end
+	c.with = function(self,...) assert_call_from_class(self, 'with(mixin)'); for _, mixin in ipairs({...}) do assert(self.mixins[mixin] ~= true, ('Attempted to include a mixin which was already included in %s'):format(tostring(self))); self.mixins[mixin] = true; deep_copy(mixin, self, 'function') end return self end
+	c.includes = function(self, mixin) assert_call_from_class(self,'includes(mixin)'); return not not (self.mixins[mixin] or (self.super and self.super:includes(mixin))) end	
+	c.without = function(self, ...) assert_call_from_class(self, 'without(mixin)'); for _, mixin in ipairs({...}) do
+		assert(self.mixins[mixin] == true, ('Attempted to remove a mixin which is not included in %s'):format(tostring(self))); local classes = self:subclasses(); classes[#classes + 1] = self
+		for _, class in ipairs(classes) do for method_name, method in pairs(mixin) do if type(method) == 'function' then class[method_name] = nil end end end; self.mixins[mixin] = nil end; return self end; return setmetatable(c, baseMt) end
+class._DESCRIPTION = '30 lines library for object orientation in Lua'; class._VERSION = '30log v1.2.0'; class._URL = 'http://github.com/Yonaba/30log'; class._LICENSE = 'MIT LICENSE <http://www.opensource.org/licenses/mit-license.php>'
+return setmetatable(class,{__call = function(_,...) return _class(...) end })
\ No newline at end of file
diff --git a/hswaw/signage/vendor/debugGraph.lua b/hswaw/signage/vendor/debugGraph.lua
new file mode 100644
index 0000000..4eac7b9
--- /dev/null
+++ b/hswaw/signage/vendor/debugGraph.lua
@@ -0,0 +1,132 @@
+--[[
+  UNLICENSE
+
+    This is free and unencumbered software released into the public domain.
+
+    Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
+    software, either in source code form or as a compiled binary, for any purpose,
+    commercial or non-commercial, and by any means.
+
+    In jurisdictions that recognize copyright laws, the author or authors of this
+    software dedicate any and all copyright interest in the software to the public
+    domain.  We make this dedication for the benefit of the public at large and to
+    the detriment of our heirs and successors.  We intend this dedication to be an
+    overt act of relinquishment in perpetuity of all present and future rights to
+    this software under copyright law.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTBILITY, FITNESS
+    FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT, IN NO EVENT SHALL THE AUTHORS BE
+    LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF
+    CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+    SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+    For more information, please refer to <http://unlicense.org/>
+]]
+
+-- Code based on https://github.com/icrawler/FPSGraph
+
+local debugGraph = {}
+
+function debugGraph:new(type, x, y, width, height, delay, label, font)
+  if ({mem=0, fps=0, custom=0})[type] == nil then
+    error('Acceptable types: mem, fps, custom')
+  end
+
+  local instance = {
+    x = x or 0, -- X position
+    y = y or 0, -- Y position
+    width = width or 50, -- Graph width
+    height = height or 30, -- Graph height
+    delay = delay or 0.5, -- Update delay
+    label = label or type, -- Graph label
+    font = font or love.graphics.newFont(8),
+    data = {},
+    _max = 0,
+    _time = 0,
+    _type = type,
+  }
+
+  -- Build base data
+  for i = 0, math.floor(instance.width / 2) do
+    table.insert(instance.data, 0)
+  end
+
+  -- Updating the graph
+  function instance:update(dt, val)
+    local lastTime = self._time
+    self._time = (self._time + dt) % self.delay
+
+    -- Check if the minimum amount of time has past
+    if dt > self.delay or lastTime > self._time then
+      -- Fetch data if needed
+      if val == nil then
+        if self._type == 'fps' then
+          -- Collect fps info and update the label
+          val = 0.75 * 1 / dt + 0.25 * love.timer.getFPS()
+          self.label = "FPS: " .. math.floor(val * 10) / 10
+        elseif self._type == 'mem' then
+          -- Collect memory info and update the label
+          val = collectgarbage('count')
+          self.label = "Memory (KB): " .. math.floor(val * 10) / 10
+        else
+          -- If the val is nil then we'll just skip this time
+          return
+        end
+      end
+
+
+      -- pop the old data and push new data
+      table.remove(self.data, 1)
+      table.insert(self.data, val)
+
+      -- Find the highest value
+      local max = 0
+      for i=1, #self.data do
+        local v = self.data[i]
+        if v > max then
+          max = v
+        end
+      end
+
+      self._max = max
+    end
+  end
+
+  function instance:draw()
+    -- Store the currently set font and change the font to our own
+    local fontCache = love.graphics.getFont()
+    love.graphics.setFont(self.font)
+
+    local max = math.ceil(self._max/10) * 10 + 20
+    local len = #self.data
+    local steps = self.width / len
+
+    -- Build the line data
+    local lineData = {}
+    for i=1, len do
+      -- Build the X and Y of the point
+      local x = steps * (i - 1) + self.x
+      local y = self.height * (-self.data[i] / max + 1) + self.y
+
+      -- Append it to the line
+      table.insert(lineData, x)
+      table.insert(lineData, y)
+    end
+
+    -- Draw the line
+    love.graphics.line(unpack(lineData))
+
+    -- Print the label
+    if self.label ~= '' then
+      love.graphics.print(self.label, self.x, self.y + self.height - self.font:getHeight())
+    end
+
+    -- Reset the font
+    love.graphics.setFont(fontCache)
+  end
+
+  return instance
+end
+
+return debugGraph
diff --git a/hswaw/signage/vendor/inspect.lua b/hswaw/signage/vendor/inspect.lua
new file mode 100644
index 0000000..ae5b430
--- /dev/null
+++ b/hswaw/signage/vendor/inspect.lua
@@ -0,0 +1,341 @@
+local inspect ={
+  _VERSION = 'inspect.lua 3.1.0',
+  _URL     = 'http://github.com/kikito/inspect.lua',
+  _DESCRIPTION = 'human-readable representations of tables',
+  _LICENSE = [[
+    MIT LICENSE
+
+    Copyright (c) 2013 Enrique García Cota
+
+    Permission is hereby granted, free of charge, to any person obtaining a
+    copy of this software and associated documentation files (the
+    "Software"), to deal in the Software without restriction, including
+    without limitation the rights to use, copy, modify, merge, publish,
+    distribute, sublicense, and/or sell copies of the Software, and to
+    permit persons to whom the Software is furnished to do so, subject to
+    the following conditions:
+
+    The above copyright notice and this permission notice shall be included
+    in all copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+    OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+    MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+    IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+    CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+    TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+    SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+  ]]
+}
+
+local tostring = tostring
+
+inspect.KEY       = setmetatable({}, {__tostring = function() return 'inspect.KEY' end})
+inspect.METATABLE = setmetatable({}, {__tostring = function() return 'inspect.METATABLE' end})
+
+-- Apostrophizes the string if it has quotes, but not aphostrophes
+-- Otherwise, it returns a regular quoted string
+local function smartQuote(str)
+  if str:match('"') and not str:match("'") then
+    return "'" .. str .. "'"
+  end
+  return '"' .. str:gsub('"', '\\"') .. '"'
+end
+
+-- \a => '\\a', \0 => '\\0', 31 => '\31'
+local shortControlCharEscapes = {
+  ["\a"] = "\\a",  ["\b"] = "\\b", ["\f"] = "\\f", ["\n"] = "\\n",
+  ["\r"] = "\\r",  ["\t"] = "\\t", ["\v"] = "\\v"
+}
+local longControlCharEscapes = {} -- \a => nil, \0 => \000, 31 => \031
+for i=0, 31 do
+  local ch = string.char(i)
+  if not shortControlCharEscapes[ch] then
+    shortControlCharEscapes[ch] = "\\"..i
+    longControlCharEscapes[ch]  = string.format("\\%03d", i)
+  end
+end
+
+local function escape(str)
+  return (str:gsub("\\", "\\\\")
+             :gsub("(%c)%f[0-9]", longControlCharEscapes)
+             :gsub("%c", shortControlCharEscapes))
+end
+
+local function isIdentifier(str)
+  return type(str) == 'string' and str:match( "^[_%a][_%a%d]*$" )
+end
+
+local function isSequenceKey(k, sequenceLength)
+  return type(k) == 'number'
+     and 1 <= k
+     and k <= sequenceLength
+     and math.floor(k) == k
+end
+
+local defaultTypeOrders = {
+  ['number']   = 1, ['boolean']  = 2, ['string'] = 3, ['table'] = 4,
+  ['function'] = 5, ['userdata'] = 6, ['thread'] = 7
+}
+
+local function sortKeys(a, b)
+  local ta, tb = type(a), type(b)
+
+  -- strings and numbers are sorted numerically/alphabetically
+  if ta == tb and (ta == 'string' or ta == 'number') then return a < b end
+
+  local dta, dtb = defaultTypeOrders[ta], defaultTypeOrders[tb]
+  -- Two default types are compared according to the defaultTypeOrders table
+  if dta and dtb then return defaultTypeOrders[ta] < defaultTypeOrders[tb]
+  elseif dta     then return true  -- default types before custom ones
+  elseif dtb     then return false -- custom types after default ones
+  end
+
+  -- custom types are sorted out alphabetically
+  return ta < tb
+end
+
+-- For implementation reasons, the behavior of rawlen & # is "undefined" when
+-- tables aren't pure sequences. So we implement our own # operator.
+local function getSequenceLength(t)
+  local len = 1
+  local v = rawget(t,len)
+  while v ~= nil do
+    len = len + 1
+    v = rawget(t,len)
+  end
+  return len - 1
+end
+
+local function getNonSequentialKeys(t)
+  local keys = {}
+  local sequenceLength = getSequenceLength(t)
+  for k,_ in pairs(t) do
+    if not isSequenceKey(k, sequenceLength) then table.insert(keys, k) end
+  end
+  table.sort(keys, sortKeys)
+  return keys, sequenceLength
+end
+
+local function getToStringResultSafely(t, mt)
+  local __tostring = type(mt) == 'table' and rawget(mt, '__tostring')
+  local str, ok
+  if type(__tostring) == 'function' then
+    ok, str = pcall(__tostring, t)
+    str = ok and str or 'error: ' .. tostring(str)
+  end
+  if type(str) == 'string' and #str > 0 then return str end
+end
+
+local function countTableAppearances(t, tableAppearances)
+  tableAppearances = tableAppearances or {}
+
+  if type(t) == 'table' then
+    if not tableAppearances[t] then
+      tableAppearances[t] = 1
+      for k,v in pairs(t) do
+        countTableAppearances(k, tableAppearances)
+        countTableAppearances(v, tableAppearances)
+      end
+      countTableAppearances(getmetatable(t), tableAppearances)
+    else
+      tableAppearances[t] = tableAppearances[t] + 1
+    end
+  end
+
+  return tableAppearances
+end
+
+local copySequence = function(s)
+  local copy, len = {}, #s
+  for i=1, len do copy[i] = s[i] end
+  return copy, len
+end
+
+local function makePath(path, ...)
+  local keys = {...}
+  local newPath, len = copySequence(path)
+  for i=1, #keys do
+    newPath[len + i] = keys[i]
+  end
+  return newPath
+end
+
+local function processRecursive(process, item, path, visited)
+
+    if item == nil then return nil end
+    if visited[item] then return visited[item] end
+
+    local processed = process(item, path)
+    if type(processed) == 'table' then
+      local processedCopy = {}
+      visited[item] = processedCopy
+      local processedKey
+
+      for k,v in pairs(processed) do
+        processedKey = processRecursive(process, k, makePath(path, k, inspect.KEY), visited)
+        if processedKey ~= nil then
+          processedCopy[processedKey] = processRecursive(process, v, makePath(path, processedKey), visited)
+        end
+      end
+
+      local mt  = processRecursive(process, getmetatable(processed), makePath(path, inspect.METATABLE), visited)
+      setmetatable(processedCopy, mt)
+      processed = processedCopy
+    end
+    return processed
+end
+
+
+
+-------------------------------------------------------------------
+
+local Inspector = {}
+local Inspector_mt = {__index = Inspector}
+
+function Inspector:puts(...)
+  local args   = {...}
+  local buffer = self.buffer
+  local len    = #buffer
+  for i=1, #args do
+    len = len + 1
+    buffer[len] = args[i]
+  end
+end
+
+function Inspector:down(f)
+  self.level = self.level + 1
+  f()
+  self.level = self.level - 1
+end
+
+function Inspector:tabify()
+  self:puts(self.newline, string.rep(self.indent, self.level))
+end
+
+function Inspector:alreadyVisited(v)
+  return self.ids[v] ~= nil
+end
+
+function Inspector:getId(v)
+  local id = self.ids[v]
+  if not id then
+    local tv = type(v)
+    id              = (self.maxIds[tv] or 0) + 1
+    self.maxIds[tv] = id
+    self.ids[v]     = id
+  end
+  return tostring(id)
+end
+
+function Inspector:putKey(k)
+  if isIdentifier(k) then return self:puts(k) end
+  self:puts("[")
+  self:putValue(k)
+  self:puts("]")
+end
+
+function Inspector:putTable(t)
+  if t == inspect.KEY or t == inspect.METATABLE then
+    self:puts(tostring(t))
+  elseif self:alreadyVisited(t) then
+    self:puts('<table ', self:getId(t), '>')
+  elseif self.level >= self.depth then
+    self:puts('{...}')
+  else
+    if self.tableAppearances[t] > 1 then self:puts('<', self:getId(t), '>') end
+
+    local nonSequentialKeys, sequenceLength = getNonSequentialKeys(t)
+    local mt                = getmetatable(t)
+    local toStringResult    = getToStringResultSafely(t, mt)
+
+    self:puts('{')
+    self:down(function()
+      if toStringResult then
+        self:puts(' -- ', escape(toStringResult))
+        if sequenceLength >= 1 then self:tabify() end
+      end
+
+      local count = 0
+      for i=1, sequenceLength do
+        if count > 0 then self:puts(',') end
+        self:puts(' ')
+        self:putValue(t[i])
+        count = count + 1
+      end
+
+      for _,k in ipairs(nonSequentialKeys) do
+        if count > 0 then self:puts(',') end
+        self:tabify()
+        self:putKey(k)
+        self:puts(' = ')
+        self:putValue(t[k])
+        count = count + 1
+      end
+
+      if mt then
+        if count > 0 then self:puts(',') end
+        self:tabify()
+        self:puts('<metatable> = ')
+        self:putValue(mt)
+      end
+    end)
+
+    if #nonSequentialKeys > 0 or mt then -- result is multi-lined. Justify closing }
+      self:tabify()
+    elseif sequenceLength > 0 then -- array tables have one extra space before closing }
+      self:puts(' ')
+    end
+
+    self:puts('}')
+  end
+end
+
+function Inspector:putValue(v)
+  local tv = type(v)
+
+  if tv == 'string' then
+    self:puts(smartQuote(escape(v)))
+  elseif tv == 'number' or tv == 'boolean' or tv == 'nil' then
+    self:puts(tostring(v))
+  elseif tv == 'table' then
+    self:putTable(v)
+  else
+    self:puts('<',tv,' ',self:getId(v),'>')
+  end
+end
+
+-------------------------------------------------------------------
+
+function inspect.inspect(root, options)
+  options       = options or {}
+
+  local depth   = options.depth   or math.huge
+  local newline = options.newline or '\n'
+  local indent  = options.indent  or '  '
+  local process = options.process
+
+  if process then
+    root = processRecursive(process, root, {}, {})
+  end
+
+  local inspector = setmetatable({
+    depth            = depth,
+    level            = 0,
+    buffer           = {},
+    ids              = {},
+    maxIds           = {},
+    newline          = newline,
+    indent           = indent,
+    tableAppearances = countTableAppearances(root)
+  }, Inspector_mt)
+
+  inspector:putValue(root)
+
+  return table.concat(inspector.buffer)
+end
+
+setmetatable(inspect, { __call = function(_, ...) return inspect.inspect(...) end })
+
+return inspect
+
diff --git a/hswaw/signage/vendor/json.lua b/hswaw/signage/vendor/json.lua
new file mode 100644
index 0000000..dda6193
--- /dev/null
+++ b/hswaw/signage/vendor/json.lua
@@ -0,0 +1,380 @@
+--
+-- json.lua
+--
+-- Copyright (c) 2015 rxi
+--
+-- This library is free software; you can redistribute it and/or modify it
+-- under the terms of the MIT license. See LICENSE for details.
+--
+
+local json = { _version = "0.1.0" }
+
+-------------------------------------------------------------------------------
+-- Encode
+-------------------------------------------------------------------------------
+
+local encode
+
+local escape_char_map = {
+  [ "\\" ] = "\\\\",
+  [ "\"" ] = "\\\"",
+  [ "\b" ] = "\\b",
+  [ "\f" ] = "\\f",
+  [ "\n" ] = "\\n",
+  [ "\r" ] = "\\r",
+  [ "\t" ] = "\\t",
+}
+
+local escape_char_map_inv = { [ "\\/" ] = "/" }
+for k, v in pairs(escape_char_map) do
+  escape_char_map_inv[v] = k
+end
+
+
+local function escape_char(c)
+  return escape_char_map[c] or string.format("\\u%04x", c:byte())
+end
+
+
+local function encode_nil(val)
+  return "null"
+end 
+
+
+local function encode_table(val, stack)
+  local res = {}
+  stack = stack or {}
+
+  -- Circular reference?
+  if stack[val] then error("circular reference") end
+
+  stack[val] = true
+
+  if val[1] ~= nil or next(val) == nil then
+    -- Treat as array -- check keys are valid and it is not sparse
+    local n = 0
+    for k in pairs(val) do
+      if type(k) ~= "number" then
+        error("invalid table: mixed or invalid key types")
+      end
+      n = n + 1
+    end
+    if n ~= #val then
+      error("invalid table: sparse array")
+    end
+    -- Encode
+    for i, v in ipairs(val) do
+      table.insert(res, encode(v, stack))
+    end
+    stack[val] = nil
+    return "[" .. table.concat(res, ",") .. "]"
+
+  else
+    -- Treat as an object
+    for k, v in pairs(val) do
+      if type(k) ~= "string" then
+        error("invalid table: mixed or invalid key types")
+      end
+      table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
+    end
+    stack[val] = nil
+    return "{" .. table.concat(res, ",") .. "}"
+  end
+end
+
+
+local function encode_string(val)
+  return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
+end
+
+
+local function encode_number(val)
+  -- Check for NaN, -inf and inf
+  if val ~= val or val <= -math.huge or val >= math.huge then
+    error("unexpected number value '" .. tostring(val) .. "'")
+  end
+  return string.format("%.14g", val)
+end
+
+
+local type_func_map = {
+  [ "nil"     ] = encode_nil,
+  [ "table"   ] = encode_table,
+  [ "string"  ] = encode_string,
+  [ "number"  ] = encode_number,
+  [ "boolean" ] = tostring,
+}
+
+
+encode = function(val, stack)
+  local t = type(val)
+  local f = type_func_map[t]
+  if f then
+    return f(val, stack)
+  end
+  error("unexpected type '" .. t .. "'")
+end
+
+
+function json.encode(val)
+  return ( encode(val) )
+end
+
+
+-------------------------------------------------------------------------------
+-- Decode
+-------------------------------------------------------------------------------
+
+local parse
+
+local function create_set(...) 
+  local res = {}
+  for i = 1, select("#", ...) do
+    res[ select(i, ...) ] = true
+  end
+  return res
+end
+
+local space_chars   = create_set(" ", "\t", "\r", "\n")
+local delim_chars   = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
+local escape_chars  = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
+local literals      = create_set("true", "false", "null")
+
+local literal_map = {
+  [ "true"  ] = true,
+  [ "false" ] = false,
+  [ "null"  ] = nil,
+}
+
+
+local function next_char(str, idx, set, negate)
+  for i = idx, #str do
+    if set[str:sub(i, i)] ~= negate then
+      return i
+    end
+  end
+  return #str + 1
+end
+
+
+local function decode_error(str, idx, msg)
+  local line_count = 1
+  local col_count = 1
+  for i = 1, idx - 1 do
+    col_count = col_count + 1
+    if str:sub(i, i) == "\n" then
+      line_count = line_count + 1
+      col_count = 1
+    end
+  end
+  error( string.format("%s at line %d col %d", msg, line_count, col_count) )
+end
+
+
+local function codepoint_to_utf8(n)
+  -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
+  local f = math.floor
+  if n <= 0x7f then
+    return string.char(n)
+  elseif n <= 0x7ff then
+    return string.char(f(n / 64) + 192, n % 64 + 128)
+  elseif n <= 0xffff then
+    return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
+  elseif n <= 0x10ffff then
+    return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
+                       f(n % 4096 / 64) + 128, n % 64 + 128)
+  end
+  error( string.format("invalid unicode codepoint '%x'", n) )
+end
+
+
+local function parse_unicode_escape(s)
+  local n1 = tonumber( s:sub(3, 6),  16 )
+  local n2 = tonumber( s:sub(9, 12), 16 )
+  -- Surrogate pair?
+  if n2 then
+    return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
+  else
+    return codepoint_to_utf8(n1)
+  end
+end
+
+
+local function parse_string(str, i)
+  local has_unicode_escape = false
+  local has_surrogate_escape = false
+  local has_escape = false
+  local last
+  for j = i + 1, #str do
+    local x = str:byte(j)
+
+    if x < 32 then
+      decode_error(str, j, "control character in string")
+    end
+
+    if last == 92 then -- "\\" (escape char)
+      if x == 117 then -- "u" (unicode escape sequence)
+        local hex = str:sub(j + 1, j + 5)
+        if not hex:find("%x%x%x%x") then
+          decode_error(str, j, "invalid unicode escape in string")
+        end
+        if hex:find("^[dD][89aAbB]") then
+          has_surrogate_escape = true
+        else
+          has_unicode_escape = true
+        end
+      else
+        local c = string.char(x)
+        if not escape_chars[c] then
+          decode_error(str, j, "invalid escape char '" .. c .. "' in string")
+        end
+        has_escape = true
+      end
+      last = nil
+
+    elseif x == 34 then -- '"' (end of string)
+      local s = str:sub(i + 1, j - 1)
+      if has_surrogate_escape then 
+        s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
+      end
+      if has_unicode_escape then 
+        s = s:gsub("\\u....", parse_unicode_escape)
+      end
+      if has_escape then
+        s = s:gsub("\\.", escape_char_map_inv)
+      end
+      return s, j + 1
+    
+    else
+      last = x
+    end
+  end
+  decode_error(str, i, "expected closing quote for string")
+end
+
+
+local function parse_number(str, i)
+  local x = next_char(str, i, delim_chars)
+  local s = str:sub(i, x - 1)
+  local n = tonumber(s)
+  if not n then
+    decode_error(str, i, "invalid number '" .. s .. "'")
+  end
+  return n, x
+end
+
+
+local function parse_literal(str, i)
+  local x = next_char(str, i, delim_chars)
+  local word = str:sub(i, x - 1)
+  if not literals[word] then
+    decode_error(str, i, "invalid literal '" .. word .. "'")
+  end
+  return literal_map[word], x
+end
+
+
+local function parse_array(str, i)
+  local res = {}
+  local n = 1
+  i = i + 1
+  while 1 do
+    local x
+    i = next_char(str, i, space_chars, true)
+    -- Empty / end of array?
+    if str:sub(i, i) == "]" then 
+      i = i + 1
+      break
+    end
+    -- Read token
+    x, i = parse(str, i)
+    res[n] = x
+    n = n + 1
+    -- Next token 
+    i = next_char(str, i, space_chars, true)
+    local chr = str:sub(i, i)
+    i = i + 1
+    if chr == "]" then break end
+    if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
+  end
+  return res, i
+end
+
+
+local function parse_object(str, i)
+  local res = {}
+  i = i + 1
+  while 1 do
+    local key, val
+    i = next_char(str, i, space_chars, true)
+    -- Empty / end of object?
+    if str:sub(i, i) == "}" then 
+      i = i + 1
+      break
+    end
+    -- Read key
+    if str:sub(i, i) ~= '"' then
+      decode_error(str, i, "expected string for key")
+    end
+    key, i = parse(str, i)
+    -- Read ':' delimiter
+    i = next_char(str, i, space_chars, true)
+    if str:sub(i, i) ~= ":" then
+      decode_error(str, i, "expected ':' after key")
+    end
+    i = next_char(str, i + 1, space_chars, true)
+    -- Read value
+    val, i = parse(str, i)
+    -- Set
+    res[key] = val
+    -- Next token
+    i = next_char(str, i, space_chars, true)
+    local chr = str:sub(i, i)
+    i = i + 1
+    if chr == "}" then break end
+    if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
+  end
+  return res, i
+end
+
+
+local char_func_map = {
+  [ '"' ] = parse_string,
+  [ "0" ] = parse_number,
+  [ "1" ] = parse_number,
+  [ "2" ] = parse_number,
+  [ "3" ] = parse_number,
+  [ "4" ] = parse_number,
+  [ "5" ] = parse_number,
+  [ "6" ] = parse_number,
+  [ "7" ] = parse_number,
+  [ "8" ] = parse_number,
+  [ "9" ] = parse_number,
+  [ "-" ] = parse_number,
+  [ "t" ] = parse_literal,
+  [ "f" ] = parse_literal,
+  [ "n" ] = parse_literal,
+  [ "[" ] = parse_array,
+  [ "{" ] = parse_object,
+}
+
+
+parse = function(str, idx)
+  local chr = str:sub(idx, idx)
+  local f = char_func_map[chr]
+  if f then
+    return f(str, idx)
+  end
+  decode_error(str, idx, "unexpected character '" .. chr .. "'")
+end
+
+
+function json.decode(str)
+  if type(str) ~= "string" then
+    error("expected argument of type string, got " .. type(str))
+  end
+  return ( parse(str, next_char(str, 1, space_chars, true)) )
+end
+
+
+return json
diff --git a/hswaw/signage/vendor/lume.lua b/hswaw/signage/vendor/lume.lua
new file mode 100644
index 0000000..3004507
--- /dev/null
+++ b/hswaw/signage/vendor/lume.lua
@@ -0,0 +1,772 @@
+--
+-- lume
+--
+-- Copyright (c) 2016 rxi
+--
+-- This library is free software; you can redistribute it and/or modify it
+-- under the terms of the MIT license. See LICENSE for details.
+--
+
+local lume = { _version = "2.2.3" }
+
+local pairs, ipairs = pairs, ipairs
+local type, assert, unpack = type, assert, unpack or table.unpack
+local tostring, tonumber = tostring, tonumber
+local math_floor = math.floor
+local math_ceil = math.ceil
+local math_atan2 = math.atan2 or math.atan
+local math_sqrt = math.sqrt
+local math_abs = math.abs
+
+local noop = function()
+end
+
+local identity = function(x)
+  return x
+end
+
+local patternescape = function(str)
+  return str:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")
+end
+
+local absindex = function(len, i)
+  return i < 0 and (len + i + 1) or i
+end
+
+local iscallable = function(x)
+  if type(x) == "function" then return true end
+  local mt = getmetatable(x)
+  return mt and mt.__call ~= nil
+end
+
+local getiter = function(x)
+  if lume.isarray(x) then
+    return ipairs
+  elseif type(x) == "table" then
+    return pairs
+  end
+  error("expected table", 3)
+end
+
+local iteratee = function(x)
+  if x == nil then return identity end
+  if iscallable(x) then return x end
+  if type(x) == "table" then
+    return function(z)
+      for k, v in pairs(x) do
+        if z[k] ~= v then return false end
+      end
+      return true
+    end
+  end
+  return function(z) return z[x] end
+end
+
+
+
+function lume.clamp(x, min, max)
+  return x < min and min or (x > max and max or x)
+end
+
+
+function lume.round(x, increment)
+  if increment then return lume.round(x / increment) * increment end
+  return x >= 0 and math_floor(x + .5) or math_ceil(x - .5)
+end
+
+
+function lume.sign(x)
+  return x < 0 and -1 or 1
+end
+
+
+function lume.lerp(a, b, amount)
+  return a + (b - a) * lume.clamp(amount, 0, 1)
+end
+
+
+function lume.smooth(a, b, amount)
+  local t = lume.clamp(amount, 0, 1)
+  local m = t * t * (3 - 2 * t)
+  return a + (b - a) * m
+end
+
+
+function lume.pingpong(x)
+  return 1 - math_abs(1 - x % 2)
+end
+
+
+function lume.distance(x1, y1, x2, y2, squared)
+  local dx = x1 - x2
+  local dy = y1 - y2
+  local s = dx * dx + dy * dy
+  return squared and s or math_sqrt(s)
+end
+
+
+function lume.angle(x1, y1, x2, y2)
+  return math_atan2(y2 - y1, x2 - x1)
+end
+
+
+function lume.vector(angle, magnitude)
+  return math.cos(angle) * magnitude, math.sin(angle) * magnitude
+end
+
+
+function lume.random(a, b)
+  if not a then a, b = 0, 1 end
+  if not b then b = 0 end
+  return a + math.random() * (b - a)
+end
+
+
+function lume.randomchoice(t)
+  return t[math.random(#t)]
+end
+
+
+function lume.weightedchoice(t)
+  local sum = 0
+  for _, v in pairs(t) do
+    assert(v >= 0, "weight value less than zero")
+    sum = sum + v
+  end
+  assert(sum ~= 0, "all weights are zero")
+  local rnd = lume.random(sum)
+  for k, v in pairs(t) do
+    if rnd < v then return k end
+    rnd = rnd - v
+  end
+end
+
+
+function lume.isarray(x)
+  return (type(x) == "table" and x[1] ~= nil) and true or false
+end
+
+
+function lume.push(t, ...)
+  local n = select("#", ...)
+  for i = 1, n do
+    t[#t + 1] = select(i, ...)
+  end
+  return ...
+end
+
+
+function lume.remove(t, x)
+  local iter = getiter(t)
+  for i, v in iter(t) do
+    if v == x then
+      if lume.isarray(t) then
+        table.remove(t, i)
+        break
+      else
+        t[i] = nil
+        break
+      end
+    end
+  end
+  return x
+end
+
+
+function lume.clear(t)
+  local iter = getiter(t)
+  for k in iter(t) do
+    t[k] = nil
+  end
+  return t
+end
+
+
+function lume.extend(t, ...)
+  for i = 1, select("#", ...) do
+    local x = select(i, ...)
+    if x then
+      for k, v in pairs(x) do
+        t[k] = v
+      end
+    end
+  end
+  return t
+end
+
+
+function lume.shuffle(t)
+  local rtn = {}
+  for i = 1, #t do
+    local r = math.random(i)
+    if r ~= i then
+      rtn[i] = rtn[r]
+    end
+    rtn[r] = t[i]
+  end
+  return rtn
+end
+
+
+function lume.sort(t, comp)
+  local rtn = lume.clone(t)
+  if comp then
+    if type(comp) == "string" then
+      table.sort(rtn, function(a, b) return a[comp] < b[comp] end)
+    else
+      table.sort(rtn, comp)
+    end
+  else
+    table.sort(rtn)
+  end
+  return rtn
+end
+
+
+function lume.array(...)
+  local t = {}
+  for x in ... do t[#t + 1] = x end
+  return t
+end
+
+
+function lume.each(t, fn, ...)
+  local iter = getiter(t)
+  if type(fn) == "string" then
+    for _, v in iter(t) do v[fn](v, ...) end
+  else
+    for _, v in iter(t) do fn(v, ...) end
+  end
+  return t
+end
+
+
+function lume.map(t, fn)
+  fn = iteratee(fn)
+  local iter = getiter(t)
+  local rtn = {}
+  for k, v in iter(t) do rtn[k] = fn(v) end
+  return rtn
+end
+
+
+function lume.all(t, fn)
+  fn = iteratee(fn)
+  local iter = getiter(t)
+  for _, v in iter(t) do
+    if not fn(v) then return false end
+  end
+  return true
+end
+
+
+function lume.any(t, fn)
+  fn = iteratee(fn)
+  local iter = getiter(t)
+  for _, v in iter(t) do
+    if fn(v) then return true end
+  end
+  return false
+end
+
+
+function lume.reduce(t, fn, first)
+  local acc = first
+  local started = first and true or false
+  local iter = getiter(t)
+  for _, v in iter(t) do
+    if started then
+      acc = fn(acc, v)
+    else
+      acc = v
+      started = true
+    end
+  end
+  assert(started, "reduce of an empty table with no first value")
+  return acc
+end
+
+
+function lume.set(t)
+  local rtn = {}
+  for k in pairs(lume.invert(t)) do
+    rtn[#rtn + 1] = k
+  end
+  return rtn
+end
+
+
+function lume.filter(t, fn, retainkeys)
+  fn = iteratee(fn)
+  local iter = getiter(t)
+  local rtn = {}
+  if retainkeys then
+    for k, v in iter(t) do
+      if fn(v) then rtn[k] = v end
+    end
+  else
+    for _, v in iter(t) do
+      if fn(v) then rtn[#rtn + 1] = v end
+    end
+  end
+  return rtn
+end
+
+
+function lume.reject(t, fn, retainkeys)
+  fn = iteratee(fn)
+  local iter = getiter(t)
+  local rtn = {}
+  if retainkeys then
+    for k, v in iter(t) do
+      if not fn(v) then rtn[k] = v end
+    end
+  else
+    for _, v in iter(t) do
+      if not fn(v) then rtn[#rtn + 1] = v end
+    end
+  end
+  return rtn
+end
+
+
+function lume.merge(...)
+  local rtn = {}
+  for i = 1, select("#", ...) do
+    local t = select(i, ...)
+    local iter = getiter(t)
+    for k, v in iter(t) do
+      rtn[k] = v
+    end
+  end
+  return rtn
+end
+
+
+function lume.concat(...)
+  local rtn = {}
+  for i = 1, select("#", ...) do
+    local t = select(i, ...)
+    if t ~= nil then
+      local iter = getiter(t)
+      for _, v in iter(t) do
+        rtn[#rtn + 1] = v
+      end
+    end
+  end
+  return rtn
+end
+
+
+function lume.find(t, value)
+  local iter = getiter(t)
+  for k, v in iter(t) do
+    if v == value then return k end
+  end
+  return nil
+end
+
+
+function lume.match(t, fn)
+  fn = iteratee(fn)
+  local iter = getiter(t)
+  for k, v in iter(t) do
+    if fn(v) then return v, k end
+  end
+  return nil
+end
+
+
+function lume.count(t, fn)
+  local count = 0
+  local iter = getiter(t)
+  if fn then
+    fn = iteratee(fn)
+    for _, v in iter(t) do
+      if fn(v) then count = count + 1 end
+    end
+  else
+    if lume.isarray(t) then
+      return #t
+    end
+    for _ in iter(t) do count = count + 1 end
+  end
+  return count
+end
+
+
+function lume.slice(t, i, j)
+  i = i and absindex(#t, i) or 1
+  j = j and absindex(#t, j) or #t
+  local rtn = {}
+  for x = i < 1 and 1 or i, j > #t and #t or j do
+    rtn[#rtn + 1] = t[x]
+  end
+  return rtn
+end
+
+
+function lume.first(t, n)
+  if not n then return t[1] end
+  return lume.slice(t, 1, n)
+end
+
+
+function lume.last(t, n)
+  if not n then return t[#t] end
+  return lume.slice(t, -n, -1)
+end
+
+
+function lume.invert(t)
+  local rtn = {}
+  for k, v in pairs(t) do rtn[v] = k end
+  return rtn
+end
+
+
+function lume.pick(t, ...)
+  local rtn = {}
+  for i = 1, select("#", ...) do
+    local k = select(i, ...)
+    rtn[k] = t[k]
+  end
+  return rtn
+end
+
+
+function lume.keys(t)
+  local rtn = {}
+  local iter = getiter(t)
+  for k in iter(t) do rtn[#rtn + 1] = k end
+  return rtn
+end
+
+
+function lume.clone(t)
+  local rtn = {}
+  for k, v in pairs(t) do rtn[k] = v end
+  return rtn
+end
+
+
+function lume.fn(fn, ...)
+  assert(iscallable(fn), "expected a function as the first argument")
+  local args = { ... }
+  return function(...)
+    local a = lume.concat(args, { ... })
+    return fn(unpack(a))
+  end
+end
+
+
+function lume.once(fn, ...)
+  local f = lume.fn(fn, ...)
+  local done = false
+  return function(...)
+    if done then return end
+    done = true
+    return f(...)
+  end
+end
+
+
+local memoize_fnkey = {}
+local memoize_nil = {}
+
+function lume.memoize(fn)
+  local cache = {}
+  return function(...)
+    local c = cache
+    for i = 1, select("#", ...) do
+      local a = select(i, ...) or memoize_nil
+      c[a] = c[a] or {}
+      c = c[a]
+    end
+    c[memoize_fnkey] = c[memoize_fnkey] or {fn(...)}
+    return unpack(c[memoize_fnkey])
+  end
+end
+
+
+function lume.combine(...)
+  local n = select('#', ...)
+  if n == 0 then return noop end
+  if n == 1 then
+    local fn = select(1, ...)
+    if not fn then return noop end
+    assert(iscallable(fn), "expected a function or nil")
+    return fn
+  end
+  local funcs = {}
+  for i = 1, n do
+    local fn = select(i, ...)
+    if fn ~= nil then
+      assert(iscallable(fn), "expected a function or nil")
+      funcs[#funcs + 1] = fn
+    end
+  end
+  return function(...)
+    for _, f in ipairs(funcs) do f(...) end
+  end
+end
+
+
+function lume.call(fn, ...)
+  if fn then
+    return fn(...)
+  end
+end
+
+
+function lume.time(fn, ...)
+  local start = os.clock()
+  local rtn = {fn(...)}
+  return (os.clock() - start), unpack(rtn)
+end
+
+
+local lambda_cache = {}
+
+function lume.lambda(str)
+  if not lambda_cache[str] then
+    local args, body = str:match([[^([%w,_ ]-)%->(.-)$]])
+    assert(args and body, "bad string lambda")
+    local s = "return function(" .. args .. ")\nreturn " .. body .. "\nend"
+    lambda_cache[str] = lume.dostring(s)
+  end
+  return lambda_cache[str]
+end
+
+
+local serialize
+
+local serialize_map = {
+  [ "boolean" ] = tostring,
+  [ "nil"     ] = tostring,
+  [ "string"  ] = function(v) return string.format("%q", v) end,
+  [ "number"  ] = function(v)
+    if      v ~=  v     then return  "0/0"      --  nan
+    elseif  v ==  1 / 0 then return  "1/0"      --  inf
+    elseif  v == -1 / 0 then return "-1/0" end  -- -inf
+    return tostring(v)
+  end,
+  [ "table"   ] = function(t, stk)
+    stk = stk or {}
+    if stk[t] then error("circular reference") end
+    local rtn = {}
+    stk[t] = true
+    for k, v in pairs(t) do
+      rtn[#rtn + 1] = "[" .. serialize(k, stk) .. "]=" .. serialize(v, stk)
+    end
+    stk[t] = nil
+    return "{" .. table.concat(rtn, ",") .. "}"
+  end
+}
+
+setmetatable(serialize_map, {
+  __index = function(_, k) error("unsupported serialize type: " .. k) end
+})
+
+serialize = function(x, stk)
+  return serialize_map[type(x)](x, stk)
+end
+
+function lume.serialize(x)
+  return serialize(x)
+end
+
+
+function lume.deserialize(str)
+  return lume.dostring("return " .. str)
+end
+
+
+function lume.split(str, sep)
+  if not sep then
+    return lume.array(str:gmatch("([%S]+)"))
+  else
+    assert(sep ~= "", "empty separator")
+    local psep = patternescape(sep)
+    return lume.array((str..sep):gmatch("(.-)("..psep..")"))
+  end
+end
+
+
+function lume.trim(str, chars)
+  if not chars then return str:match("^[%s]*(.-)[%s]*$") end
+  chars = patternescape(chars)
+  return str:match("^[" .. chars .. "]*(.-)[" .. chars .. "]*$")
+end
+
+
+function lume.wordwrap(str, limit)
+  limit = limit or 72
+  local check
+  if type(limit) == "number" then
+    check = function(s) return #s >= limit end
+  else
+    check = limit
+  end
+  local rtn = {}
+  local line = ""
+  for word, spaces in str:gmatch("(%S+)(%s*)") do
+    local s = line .. word
+    if check(s) then
+      table.insert(rtn, line .. "\n")
+      line = word
+    else
+      line = s
+    end
+    for c in spaces:gmatch(".") do
+      if c == "\n" then
+        table.insert(rtn, line .. "\n")
+        line = ""
+      else
+        line = line .. c
+      end
+    end
+  end
+  table.insert(rtn, line)
+  return table.concat(rtn)
+end
+
+
+function lume.format(str, vars)
+  if not vars then return str end
+  local f = function(x)
+    return tostring(vars[x] or vars[tonumber(x)] or "{" .. x .. "}")
+  end
+  return (str:gsub("{(.-)}", f))
+end
+
+
+function lume.trace(...)
+  local info = debug.getinfo(2, "Sl")
+  local t = { info.short_src .. ":" .. info.currentline .. ":" }
+  for i = 1, select("#", ...) do
+    local x = select(i, ...)
+    if type(x) == "number" then
+      x = string.format("%g", lume.round(x, .01))
+    end
+    t[#t + 1] = tostring(x)
+  end
+  print(table.concat(t, " "))
+end
+
+
+function lume.dostring(str)
+  return assert((loadstring or load)(str))()
+end
+
+
+function lume.uuid()
+  local fn = function(x)
+    local r = math.random(16) - 1
+    r = (x == "x") and (r + 1) or (r % 4) + 9
+    return ("0123456789abcdef"):sub(r, r)
+  end
+  return (("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"):gsub("[xy]", fn))
+end
+
+
+function lume.hotswap(modname)
+  local oldglobal = lume.clone(_G)
+  local updated = {}
+  local function update(old, new)
+    if updated[old] then return end
+    updated[old] = true
+    local oldmt, newmt = getmetatable(old), getmetatable(new)
+    if oldmt and newmt then update(oldmt, newmt) end
+    for k, v in pairs(new) do
+      if type(v) == "table" then update(old[k], v) else old[k] = v end
+    end
+  end
+  local err = nil
+  local function onerror(e)
+    for k in pairs(_G) do _G[k] = oldglobal[k] end
+    err = lume.trim(e)
+  end
+  local ok, oldmod = pcall(require, modname)
+  oldmod = ok and oldmod or nil
+  xpcall(function()
+    package.loaded[modname] = nil
+    local newmod = require(modname)
+    if type(oldmod) == "table" then update(oldmod, newmod) end
+    for k, v in pairs(oldglobal) do
+      if v ~= _G[k] and type(v) == "table" then
+        update(v, _G[k])
+        _G[k] = v
+      end
+    end
+  end, onerror)
+  package.loaded[modname] = oldmod
+  if err then return nil, err end
+  return oldmod
+end
+
+
+local ripairs_iter = function(t, i)
+  i = i - 1
+  local v = t[i]
+  if v then return i, v end
+end
+
+function lume.ripairs(t)
+  return ripairs_iter, t, (#t + 1)
+end
+
+
+function lume.color(str, mul)
+  mul = mul or 1
+  local r, g, b, a
+  r, g, b = str:match("#(%x%x)(%x%x)(%x%x)")
+  if r then
+    r = tonumber(r, 16) / 0xff
+    g = tonumber(g, 16) / 0xff
+    b = tonumber(b, 16) / 0xff
+    a = 1
+  elseif str:match("rgba?%s*%([%d%s%.,]+%)") then
+    local f = str:gmatch("[%d.]+")
+    r = (f() or 0) / 0xff
+    g = (f() or 0) / 0xff
+    b = (f() or 0) / 0xff
+    a = f() or 1
+  else
+    error(("bad color string '%s'"):format(str))
+  end
+  return r * mul, g * mul, b * mul, a * mul
+end
+
+
+function lume.rgba(color)
+  local a = math_floor((color / 16777216) % 256)
+  local r = math_floor((color /    65536) % 256)
+  local g = math_floor((color /      256) % 256)
+  local b = math_floor((color) % 256)
+  return r, g, b, a
+end
+
+
+local chain_mt = {}
+chain_mt.__index = lume.map(lume.filter(lume, iscallable, true),
+  function(fn)
+    return function(self, ...)
+      self._value = fn(self._value, ...)
+      return self
+    end
+  end)
+chain_mt.__index.result = function(x) return x._value end
+
+function lume.chain(value)
+  return setmetatable({ _value = value }, chain_mt)
+end
+
+setmetatable(lume,  {
+  __call = function(_, ...)
+    return lume.chain(...)
+  end
+})
+
+
+return lume
diff --git a/hswaw/signage/vendor/lurker.lua b/hswaw/signage/vendor/lurker.lua
new file mode 100644
index 0000000..9200ee7
--- /dev/null
+++ b/hswaw/signage/vendor/lurker.lua
@@ -0,0 +1,269 @@
+--
+-- lurker
+--
+-- Copyright (c) 2018 rxi
+--
+-- This library is free software; you can redistribute it and/or modify it
+-- under the terms of the MIT license. See LICENSE for details.
+--
+
+-- Assumes lume is in the same directory as this file if it does not exist
+-- as a global
+local lume = rawget(_G, "lume") or require((...):gsub("[^/.\\]+$", "lume"))
+
+local lurker = { _version = "1.0.1" }
+
+
+local dir = love.filesystem.enumerate or love.filesystem.getDirectoryItems
+local time = love.timer.getTime or os.time
+
+local function isdir(path)
+    local info = love.filesystem.getInfo(path)
+    return info.type == "directory"
+end
+
+local function lastmodified(path)
+    local info = love.filesystem.getInfo(path, "file")
+    return info.modtime
+end
+
+local lovecallbacknames = {
+  "update",
+  "load",
+  "draw",
+  "mousepressed",
+  "mousereleased",
+  "keypressed",
+  "keyreleased",
+  "focus",
+  "quit",
+}
+
+
+function lurker.init()
+  lurker.print("Initing lurker")
+  lurker.path = "."
+  lurker.preswap = function() end
+  lurker.postswap = function() end
+  lurker.interval = .5
+  lurker.protected = true
+  lurker.quiet = false
+  lurker.lastscan = 0
+  lurker.lasterrorfile = nil
+  lurker.files = {}
+  lurker.funcwrappers = {}
+  lurker.lovefuncs = {}
+  lurker.state = "init"
+  lume.each(lurker.getchanged(), lurker.resetfile)
+  return lurker
+end
+
+
+function lurker.print(...)
+  print("[lurker] " .. lume.format(...))
+end
+
+
+function lurker.listdir(path, recursive, skipdotfiles)
+  path = (path == ".") and "" or path
+  local function fullpath(x) return path .. "/" .. x end
+  local t = {}
+  for _, f in pairs(lume.map(dir(path), fullpath)) do
+    if not skipdotfiles or not f:match("/%.[^/]*$") then
+      if recursive and isdir(f) then
+        t = lume.concat(t, lurker.listdir(f, true, true))
+      else
+        table.insert(t, lume.trim(f, "/"))
+      end
+    end
+  end
+  return t
+end
+
+
+function lurker.initwrappers()
+  for _, v in pairs(lovecallbacknames) do
+    lurker.funcwrappers[v] = function(...)
+      local args = {...}
+      xpcall(function()
+        return lurker.lovefuncs[v] and lurker.lovefuncs[v](unpack(args))
+      end, lurker.onerror)
+    end
+    lurker.lovefuncs[v] = love[v]
+  end
+  lurker.updatewrappers()
+end
+
+
+function lurker.updatewrappers()
+  for _, v in pairs(lovecallbacknames) do
+    if love[v] ~= lurker.funcwrappers[v] then
+      lurker.lovefuncs[v] = love[v]
+      love[v] = lurker.funcwrappers[v]
+    end
+  end
+end
+
+
+function lurker.onerror(e, nostacktrace)
+  lurker.print("An error occurred; switching to error state")
+  lurker.state = "error"
+
+  -- Release mouse
+  local setgrab = love.mouse.setGrab or love.mouse.setGrabbed
+  setgrab(false)
+
+  -- Set up callbacks
+  for _, v in pairs(lovecallbacknames) do
+    love[v] = function() end
+  end
+
+  love.update = lurker.update
+
+  love.keypressed = function(k)
+    if k == "escape" then
+      lurker.print("Exiting...")
+      love.event.quit()
+    end
+  end
+
+  local stacktrace = nostacktrace and "" or
+                     lume.trim((debug.traceback("", 2):gsub("\t", "")))
+  local msg = lume.format("{1}\n\n{2}", {e, stacktrace})
+  local colors = {
+    { lume.color("#1e1e2c", 256) },
+    { lume.color("#f0a3a3", 256) },
+    { lume.color("#92b5b0", 256) },
+    { lume.color("#66666a", 256) },
+    { lume.color("#cdcdcd", 256) },
+  }
+  love.graphics.reset()
+  love.graphics.setFont(love.graphics.newFont(12))
+
+  love.draw = function()
+    local pad = 25
+    local width = love.graphics.getWidth()
+
+    local function drawhr(pos, color1, color2)
+      local animpos = lume.smooth(pad, width - pad - 8, lume.pingpong(time()))
+      if color1 then love.graphics.setColor(color1) end
+      love.graphics.rectangle("fill", pad, pos, width - pad*2, 1)
+      if color2 then love.graphics.setColor(color2) end
+      love.graphics.rectangle("fill", animpos, pos, 8, 1)
+    end
+
+    local function drawtext(str, x, y, color, limit)
+      love.graphics.setColor(color)
+      love.graphics[limit and "printf" or "print"](str, x, y, limit)
+    end
+
+    love.graphics.setBackgroundColor(colors[1])
+    love.graphics.clear()
+
+    drawtext("An error has occurred", pad, pad, colors[2])
+    drawtext("lurker", width - love.graphics.getFont():getWidth("lurker") -
+             pad, pad, colors[4])
+    drawhr(pad + 32, colors[4], colors[5])
+    drawtext("If you fix the problem and update the file the program will " ..
+             "resume", pad, pad + 46, colors[3])
+    drawhr(pad + 72, colors[4], colors[5])
+    drawtext(msg, pad, pad + 90, colors[5], width - pad * 2)
+
+    love.graphics.reset()
+  end
+end
+
+
+function lurker.exitinitstate()
+  lurker.state = "normal"
+  if lurker.protected then
+    lurker.initwrappers()
+  end
+end
+
+
+function lurker.exiterrorstate()
+  lurker.state = "normal"
+  for _, v in pairs(lovecallbacknames) do
+    love[v] = lurker.funcwrappers[v]
+  end
+end
+
+
+function lurker.update()
+  if lurker.state == "init" then
+    lurker.exitinitstate()
+  end
+  local diff = time() - lurker.lastscan
+  if diff > lurker.interval then
+    lurker.lastscan = lurker.lastscan + diff
+    local changed = lurker.scan()
+    if #changed > 0 and lurker.lasterrorfile then
+      local f = lurker.lasterrorfile
+      lurker.lasterrorfile = nil
+      lurker.hotswapfile(f)
+    end
+  end
+end
+
+
+function lurker.getchanged()
+  local function fn(f)
+    return f:match("%.lua$") and lurker.files[f] ~= lastmodified(f)
+  end
+  return lume.filter(lurker.listdir(lurker.path, true, true), fn)
+end
+
+
+function lurker.modname(f)
+  return (f:gsub("%.lua$", ""):gsub("[/\\]", "."))
+end
+
+
+function lurker.resetfile(f)
+  lurker.files[f] = lastmodified(f)
+end
+
+
+function lurker.hotswapfile(f)
+  lurker.print("Hotswapping '{1}'...", {f})
+  if lurker.state == "error" then
+    lurker.exiterrorstate()
+  end
+  if lurker.preswap(f) then
+    lurker.print("Hotswap of '{1}' aborted by preswap", {f})
+    lurker.resetfile(f)
+    return
+  end
+  local modname = lurker.modname(f)
+  local t, ok, err = lume.time(lume.hotswap, modname)
+  if ok then
+    lurker.print("Swapped '{1}' in {2} secs", {f, t})
+  else
+    lurker.print("Failed to swap '{1}' : {2}", {f, err})
+    if not lurker.quiet and lurker.protected then
+      lurker.lasterrorfile = f
+      lurker.onerror(err, true)
+      lurker.resetfile(f)
+      return
+    end
+  end
+  lurker.resetfile(f)
+  lurker.postswap(f)
+  if lurker.protected then
+    lurker.updatewrappers()
+  end
+end
+
+
+function lurker.scan()
+  if lurker.state == "init" then
+    lurker.exitinitstate()
+  end
+  local changed = lurker.getchanged()
+  lume.each(changed, lurker.hotswapfile)
+  return changed
+end
+
+
+return lurker.init()
diff --git a/hswaw/signage/vendor/push.lua b/hswaw/signage/vendor/push.lua
new file mode 100644
index 0000000..cb1a947
--- /dev/null
+++ b/hswaw/signage/vendor/push.lua
@@ -0,0 +1,131 @@
+-- push.lua v0.1
+
+-- Copyright (c) 2016 Ulysse Ramage
+-- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+-- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+local push = {}
+setmetatable(push, push)
+
+function push:setupScreen(WWIDTH, WHEIGHT, RWIDTH, RHEIGHT, f)
+
+  f = f or {}
+
+  self._WWIDTH, self._WHEIGHT = WWIDTH, WHEIGHT
+  self._RWIDTH, self._RHEIGHT = RWIDTH, RHEIGHT
+  self._fullscreen = f.fullscreen or self._fullscreen or  false
+  self._resizable = f.resizable or self._resizable or false
+  if f.canvas == nil then f.canvas = true end
+
+  love.window.setMode( self._RWIDTH, self._RHEIGHT, {fullscreen = self._fullscreen, borderless = false, resizable = self._resizable} )
+
+  self:initValues()
+
+  if f.canvas then self:createCanvas() end
+
+  self._borderColor = {0, 0, 0}
+
+  self._drawFunctions = {["start"]=push.start, ["end"]=push.finish}
+end
+
+function push:createCanvas()
+  self._canvas = love.graphics.newCanvas(self._WWIDTH, self._WHEIGHT)
+end
+
+function push:initValues()
+  self._SCALEX, self._SCALEY = self._RWIDTH/self._WWIDTH, self._RHEIGHT/self._WHEIGHT
+  self._SCALE = math.min(self._SCALEX, self._SCALEY)
+  self._OFFSET = {x = (self._SCALEX - self._SCALE) * (self._WWIDTH/2), y = (self._SCALEY - self._SCALE) * (self._WHEIGHT/2)}
+  self._GWIDTH, self._GHEIGHT = self._RWIDTH-self._OFFSET.x*2, self._RHEIGHT-self._OFFSET.y*2
+
+  self._INV_SCALE = 1/self._SCALE
+end
+
+function push:setShader(shader)
+  self._shader = shader
+end
+
+--[[ DEPRECATED ]]--
+function push:apply(operation, shader)
+  if operation == "start" then
+    self:start()
+  elseif operation == "finish" or operation == "end" then
+    self:finish(shader)
+  end
+end
+
+function push:start()
+  if self._canvas then
+    love.graphics.push()
+    love.graphics.setCanvas(self._canvas)
+  else
+    love.graphics.translate(self._OFFSET.x, self._OFFSET.y)
+    love.graphics.setScissor(self._OFFSET.x, self._OFFSET.y, self._WWIDTH*self._SCALE, self._WHEIGHT*self._SCALE)
+    love.graphics.push()
+    love.graphics.scale(self._SCALE)
+  end
+end
+
+function push:finish(shader)
+  love.graphics.setBackgroundColor(unpack(self._borderColor))
+  if self._canvas then
+    love.graphics.pop()
+    love.graphics.setCanvas()
+
+    love.graphics.translate(self._OFFSET.x, self._OFFSET.y)
+    love.graphics.setColor(1.0, 1.0, 1.0)
+    love.graphics.setShader(shader or self._shader)
+    love.graphics.draw(self._canvas, 0, 0, 0, self._SCALE, self._SCALE)
+    love.graphics.setCanvas(self._canvas)
+    love.graphics.clear()
+    love.graphics.setCanvas()
+    love.graphics.setShader()
+  else
+    love.graphics.pop()
+    love.graphics.setScissor()
+  end
+end
+
+function push:calculateScale(offset)
+  self._SCALEX, self._SCALEY = self._RWIDTH/self._WWIDTH, self._RHEIGHT/self._WHEIGHT
+  self._SCALE = math.min(self._SCALEX, self._SCALEY)+offset
+  self._OFFSET = {x = (self._SCALEX - self._SCALE) * (self._WWIDTH/2), y = (self._SCALEY - self._SCALE) * (self._WHEIGHT/2)}
+end
+
+function push:setBorderColor(color, g, b)
+  self._borderColor = g and {color, g, b} or color
+end
+
+function push:toGame(x, y)
+  x, y = x-self._OFFSET.x, y-self._OFFSET.y
+  local normalX, normalY = x/self._GWIDTH, y/self._GHEIGHT
+  x, y = (x>=0 and x<=self._WWIDTH*self._SCALE) and normalX*self._WWIDTH or nil, (y>=0 and y<=self._WHEIGHT*self._SCALE) and normalY*self._WHEIGHT or nil
+  return x, y
+end
+
+--doesn't work - TODO
+function push:toReal(x, y)
+  return x+self._OFFSET.x, y+self._OFFSET.y
+end
+
+function push:switchFullscreen(winw, winh)
+  self._fullscreen = not self._fullscreen
+  local windowWidth, windowHeight = love.window.getDesktopDimensions()
+  self._RWIDTH = self._fullscreen and windowWidth or winw or windowWidth*.5
+  self._RHEIGHT = self._fullscreen and windowHeight or winh or windowHeight*.5
+  self:initValues()
+  love.window.setFullscreen(self._fullscreen, "desktop")
+end
+
+function push:resize(w, h)
+  self._RWIDTH = w
+  self._RHEIGHT = h
+  self:initValues()
+end
+
+function push:getWidth() return self._WWIDTH end
+function push:getHeight() return self._WHEIGHT end
+function push:getDimensions() return self._WWIDTH, self._WHEIGHT end
+
+return push