diff --git a/timers/config.yml b/timers/config.yml
new file mode 100644
index 0000000..8b56c23
--- /dev/null
+++ b/timers/config.yml
@@ -0,0 +1,5 @@
+name: "timers"
+packageKey: "timers"
+permissions:
+ lan: {}
+ discovery: {}
diff --git a/timers/profiles/timer-profile.yml b/timers/profiles/timer-profile.yml
new file mode 100644
index 0000000..33b3dcf
--- /dev/null
+++ b/timers/profiles/timer-profile.yml
@@ -0,0 +1,32 @@
+name: timer.v1
+components:
+ - id: main
+ capabilities:
+ - id: switch
+ version: 1
+ - id: expected
+ capabilities:
+ - id: atmosphericPressureMeasurement
+ version: 1
+ - id: actual
+ capabilities:
+ - id: atmosphericPressureMeasurement
+ version: 1
+
+preferences:
+ - preferenceType: number
+ name: timeout
+ title: Seconds
+ required: true
+ description: Number of seconds this timer should wait
+ definition:
+ min: 0.5
+ max: 600
+ default: 1
+ - preferenceType: boolean
+ name: interval
+ title: Interval
+ description: If the timer should repeat once started
+ required: true
+ definition:
+ default: false
diff --git a/timers/profiles/timer_bridge-profile.yml b/timers/profiles/timer_bridge-profile.yml
new file mode 100644
index 0000000..cac510e
--- /dev/null
+++ b/timers/profiles/timer_bridge-profile.yml
@@ -0,0 +1,10 @@
+name: timer_bridge.v1
+components:
+ - id: main
+ capabilities:
+ - id: honestadmin11679.targetcreate
+ version: 1
+ - id: honestadmin11679.targetCount
+ version: 1
+ - id: honestadmin11679.currentUrl
+ version: 1
diff --git a/timers/readme.md b/timers/readme.md
new file mode 100644
index 0000000..2f76034
--- /dev/null
+++ b/timers/readme.md
@@ -0,0 +1 @@
+# Timers
diff --git a/timers/src/disco.lua b/timers/src/disco.lua
new file mode 100644
index 0000000..7c74554
--- /dev/null
+++ b/timers/src/disco.lua
@@ -0,0 +1,89 @@
+local json = require "dkjson"
+local log = require "log"
+local utils = require "st.utils"
+
+--- Add a new device to this driver
+---
+---@param driver Driver The driver instance to use
+---@param device_number number|nil If populated this will be used to generate the device name/label if not, `get_device_list`
+--- will be called to provide this value
+local function add_timer_device(driver, device_number, parent_device_id)
+ log.trace("add_timer_device")
+ if device_number == nil then
+ log.debug("determining current device count")
+ local device_list = driver.device_api.get_device_list()
+ device_number = #device_list
+ end
+ local device_name = "Timer " .. device_number
+ log.debug("adding device " .. device_name)
+ local device_id = utils.generate_uuid_v4() .. tostring(device_number)
+ local device_info = {
+ type = "LAN",
+ deviceNetworkId = device_id,
+ label = device_name,
+ parent_device_id = parent_device_id,
+ profileReference = "timer.v1",
+ vendorProvidedName = device_name,
+ }
+ local device_info_json = json.encode(device_info)
+ local success, msg = driver.device_api.create_device(device_info_json)
+ if success then
+ log.debug("successfully created device")
+ return device_name, device_id
+ end
+ log.error(string.format("unsuccessful create_device (sensor) %s", msg))
+ return nil, nil, msg
+end
+
+local function add_bridge_device(driver)
+ log.trace("add_bridge_device")
+ local device_id = utils.generate_uuid_v4()
+ local device_name = "Timer Bridge"
+ local device_info = {
+ type = "LAN",
+ deviceNetworkId = device_id,
+ label = device_name,
+ profileReference = "timer_bridge.v1",
+ vendorProvidedName = device_name,
+ }
+ local device_info_json = json.encode(device_info)
+ local success, msg = driver.device_api.create_device(device_info_json)
+ if success then
+ log.debug("successfully created device")
+ return device_name, device_id
+ end
+ log.error(string.format("unsuccessful create_device (bridge) %s", msg))
+ return nil, nil, msg
+end
+
+--- A discovery pass that will discover exactly 1 device
+--- for a driver. I any devices are already associated with
+--- this driver, no devices will be discovered
+---
+---@param driver Driver the driver name to use when discovering a device
+---@param opts table the discovery options
+---@param cont function function to check if discovery should continue
+local function disco_handler(driver, opts, cont)
+ log.trace("disco")
+
+ if cont() then
+ local device_list = driver:get_devices()
+ log.trace("starting discovery")
+ for _idx, device in ipairs(device_list or {}) do
+ if device:supports_capability_by_id("honestadmin11679.targetcreate") then
+ return
+ end
+ end
+ local device_name, device_id, err = add_bridge_device(driver)
+ if err ~= nil then
+ log.error(err)
+ return
+ end
+ log.info("added new device " .. device_name)
+ end
+end
+
+return {
+ disco_handler = disco_handler,
+ add_timer_device = add_timer_device,
+}
diff --git a/timers/src/driver_name.lua b/timers/src/driver_name.lua
new file mode 100644
index 0000000..560d945
--- /dev/null
+++ b/timers/src/driver_name.lua
@@ -0,0 +1 @@
+return "timers"
diff --git a/timers/src/init.lua b/timers/src/init.lua
new file mode 100644
index 0000000..fe58882
--- /dev/null
+++ b/timers/src/init.lua
@@ -0,0 +1,287 @@
+local capabilities = require "st.capabilities"
+local Driver = require "st.driver"
+local log = require "log"
+
+local discovery = require "disco"
+local server = require "server"
+local utils = require "st.utils"
+local cosock = require "cosock"
+
+local currentUrlID = "honestadmin11679.currentUrl"
+local currentUrl = capabilities[currentUrlID]
+
+local createTargetId = "honestadmin11679.targetcreate"
+local createTarget = capabilities[createTargetId];
+
+local targetCountId = "honestadmin11679.targetCount"
+local targetCount = capabilities[targetCountId]
+
+local Time = capabilities.atmosphericPressureMeasurement
+Time.time = Time.atmosphericPressure
+local Switch = capabilities.switch
+
+local function is_bridge(device)
+ return device:supports_capability_by_id(targetCountId)
+end
+
+local function device_init(driver, device)
+ if is_bridge(device) then
+ local dev_ids = driver:get_devices() or {""}
+ log.debug("Emitting target count ", #dev_ids - 1)
+ local ev = targetCount.targetCount(math.max(#dev_ids - 1, 0))
+ device:emit_event(ev)
+ else
+ local state = driver:get_state_object(device)
+ log.debug(utils.stringify_table(state, "state", true))
+ device:emit_event(Switch.switch.off())
+ device:emit_component_event(device.profile.components.expected,
+ Time.time(device.preferences.timeout or 1))
+ driver:send_to_all_sse({
+ event = "init",
+ device_id = device.id,
+ device_name = device.label,
+ state = state,
+ })
+ end
+end
+
+local function device_removed(driver, device)
+ log.trace("Removed http_sensor " .. device.id)
+ driver:send_to_all_sse({
+ event = "removed",
+ device_id = device.id,
+ })
+end
+
+local function info_changed(driver, device, event, args)
+ log.trace("Info Changed ", device.id)
+ local timer = device:get_field("timer")
+ if timer then
+ driver:cancel_timer(timer)
+ device:set_field("timer", nil)
+ end
+ device:emit_component_event(device.profile.components.expected,
+ Time.time(device.preferences.timeout or 1))
+ device:emit_event(Switch.switch.off())
+end
+
+local function do_refresh(driver, device)
+ -- If this is a sensor device, re-emit the stored state
+ if not is_bridge(device) then
+ device_init(driver, device)
+ return
+ end
+ -- If this is a bridge device, re-emit the state for all devices
+ for _, device in ipairs(driver:get_devices()) do
+ if not is_bridge(device) then
+ device_init(driver, device)
+ end
+ end
+end
+
+local function timer_expired(driver, device)
+ print("timer expired")
+ local now = cosock.socket.gettime()
+ local started = device:get_field("last-start")
+ local elapsed = -1
+ if started then
+ elapsed = now - started
+ end
+ device:emit_component_event(device.profile.components.actual, Time.time(elapsed))
+ driver:send_all_states_to_sse(device)
+end
+
+
+local function do_switch(driver, device, on)
+ print("do_switch", device.id, utils.stringify_table(device.preferences))
+ local ev = on and Switch.switch.on() or Switch.switch.off()
+ device:emit_event(ev)
+ print("emitted event")
+ local existing_timer = device:get_field("timer")
+ if existing_timer then
+ print("canceling existing timer")
+ driver:cancel_timer(existing_timer)
+ end
+ if on then
+ local new_timer
+ local to = math.max(device.preferences.timeout or 0, 1)
+ device:set_field("last-start", cosock.socket.gettime())
+ if device.preferences.interval then
+ new_timer = device.thread:call_on_schedule(to, function()
+ timer_expired(driver, device)
+ end)
+ else
+ new_timer = device.thread:call_with_delay(to, function()
+ timer_expired(driver, device)
+ device:set_field("last-start", nil)
+ device:emit_event(Switch.switch.off())
+ end)
+ end
+ device:set_field("timer", new_timer)
+ end
+ driver:send_all_states_to_sse(device)
+end
+
+function Driver:get_bridge_id()
+ if self.bridge_id and #self.bridge_id > 0 then
+ return self.bridge_id
+ end
+ for _, device in ipairs(self:get_devices()) do
+ if is_bridge(device) then
+ self.bridge_id = device.id
+ return self.bridge_id
+ end
+ end
+ error("No devices were bridges!")
+end
+
+function Driver:send_all_states_to_sse(device, supp)
+ self:send_to_all_sse({
+ event = "update",
+ device_id = device.id,
+ device_name = device.label,
+ state = supp or self:get_state_object(device),
+ })
+end
+
+function Driver:emit_state(device, state)
+ do_switch(self, device, state.switch == "on")
+end
+
+
+function Driver:send_to_all_sse(event)
+ local not_closed = {}
+ for i, tx in ipairs(self.sse_txs) do
+ print("sending event to tx ", i)
+ local _, err = tx:send(event)
+ if err ~= "closed" then
+ table.insert(not_closed, tx)
+ end
+ end
+ self.sse_txs = not_closed
+
+end
+
+function Driver:get_state_object(device)
+ print("Driver:get_state_object")
+ return {
+ switch = device:get_latest_state("main", Switch.ID, Switch.switch.NAME),
+ timeout = device.preferences.timeout,
+ interval = device.preferences.interval,
+ last_duration = device:get_latest_state("actual", Time.ID, Time.time.NAME),
+ }
+end
+
+function Driver:get_url()
+ if self.server == nil or self.server.port == nil then
+ log.info("waiting for server to start")
+ return
+ end
+ local ip = self.server:get_ip()
+ local port = self.server.port
+ if ip == nil then
+ return
+ end
+ return string.format("http://%s:%s", ip, port)
+end
+
+function Driver:get_sensor_states()
+ local devices_list = {}
+ for _, device in ipairs(self:get_devices()) do
+ if not is_bridge(device) then
+ local state = self:get_sensor_state(device)
+ table.insert(devices_list, state)
+ end
+ end
+ return devices_list
+end
+
+function Driver:get_sensor_state(device)
+ print("Driver:get_sensor_state", device.label or device.id)
+ if is_bridge(device) then
+ print("device is a bridge!")
+ return nil, "device is bridge"
+ end
+ print("getting state object")
+ local state = self:get_state_object(device)
+ return {
+ device_id = device.id,
+ device_name = device.label,
+ state = state,
+ }
+end
+
+function Driver:emit_current_url()
+ local url = self:get_url()
+ local bridge
+ for i, device in ipairs(self:get_devices()) do
+ if device:supports_capability_by_id(currentUrlID) then
+ self.bridge_id = device.id
+ bridge = device
+ break
+ end
+ end
+ if url and bridge then
+ bridge:emit_event(currentUrl.currentUrl(url))
+ end
+end
+
+local driver = Driver(require("driver_name"), {
+ lifecycle_handlers = {
+ init = device_init,
+ added = device_init,
+ removed = device_removed,
+ infoChanged = info_changed,
+ },
+ discovery = discovery.disco_handler,
+ driver_lifecycle = function()
+ os.exit()
+ end,
+ capability_handlers = {
+ [createTargetId] = {
+ ["create"] = function(driver, device)
+ log.info("createTarget")
+ discovery.add_timer_device(driver, nil, driver:get_bridge_id())
+ end,
+ },
+ [capabilities.switch.ID] = {
+ [capabilities.switch.commands.on.NAME] = function(driver, device)
+ do_switch(driver, device, true)
+ end,
+ [capabilities.switch.commands.off.NAME] = function(driver, device)
+ do_switch(driver, device, false)
+ end,
+ },
+ },
+})
+
+driver.sse_txs = {}
+
+cosock.spawn(function()
+ local url, new_url, bridge_id, bridge
+ while true do
+ bridge_id = driver:get_bridge_id()
+ if not bridge_id then
+ goto continue
+ end
+ bridge = driver:get_device_info(bridge_id)
+ if not bridge then
+ goto continue
+ end
+ new_url = driver:get_url()
+ if not new_url then
+ goto continue
+ end
+ if new_url == url then
+ goto continue
+ end
+ url = new_url
+ bridge:emit_event(currentUrl.currentUrl(new_url))
+ ::continue::
+ cosock.socket.sleep(10)
+ end
+end)
+
+server(driver)
+
+driver:run()
diff --git a/timers/src/server.lua b/timers/src/server.lua
new file mode 100644
index 0000000..156332e
--- /dev/null
+++ b/timers/src/server.lua
@@ -0,0 +1,209 @@
+local lux = require "luxure"
+local sse = require "luxure.sse"
+local cosock = require "cosock"
+local dkjson = require "st.json"
+local log = require "log"
+local static = require "static"
+local discovery = require "disco"
+
+---Find the Hub's IP address if not populated in the
+--- environment info
+---@param driver Driver
+---@return string|nil
+local function find_hub_ip(driver)
+ if driver.environment_info.hub_ipv4 then
+ return driver.environment_info.hub_ipv4
+ end
+ local s = cosock.socket:udp()
+ -- The IP address here doesn't seem to matter so long as its
+ -- isn't '*'
+ s:setpeername("192.168.0.0", 0)
+ local localip, _, _ = s:getsockname()
+ return localip
+end
+
+--- Setup a multicast UDP socket to listen for the string "whereareyou"
+--- which will respond with the full url for the server.
+local function setup_multicast_disocvery(server)
+ local function gen_url(server)
+ local server_ip = assert(server:get_ip())
+ return string.format("http://%s:%s", server:get_ip(), server.port)
+ end
+
+ cosock.spawn(function()
+ while true do
+ local ip = "239.255.255.250"
+ local port = 9887
+ local sock = cosock.socket.udp()
+ print("setting up socket")
+ assert(sock:setoption("reuseaddr", true))
+ assert(sock:setsockname(ip, port))
+ assert(sock:setoption("ip-add-membership", {
+ multiaddr = ip,
+ interface = "0.0.0.0",
+ }))
+ assert(sock:setoption("ip-multicast-loop", false))
+ assert(sock:sendto(gen_url(server), ip, port))
+ sock:settimeout(60)
+ while true do
+ print("receiving from")
+ local bytes, ip_or_err, rport = sock:receivefrom()
+ print("recv:", bytes, ip_or_err, rport)
+ if ip_or_err == "timeout" or bytes == "whereareyou" then
+ print("sending broadcast")
+ assert(sock:sendto(gen_url(server), ip, port))
+ else
+ print("Error in multicast listener: ", ip_or_err)
+ break
+ end
+ end
+ end
+ end)
+end
+
+return function(driver)
+ local server = lux.Server.new_with(assert(cosock.socket.tcp()), {
+ env = "debug",
+ })
+ --- Connect the server up to a new socket
+ server:listen()
+ --- spawn a lua coroutine that will accept incomming connections and router
+ --- their http requests
+ cosock.spawn(function()
+ while true do
+ server:tick(print)
+ end
+ end)
+
+ --- Middleware to log all incoming requests with the method and path
+ server:use(function(req, res, next)
+ log.debug(string.format("%s %s", req.method, req.url.path))
+ next(req, res)
+ end)
+
+ --- Middleware to redirect all 404s to /index.html
+ server:use(function(req, res, next)
+ if (not req.url.path) or req.url.path == "/" then
+ req.url.path = "/index.html"
+ end
+ return next(req, res)
+ end)
+
+ --- Middleware for parsing json bodies
+ server:use(function(req, res, next)
+ local h = req:get_headers()
+ if req.method ~= "GET" and h:get_one("content-type") == "application/json" then
+ req.raw_body = req:get_body()
+ assert(req.raw_body)
+ local success, body = pcall(dkjson.decode, req.raw_body)
+ if success then
+ req.body = body
+ else
+ print("failed to parse json", body)
+ end
+ end
+ next(req, res)
+ end)
+
+ --- The static routes
+ server:get("/index.html", function(req, res)
+ res:set_content_type("text/html")
+ res:send(static:html())
+ end)
+ server:get("/index.js", function(req, res)
+ res:set_content_type("text/javascript")
+ res:send(static:js())
+ end)
+ server:get("/style.css", function(req, res)
+ res:set_content_type("text/css")
+ res:send(static:css())
+ end)
+
+ server:get("/device/:device_id", function(req, res)
+ local dev = lux.Error.assert(driver:get_device_info(req.params.device_id))
+ local state = lux.Error.assert(driver:get_sensor_state(dev))
+ res:send(dkjson.encode(state))
+ end)
+
+ server:get("/info", function(req, res)
+ local devices_list = driver:get_sensor_states()
+ res:send(dkjson.encode(devices_list))
+ end)
+
+ --- Create a new http button on this hub
+ server:post("/newdevice", function(req, res)
+ local device_name, device_id, err_msg = discovery.add_timer_device(driver, nil,
+ driver:get_bridge_id())
+ if err_msg ~= nil then
+ log.error("error creating new device " .. err_msg)
+ res:set_status(503):send("Failed to add new device ")
+ return
+ end
+ res:send(dkjson.encode({
+ device_id = device_id,
+ device_name = device_name,
+ }))
+ end)
+
+ --- Handle the state update for a device
+ server:put("/device_state", function(req, res)
+ if not req.body.device_id or not req.body.state then
+ res:set_status(400):send("bad request")
+ return
+ end
+ local device = driver:get_device_info(req.body.device_id)
+ if not device then
+ res:set_status(404):send("device not found")
+ return
+ end
+ print("emitting state")
+ driver:emit_state(device, req.body.state)
+ print("replying with raw body")
+ res:send(req.raw_body)
+ end)
+
+ server:get("/subscribe", function(req, res)
+ local tx, rx = cosock.channel.new()
+ table.insert(driver.sse_txs, tx)
+ print("creating sse stream")
+ local stream = sse.Sse.new(res, 4)
+ print("starting sse loop")
+ while true do
+ print("waiting for sse event")
+ local event, err = rx:receive()
+ print("event recvd")
+ if not event then
+ print("error in sse, exiting", err)
+ break
+ end
+ local data = dkjson.encode(event)
+ print("sending", data)
+ local _, err = stream:send(sse.Event.new():data(data))
+ if err then
+ print("error in sse, exiting", err)
+ stream.tx:close()
+ break
+ end
+ end
+ rx:close()
+ end)
+
+ --- This route is for checking that the server is currently listening
+ server:get("/health", function(req, res)
+ res:send("1")
+ end)
+
+ --- Get the current IP address, if not yet populated
+ --- this will look to either the environment or a short
+ --- lived udp socket
+ ---@param self lux.Server
+ ---@return string|nil
+ server.get_ip = function(self)
+ if self.ip == nil or self.ip == "0.0.0.0" then
+ self.ip = find_hub_ip(driver)
+ end
+ return self.ip
+ end
+ -- setup_multicast_disocvery(server)
+ driver.server = server
+end
diff --git a/timers/src/static.lua b/timers/src/static.lua
new file mode 100644
index 0000000..cea0aa3
--- /dev/null
+++ b/timers/src/static.lua
@@ -0,0 +1,563 @@
+
+
+local function css()
+ return [[
+:root {
+ /*Grey-100*/
+ --light-grey: #EEEEEE;
+ /*Grey-600*/
+ --grey: #757575;
+ /*Grey-900*/
+ --dark-grey: #1F1F1F;
+ /*Blue-500*/
+ --blue: #0790ED;
+ /*Teal-500*/
+ --teal: #00B3E3;
+ /*Red-500*/
+ --red: #FF4337;
+ /*Yellow-500*/
+ --yellow: #FFB546;
+ /*Green-500*/
+ --green: #3DC270;
+}
+
+html,
+body {
+ padding: 0;
+ margin: 0;
+ border: 0;
+}
+
+* {
+ font-family: sans-serif;
+}
+
+button {
+ margin-top: 10px;
+ height: 30px;
+ line-height: 30px;
+ text-align: center;
+ padding: 0 5px;
+ cursor: pointer;
+ background-color: var(--blue);
+ color: #fff;
+ border: 0;
+ font-size: 13pt;
+ border-radius: 8px;
+
+}
+
+header {
+ text-align: center;
+ width: 100%;
+ color: #fff;
+ background-color: var(--blue);
+ margin: 0 0 8px 0;
+ padding: 10px 0;
+}
+
+body.error header,
+body.error button,
+body.error .title {
+ background-color: var(--red) !important;
+ color: var(--dark-grey) !important;
+}
+
+body.error .device {
+ border-color: var(--red);
+}
+
+header>h1 {
+ margin: 0;
+}
+
+h2 {
+ text-align: center;
+ margin: 0;
+}
+
+#new-button-container {
+ margin: auto;
+ width: 250px;
+ display: flex;
+}
+
+#new-button-container>button {
+ width: 200px;
+ margin: auto;
+ height: 50px;
+}
+
+#button-list-container {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ margin: auto;
+ justify-content: space-between;
+ max-width: 800px;
+}
+
+.device {
+ display: flex;
+ flex-flow: column;
+ width: 175px;
+ border: 1px solid var(--blue);
+ border-radius: 5px;
+ padding: 2px;
+ margin-top: 5px;
+}
+
+.device .title {
+ font-size: 15pt;
+ background: var(--blue);
+ width: 100%;
+ text-align: center;
+ color: var(--light-grey);
+ padding-top: 5px;
+ border-radius: 4px;
+}
+
+.device.sensor .color-temp {
+ display: none;
+}
+
+.device .states {
+ display: flex;
+ flex-flow: column;
+ align-content: start;
+ align-items: start;
+}
+
+#event-history {
+ margin: 10px auto 0;
+ border-radius: 5px;
+}
+
+th {
+ background: var(--blue);
+ color: white;
+ padding: 5px;
+
+}
+
+th+th {
+ border-left: 1px solid white;
+}
+
+table,
+tr,
+td {
+ border: 1px solid var(--blue);
+ border-collapse: collapse;
+}
+ ]]
+end
+
+local function js()
+ return [[
+const BUTTON_LIST_ID = 'button-list-container';
+const NEW_BUTTON_ID = 'new-button';
+/**
+ * @type HTMLTemplateElement
+ */
+const DEVICE_TEMPLATE = document.getElementById("device-template");
+let known_buttons = [];
+
+let PROP = Object.freeze({
+ SWITCH: "switch",
+});
+
+let state_update_timers = {
+
+}
+
+Promise.sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function create_device() {
+ let result = await make_request('/newdevice', 'POST');
+ if (result.error) {
+ return console.error(result.error, result.body);
+ }
+
+ let list;
+ let err_ct = 0;
+ while (true) {
+ try {
+ list = await get_all_devices();
+ } catch (e) {
+ console.error('error fetching buttons', e);
+ err_ct += 1;
+ if (err_ct > 5) {
+ break;
+ }
+ await Promise.sleep(1000);
+ continue;
+ }
+ if (list.length !== known_buttons.length) {
+ break;
+ }
+ }
+ clear_button_list();
+ for (const info of list) {
+ append_new_device(info);
+ }
+}
+
+function append_new_device(info) {
+ let list = document.getElementById(BUTTON_LIST_ID);
+ let element = DEVICE_TEMPLATE.content.cloneNode(true);
+ let container = element.querySelector(".device");
+ container.id = info.device_id;
+ update_device_card(container, info, true)
+ list.appendChild(container);
+}
+
+function handle_single_update(info) {
+ console.log("handle_single_update", info);
+ let element = document.getElementById(info.device_id);
+ append_to_history(
+ info.device_name || info.device_id || "???",
+ info.event || "unknown",
+ info?.state?.switch || "",
+ info?.state?.interval || "",
+ info?.state?.timeout || "",
+ info?.state?.last_duration?.toFixed(3) || ""
+ )
+ switch (info.event) {
+ case "init":
+ case "update": {
+ if (!element) {
+ return append_new_device(info);
+ }
+ update_device_card(element, info);
+ break;
+ }
+ case "removed": {
+ if (!!element) {
+ element.parentElement.removeChild(element);
+ }
+ append_to_history(info.device_id, "removed", "", "", "", "")
+ break;
+ }
+ default:
+ append_to_history(info.device_name || info.device_id || "??", info.event || "unknown", "", "", "", "")
+ break;
+ }
+}
+
+function update_device_card(element, info, register_handlers) {
+ console.log("update_device_card", info);
+ let title = element.querySelector(".title");
+ title.innerText = info.device_name
+ let switch_on = info.state.switch === "on";
+
+ let switch_state_on = element.querySelector('.switch-on');
+ let switch_state_off = element.querySelector('.switch-off');
+ switch_state_on.checked = switch_on;
+ switch_state_off.checked = !switch_on;
+ switch_state_on.name = `${info.device_id}-switch-state`
+ switch_state_off.name = `${info.device_id}-switch-state`
+
+ let switch_level = element.querySelector(".switch-level");
+
+ if (register_handlers) {
+ switch_state_on.parentElement.addEventListener("click", () => handle_change(info.device_id, PROP.SWITCH));
+ switch_state_off.parentElement.addEventListener("click", () => handle_change(info.device_id, PROP.SWITCH));
+ }
+}
+
+/**
+ * Get the binary value form a form element
+ * @param {HTMLDivElement} div element to search
+ * @param {string} selector arg to query selector
+ * @param {string} is_checked Value returned if checked
+ * @param {string} other value returned if not checked
+ * @returns string
+ */
+function get_binary_value(div, selector, is_checked, other) {
+ let ele = div.querySelector(selector)?.checked || false;
+ return ele ? is_checked : other
+}
+
+
+function get_float_value(div, selector) {
+ let input = div.querySelector(selector);
+ if (!input) {
+ console.warn("div didn't contain", selector)
+ return 0
+ }
+ let value_str = input.value || "0";
+ try {
+ return parseFloat(value_str);
+ } catch (e) {
+ console.warn("invalid float value", e);
+ return 0;
+ }
+}
+
+/**
+ *
+ * @param {string} device_id
+ * @param {string} prop The property that changed
+ */
+function handle_change(device_id, prop) {
+ let existing_timer = state_update_timers[device_id];
+ let props = [prop]
+ if (!!existing_timer) {
+ clearTimeout(existing_timer.timer);
+ props.push(...existing_timer.props);
+ existing_timer[device_id] = null;
+ }
+ let timer = setTimeout(() => send_state_update(device_id, props), 300);
+ state_update_timers[device_id] = {
+ timer,
+ props,
+ };
+}
+
+/**
+ *
+ * @param {string} device_id
+ * @param {string[]} properties
+ */
+async function send_state_update(device_id, properties) {
+ let state = serialize_device(device_id, properties);
+ let resp = await make_request("/device_state", "PUT", {
+ device_id,
+ state,
+ });
+ if (resp.error) {
+ console.error("Error making request", resp.error, resp.body);
+ }
+}
+
+
+async function make_request(url, method = 'GET', body = undefined) {
+ console.log('make_request', url, method, body);
+ let opts = {
+ method,
+ body,
+ }
+ if (typeof body == 'object') {
+ opts.body = JSON.stringify(body);
+ opts.headers = {
+ ['Content-Type']: 'application/json',
+ }
+ }
+ let res = await fetch(url, opts);
+ if (res.status !== 200) {
+ return {
+ error: res.statusText,
+ body: await res.text()
+ };
+ }
+ return {
+ body: await res.json()
+ };
+}
+
+function clear_button_list() {
+ let list = document.getElementById(BUTTON_LIST_ID);
+ while (list.hasChildNodes()) {
+ list.removeChild(list.lastChild);
+ }
+}
+
+async function get_all_devices() {
+ let result = await make_request('/info');
+ if (result.error) {
+ console.error(result.body);
+ throw new Error(result.error)
+ }
+ return result.body;
+}
+
+function serialize_device(device_id, properties) {
+ let device_card = document.getElementById(device_id);
+ return serialize_device_card(device_card, properties)
+}
+
+function serialize_device_card(device_card, properties) {
+ let props = properties || ["switch"];
+ let state = {}
+ for (let prop of props) {
+ switch (prop) {
+ case "switch": {
+ state["switch"] = get_binary_value(device_card, ".switch-on", "on", "off");
+ break;
+ }
+ default:
+ console.error("Invalid prop, skipping", prop)
+ }
+ }
+ return state
+}
+
+function serialize_devices() {
+ return Array.from(document.querySelectorAll(".device")).map(ele => serialize_device_card(ele))
+}
+
+async function put_into_datastore(key, value) {
+ await make_request(`/set-in-store/${key}`, "PUT", value || { when: new Date().toISOString(), where: location.toString(), data: serialize_devices() });
+ return (await make_request("/store-size")).body.size
+}
+
+function append_to_history(
+ device_name, event_type, switch_state, interval, timeout, last_measurement
+) {
+ let dest = document.getElementById("event-history-body");
+ let row = document.getElementById("history-entry-template").content.cloneNode(true);
+ let args = [
+ [".when", (new Date()).toISOString()],
+ [".device-name", device_name],
+ [".event-type", event_type],
+ [".switch-state", switch_state],
+ [".interval", interval],
+ [".interval", interval],
+ [".timeout", timeout],
+ [".last-measurement", last_measurement],
+ ]
+ for (let [selector, value] of args) {
+ set_element_content_by_selector(row, selector, value)
+ }
+ dest.appendChild(row);
+}
+
+function set_element_content_by_selector(ele, selector, value) {
+ let target = ele.querySelector(selector);
+ target.innerText = value.toString();
+}
+
+(() => {
+ get_all_devices().then(list => {
+ known_buttons = list;
+ for (const info of list) {
+ append_new_device(info);
+ }
+ }).catch(console.error);
+ let new_btn = document.getElementById(NEW_BUTTON_ID);
+ new_btn.addEventListener('click', create_device);
+ let sse = new EventSource("/subscribe");
+ sse.addEventListener("message", ev => {
+ let info = JSON.parse(ev.data);
+ handle_single_update(info);
+ });
+ sse.addEventListener("open", ev => {
+ console.log("sse opened!")
+ sse.addEventListener("error", e => {
+ console.error(`Error from sse`, e);
+ sse.close()
+ document.body.classList.add("error");
+ let header = document.querySelector("header h1")[0];
+ header.firstElementChild.innerText += " URL Expired"
+ });
+ })
+})();
+ ]]
+end
+
+local function html()
+ return [[
+
+
+
+
+
+
+ Timer
+
+
+
+
+
+
+
+
+
+ Timers
+
+
+
+
+
+ History
+
+
+
+
+
+ |
+ When
+ |
+
+ Device
+ |
+
+ Event Type
+ |
+
+ Switch State
+ |
+
+ Interval
+ |
+
+ Timeout
+ |
+
+ Last Measurement
+ |
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+ ]]
+end
+
+return {
+ css = css,
+ js = js,
+ html = html,
+}
diff --git a/timers/static/index.html b/timers/static/index.html
new file mode 100644
index 0000000..2facd7e
--- /dev/null
+++ b/timers/static/index.html
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+ Timer
+
+
+
+
+
+
+
+
+
+ Timers
+
+
+
+
+
+ History
+
+
+
+
+
+ |
+ When
+ |
+
+ Device
+ |
+
+ Event Type
+ |
+
+ Switch State
+ |
+
+ Interval
+ |
+
+ Timeout
+ |
+
+ Last Measurement
+ |
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
diff --git a/timers/static/index.js b/timers/static/index.js
new file mode 100644
index 0000000..b36e21d
--- /dev/null
+++ b/timers/static/index.js
@@ -0,0 +1,298 @@
+const BUTTON_LIST_ID = 'button-list-container';
+const NEW_BUTTON_ID = 'new-button';
+/**
+ * @type HTMLTemplateElement
+ */
+const DEVICE_TEMPLATE = document.getElementById("device-template");
+let known_buttons = [];
+
+let PROP = Object.freeze({
+ SWITCH: "switch",
+});
+
+let state_update_timers = {
+
+}
+
+Promise.sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function create_device() {
+ let result = await make_request('/newdevice', 'POST');
+ if (result.error) {
+ return console.error(result.error, result.body);
+ }
+
+ let list;
+ let err_ct = 0;
+ while (true) {
+ try {
+ list = await get_all_devices();
+ } catch (e) {
+ console.error('error fetching buttons', e);
+ err_ct += 1;
+ if (err_ct > 5) {
+ break;
+ }
+ await Promise.sleep(1000);
+ continue;
+ }
+ if (list.length !== known_buttons.length) {
+ break;
+ }
+ }
+ clear_button_list();
+ for (const info of list) {
+ append_new_device(info);
+ }
+}
+
+function append_new_device(info) {
+ let list = document.getElementById(BUTTON_LIST_ID);
+ let element = DEVICE_TEMPLATE.content.cloneNode(true);
+ let container = element.querySelector(".device");
+ container.id = info.device_id;
+ update_device_card(container, info, true)
+ list.appendChild(container);
+}
+
+function handle_single_update(info) {
+ console.log("handle_single_update", info);
+ let element = document.getElementById(info.device_id);
+ append_to_history(
+ info.device_name || info.device_id || "???",
+ info.event || "unknown",
+ info?.state?.switch || "",
+ info?.state?.interval || "",
+ info?.state?.timeout || "",
+ info?.state?.last_duration?.toFixed(3) || ""
+ )
+ switch (info.event) {
+ case "init":
+ case "update": {
+ if (!element) {
+ return append_new_device(info);
+ }
+ update_device_card(element, info);
+ break;
+ }
+ case "removed": {
+ if (!!element) {
+ element.parentElement.removeChild(element);
+ }
+ append_to_history(info.device_id, "removed", "", "", "", "")
+ break;
+ }
+ default:
+ append_to_history(info.device_name || info.device_id || "??", info.event || "unknown", "", "", "", "")
+ break;
+ }
+}
+
+function update_device_card(element, info, register_handlers) {
+ console.log("update_device_card", info);
+ let title = element.querySelector(".title");
+ title.innerText = info.device_name
+ let switch_on = info.state.switch === "on";
+
+ let switch_state_on = element.querySelector('.switch-on');
+ let switch_state_off = element.querySelector('.switch-off');
+ switch_state_on.checked = switch_on;
+ switch_state_off.checked = !switch_on;
+ switch_state_on.name = `${info.device_id}-switch-state`
+ switch_state_off.name = `${info.device_id}-switch-state`
+
+ let switch_level = element.querySelector(".switch-level");
+
+ if (register_handlers) {
+ switch_state_on.parentElement.addEventListener("click", () => handle_change(info.device_id, PROP.SWITCH));
+ switch_state_off.parentElement.addEventListener("click", () => handle_change(info.device_id, PROP.SWITCH));
+ }
+}
+
+/**
+ * Get the binary value form a form element
+ * @param {HTMLDivElement} div element to search
+ * @param {string} selector arg to query selector
+ * @param {string} is_checked Value returned if checked
+ * @param {string} other value returned if not checked
+ * @returns string
+ */
+function get_binary_value(div, selector, is_checked, other) {
+ let ele = div.querySelector(selector)?.checked || false;
+ return ele ? is_checked : other
+}
+
+
+function get_float_value(div, selector) {
+ let input = div.querySelector(selector);
+ if (!input) {
+ console.warn("div didn't contain", selector)
+ return 0
+ }
+ let value_str = input.value || "0";
+ try {
+ return parseFloat(value_str);
+ } catch (e) {
+ console.warn("invalid float value", e);
+ return 0;
+ }
+}
+
+/**
+ *
+ * @param {string} device_id
+ * @param {string} prop The property that changed
+ */
+function handle_change(device_id, prop) {
+ let existing_timer = state_update_timers[device_id];
+ let props = [prop]
+ if (!!existing_timer) {
+ clearTimeout(existing_timer.timer);
+ props.push(...existing_timer.props);
+ existing_timer[device_id] = null;
+ }
+ let timer = setTimeout(() => send_state_update(device_id, props), 300);
+ state_update_timers[device_id] = {
+ timer,
+ props,
+ };
+}
+
+/**
+ *
+ * @param {string} device_id
+ * @param {string[]} properties
+ */
+async function send_state_update(device_id, properties) {
+ let state = serialize_device(device_id, properties);
+ let resp = await make_request("/device_state", "PUT", {
+ device_id,
+ state,
+ });
+ if (resp.error) {
+ console.error("Error making request", resp.error, resp.body);
+ }
+}
+
+
+async function make_request(url, method = 'GET', body = undefined) {
+ console.log('make_request', url, method, body);
+ let opts = {
+ method,
+ body,
+ }
+ if (typeof body == 'object') {
+ opts.body = JSON.stringify(body);
+ opts.headers = {
+ ['Content-Type']: 'application/json',
+ }
+ }
+ let res = await fetch(url, opts);
+ if (res.status !== 200) {
+ return {
+ error: res.statusText,
+ body: await res.text()
+ };
+ }
+ return {
+ body: await res.json()
+ };
+}
+
+function clear_button_list() {
+ let list = document.getElementById(BUTTON_LIST_ID);
+ while (list.hasChildNodes()) {
+ list.removeChild(list.lastChild);
+ }
+}
+
+async function get_all_devices() {
+ let result = await make_request('/info');
+ if (result.error) {
+ console.error(result.body);
+ throw new Error(result.error)
+ }
+ return result.body;
+}
+
+function serialize_device(device_id, properties) {
+ let device_card = document.getElementById(device_id);
+ return serialize_device_card(device_card, properties)
+}
+
+function serialize_device_card(device_card, properties) {
+ let props = properties || ["switch"];
+ let state = {}
+ for (let prop of props) {
+ switch (prop) {
+ case "switch": {
+ state["switch"] = get_binary_value(device_card, ".switch-on", "on", "off");
+ break;
+ }
+ default:
+ console.error("Invalid prop, skipping", prop)
+ }
+ }
+ return state
+}
+
+function serialize_devices() {
+ return Array.from(document.querySelectorAll(".device")).map(ele => serialize_device_card(ele))
+}
+
+async function put_into_datastore(key, value) {
+ await make_request(`/set-in-store/${key}`, "PUT", value || { when: new Date().toISOString(), where: location.toString(), data: serialize_devices() });
+ return (await make_request("/store-size")).body.size
+}
+
+function append_to_history(
+ device_name, event_type, switch_state, interval, timeout, last_measurement
+) {
+ let dest = document.getElementById("event-history-body");
+ let row = document.getElementById("history-entry-template").content.cloneNode(true);
+ let args = [
+ [".when", (new Date()).toISOString()],
+ [".device-name", device_name],
+ [".event-type", event_type],
+ [".switch-state", switch_state],
+ [".interval", interval],
+ [".interval", interval],
+ [".timeout", timeout],
+ [".last-measurement", last_measurement],
+ ]
+ for (let [selector, value] of args) {
+ set_element_content_by_selector(row, selector, value)
+ }
+ dest.appendChild(row);
+}
+
+function set_element_content_by_selector(ele, selector, value) {
+ let target = ele.querySelector(selector);
+ target.innerText = value.toString();
+}
+
+(() => {
+ get_all_devices().then(list => {
+ known_buttons = list;
+ for (const info of list) {
+ append_new_device(info);
+ }
+ }).catch(console.error);
+ let new_btn = document.getElementById(NEW_BUTTON_ID);
+ new_btn.addEventListener('click', create_device);
+ let sse = new EventSource("/subscribe");
+ sse.addEventListener("message", ev => {
+ let info = JSON.parse(ev.data);
+ handle_single_update(info);
+ });
+ sse.addEventListener("open", ev => {
+ console.log("sse opened!")
+ sse.addEventListener("error", e => {
+ console.error(`Error from sse`, e);
+ sse.close()
+ document.body.classList.add("error");
+ let header = document.querySelector("header h1")[0];
+ header.firstElementChild.innerText += " URL Expired"
+ });
+ })
+})();
diff --git a/timers/static/style.css b/timers/static/style.css
new file mode 100644
index 0000000..9c8b158
--- /dev/null
+++ b/timers/static/style.css
@@ -0,0 +1,148 @@
+:root {
+ /*Grey-100*/
+ --light-grey: #EEEEEE;
+ /*Grey-600*/
+ --grey: #757575;
+ /*Grey-900*/
+ --dark-grey: #1F1F1F;
+ /*Blue-500*/
+ --blue: #0790ED;
+ /*Teal-500*/
+ --teal: #00B3E3;
+ /*Red-500*/
+ --red: #FF4337;
+ /*Yellow-500*/
+ --yellow: #FFB546;
+ /*Green-500*/
+ --green: #3DC270;
+}
+
+html,
+body {
+ padding: 0;
+ margin: 0;
+ border: 0;
+}
+
+* {
+ font-family: sans-serif;
+}
+
+button {
+ margin-top: 10px;
+ height: 30px;
+ line-height: 30px;
+ text-align: center;
+ padding: 0 5px;
+ cursor: pointer;
+ background-color: var(--blue);
+ color: #fff;
+ border: 0;
+ font-size: 13pt;
+ border-radius: 8px;
+
+}
+
+header {
+ text-align: center;
+ width: 100%;
+ color: #fff;
+ background-color: var(--blue);
+ margin: 0 0 8px 0;
+ padding: 10px 0;
+}
+
+body.error header,
+body.error button,
+body.error .title {
+ background-color: var(--red) !important;
+ color: var(--dark-grey) !important;
+}
+
+body.error .device {
+ border-color: var(--red);
+}
+
+header>h1 {
+ margin: 0;
+}
+
+h2 {
+ text-align: center;
+ margin: 0;
+}
+
+#new-button-container {
+ margin: auto;
+ width: 250px;
+ display: flex;
+}
+
+#new-button-container>button {
+ width: 200px;
+ margin: auto;
+ height: 50px;
+}
+
+#button-list-container {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ margin: auto;
+ justify-content: space-between;
+ max-width: 800px;
+}
+
+.device {
+ display: flex;
+ flex-flow: column;
+ width: 175px;
+ border: 1px solid var(--blue);
+ border-radius: 5px;
+ padding: 2px;
+ margin-top: 5px;
+}
+
+.device .title {
+ font-size: 15pt;
+ background: var(--blue);
+ width: 100%;
+ text-align: center;
+ color: var(--light-grey);
+ padding-top: 5px;
+ border-radius: 4px;
+}
+
+.device.sensor .color-temp {
+ display: none;
+}
+
+.device .states {
+ display: flex;
+ flex-flow: column;
+ align-content: start;
+ align-items: start;
+}
+
+#event-history {
+ margin: 10px auto 0;
+ border-radius: 5px;
+}
+
+th {
+ background: var(--blue);
+ color: white;
+ padding: 5px;
+
+}
+
+th+th {
+ border-left: 1px solid white;
+}
+
+table,
+tr,
+td {
+ border: 1px solid var(--blue);
+ border-collapse: collapse;
+}