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

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

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