diff --git a/thread_tracking_sensor/config.yml b/thread_tracking_sensor/config.yml new file mode 100644 index 0000000..5f80da6 --- /dev/null +++ b/thread_tracking_sensor/config.yml @@ -0,0 +1,5 @@ +name: "thread_tracking" +packageKey: "thread_tracking" +permissions: + lan: {} + discovery: {} diff --git a/thread_tracking_sensor/get_info.sh b/thread_tracking_sensor/get_info.sh new file mode 100755 index 0000000..89ac18a --- /dev/null +++ b/thread_tracking_sensor/get_info.sh @@ -0,0 +1,3 @@ +#! /bin/bash + +curl -k -l http://$1:$2/info diff --git a/thread_tracking_sensor/profiles/thread_tracking_sensor-profile.yml b/thread_tracking_sensor/profiles/thread_tracking_sensor-profile.yml new file mode 100644 index 0000000..a6ebc77 --- /dev/null +++ b/thread_tracking_sensor/profiles/thread_tracking_sensor-profile.yml @@ -0,0 +1,14 @@ +name: "thread_tracking_sensor.v1" +components: + - id: main + capabilities: + - id: contactSensor + version: 1 + - id: temperatureMeasurement + version: 1 + - id: airQualitySensor + version: 1 + - id: switch + version: 1 + - id: switchLevel + version: 1 diff --git a/thread_tracking_sensor/profiles/thread_tracking_sensor_bridge-profile.yml b/thread_tracking_sensor/profiles/thread_tracking_sensor_bridge-profile.yml new file mode 100644 index 0000000..e43cc26 --- /dev/null +++ b/thread_tracking_sensor/profiles/thread_tracking_sensor_bridge-profile.yml @@ -0,0 +1,10 @@ +name: thread_tracking_sensor_bridge.v1 +components: + - id: main + capabilities: + - id: honestadmin11679.targetcreate + version: 1 + - id: honestadmin11679.targetCount + version: 1 + - id: honestadmin11679.currentUrl + version: 1 diff --git a/thread_tracking_sensor/readme.md b/thread_tracking_sensor/readme.md new file mode 100644 index 0000000..84032ce --- /dev/null +++ b/thread_tracking_sensor/readme.md @@ -0,0 +1 @@ +# HTTP Sensor diff --git a/thread_tracking_sensor/src/disco.lua b/thread_tracking_sensor/src/disco.lua new file mode 100644 index 0000000..dc986ff --- /dev/null +++ b/thread_tracking_sensor/src/disco.lua @@ -0,0 +1,91 @@ +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_sensor_device(driver, device_number, parent_device_id) + log.trace('add_sensor_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 = 'Thread Sensor ' .. device_number + log.debug('adding device ' .. device_name) + local device_id = utils.generate_uuid_v4() .. tostring(device_number) .. '2' + local device_info = { + type = 'LAN', + deviceNetworkId = device_id, + label = device_name, + parent_device_id = parent_device_id, + profileReference = 'thread_tracking_sensor.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 = "Thread Sensor Bridge" + local device_info = { + type = 'LAN', + deviceNetworkId = device_id, + label = device_name, + profileReference = 'thread_tracking_sensor_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_sensor_device = add_sensor_device, +} diff --git a/thread_tracking_sensor/src/driver_name.lua b/thread_tracking_sensor/src/driver_name.lua new file mode 100644 index 0000000..dec3846 --- /dev/null +++ b/thread_tracking_sensor/src/driver_name.lua @@ -0,0 +1 @@ +return "thread_tracking_sensor" diff --git a/thread_tracking_sensor/src/init.lua b/thread_tracking_sensor/src/init.lua new file mode 100644 index 0000000..f616dc8 --- /dev/null +++ b/thread_tracking_sensor/src/init.lua @@ -0,0 +1,379 @@ +local capabilities = require 'st.capabilities' +local Driver = require 'st.driver' +local log = require 'log' +local utils = require 'st.utils' +local cosock = require 'cosock' +local thread = require "st.thread" + +local discovery = require 'disco' +local server = require 'server' + +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 TemperatureMeasurement = capabilities.temperatureMeasurement +local ContactSensor = capabilities.contactSensor +local AqSensor = capabilities.airQualitySensor +local ColorTemp = capabilities.colorTemperature + +local function is_bridge(device) + return device:supports_capability_by_id(targetCountId) +end + +local function sensor_profile_name(device) + if device:supports_capability_by_id(ColorTemp.ID) then + return "http_sensor-ext.v1" + end + return "http_sensor.v1" +end + +local function do_thread_metadata(driver) + local meta = cosock.get_thread_metadata() + driver:send_to_all_sse({ + event = "report", + data = meta + }) +end + + +local function device_init(driver, device) + -- do_thread_metadata(driver) + 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)) + driver:emit_state(device, state) + 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) + -- do_thread_metadata(driver) + -- log.trace('Removed http_sensor ' .. device.id) + driver:send_to_all_sse({ + event = "removed", + device_id = device.id, + }) +end + +local function do_refresh(driver, device) + -- do_thread_metadata(driver) + -- 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 do_set_level(driver, device, args) + do_thread_metadata(driver) + -- print("do_set_level", device.id, utils.stringify_table(args, "args", true)) + device:emit_event(capabilities.switchLevel.level(args.args.level)) + driver:send_all_states_to_sse(device, { switch_level = args.args.level }) +end + +local function set_color_temp_handler(driver, device, args) + do_thread_metadata(driver) + -- print("set_color_temp_handler", device.id, utils.stringify_table(args, "args", true)) + device:emit_event(capabilities.colorTemperature.colorTemperature(args.args.temp)) + -- driver:send_all_states_to_sse(device, {switch_level = args.args.level}) +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, + capability_handlers = { + [createTargetId] = { + ["create"] = function(driver, device) + -- log.info("createTarget") + discovery.add_sensor_device(driver, nil, driver.bridge_id) + end + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = do_refresh, + }, + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = function(driver, device) + driver:emit_state(device, { switch = "on" }) + end, + [capabilities.switch.commands.off.NAME] = function(driver, device) + driver:emit_state(device, { switch = "off" }) + end, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = do_set_level, + }, + [capabilities.colorTemperature.ID] = { + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = set_color_temp_handler, + }, + } +}) + +function Driver:send_all_states_to_sse(device, supp) + self:send_to_all_sse({ + event = "update", + device_id = device.id, + device_name = device.label, + profile = sensor_profile_name(device), + state = supp or self:get_state_object(device) + }) +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") + local temp = device:get_latest_state("main", TemperatureMeasurement.ID, + TemperatureMeasurement.temperature.NAME) + if type(temp) == "number" then + temp = { + value = temp, + unit = "F", + } + end + + local ret = { + temp = temp, + contact = device:get_latest_state("main", ContactSensor.ID, ContactSensor.contact.NAME), + air = device:get_latest_state("main", AqSensor.ID, AqSensor.airQuality.NAME), + switch = device:get_latest_state("main", capabilities.switch.ID, capabilities.switch.switch.NAME), + switch_level = device:get_latest_state("main", capabilities.switchLevel.ID, capabilities.switchLevel.NAME), + } + local needs_color_temp = device:supports_capability_by_id(ColorTemp.ID) + if needs_color_temp then + ret.color_emp = device:get_latest_state("main", ColorTemp.ID, ColorTemp.colorTemperature.NAME) + end + local expected_props = { + temp = { + value = 50, + unit = "F", + }, + contact = "closed", + air = 50, + switch = "off", + switch_level = 50, + } + if needs_color_temp then + expected_props.color_temp = 1 + end + for prop, default in pairs(expected_props) do + if ret[prop] == nil then + --print(string.format("WARNING %q was nil, setting to default", prop)) + ret[prop] = default + end + end + -- print("returning state", utils.stringify_table(ret)) + return ret +end + +function Driver:emit_state(device, state) + do_thread_metadata(driver) + self:emit_sensor_state(device, state) + self:emit_switch_state(device, state) + self:send_all_states_to_sse(device) +end + +function Driver:emit_sensor_state(device, state) + if state.temp then + device:emit_event(TemperatureMeasurement.temperature(state.temp or { + value = 60, + unit = "F" + })) + end + if state.contact then + device:emit_event(ContactSensor.contact(state.contact or "closed")) + end + if state.air then + device:emit_event(AqSensor.airQuality(state.air or 50)) + end +end + +function Driver:emit_switch_state(device, state) + -- print("Driver:emit_switch_state", utils.stringify_table(state, "state", true)) + if state.switch == "on" then + -- print("emitting on for ", device.label) + device:emit_event(capabilities.switch.switch.on()) + elseif state.switch == "off" then + -- print("emitting off for ", device.label) + device:emit_event(capabilities.switch.switch.off()) + end + if state.level then + -- print("emitting level", state.level, " for ", device.label) + device:emit_event(capabilities.switchLevel.level(state.level)) + end + if device:supports_capability_by_id(ColorTemp.ID) + and state.colorTemp + then + device:emit_event(ColorTemp.colorTemperature(state.colorTemp or 0)) + end +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(driver: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, + profile = sensor_profile_name(device), + 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)) + return 1 + end +end + +function driver:check_store_size() + local json = require("st.json") + local store_value = self.datastore:get_serializable() + local store_str = json.encode(store_value) + return #store_str, store_str +end + +driver.sse_txs = {} + +-- cosock.spawn(function() +-- while true do +-- local msg, err = thread.gc_rx:receive() +-- if not msg then +-- print("gc_rx error", err) +-- break +-- end +-- print("gc_rx msg", msg) +-- end +-- end, "Thread-gc") + +local function do_memory_stuff() + local json = require "st.json" + if not memory then + return + end + local lua_info = json.decode(memory.generate_report()) + local cosock_info = cosock.get_thread_metadata() + local ttl_counts = 0 + local ttl_bytes = 0 + for k, v in pairs(lua_info) do + if string.find(k, "count") then + ttl_counts = ttl_counts + v + end + if string.find(k, "size") then + ttl_bytes = ttl_bytes + v + end + end + -- local lost = cosock.check_other_metadata_tables() + -- for k, v in pairs(lost) do + -- if v and #v > 0 then + -- log.warn("cosock is holding thread open but it wasn't in threads", k, #v) + -- end + -- end + print(utils.stringify_table({ + st_thread_count = thread.current_ttl_threads, + cosock_thread_count = #cosock_info, + lua_thread_count = lua_info.thread_count, + lua_thread_size = lua_info.thread_size, + ttl_counts = ttl_counts, + ttl_bytes = ttl_bytes, + })) +end + +driver:call_with_delay(0, function(driver) + do_memory_stuff() + while true do + if driver:emit_current_url() then + break + end + cosock.socket.sleep(10) + end +end, "url-emitter") + +local memory_report_timer +memory_report_timer = driver:call_on_schedule(10, function(driver) + if memory then + do_memory_stuff() + else + print("memory is unavailable...") + driver:cancel_timer(memory_report_timer) + end +end, "memory-reporter") + + +server(driver) + +driver:run() diff --git a/thread_tracking_sensor/src/server.lua b/thread_tracking_sensor/src/server.lua new file mode 100644 index 0000000..2d0ced3 --- /dev/null +++ b/thread_tracking_sensor/src/server.lua @@ -0,0 +1,227 @@ +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 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) + + --- Quiet the 60 second print statement about the server's address + server:post('/quiet', function(req, res) + if driver.ping_loop == nil then + res:send('Not currently printing') + return + end + driver:cancel_timer(driver.ping_loop) + driver.ping_loop = nil + res:send('Stopped ping loop') + end) + + --- Create a new http button on this hub + server:post('/newdevice', function(req, res) + local device_name, device_id, err_msg = discovery.add_sensor_device(driver, nil, + driver.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) + + server:put("/profile", function(req, res) + if not req.body.device_id or not req.body.profile then + res:set_status(400):send('bad request') + return + end + + local dev = lux.Error.assert(driver:get_device_info(req.body.device_id)) + lux.Error.assert(dev:try_update_metadata({ + profile = req.body.profile + })) + res:set_status(200):send("{}") + 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/thread_tracking_sensor/src/static.lua b/thread_tracking_sensor/src/static.lua new file mode 100644 index 0000000..f93fe23 --- /dev/null +++ b/thread_tracking_sensor/src/static.lua @@ -0,0 +1,715 @@ + + +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; +} + +#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.extended .title { + background-color: var(--green); +} + +.device.extended .color-temp { + display: unset; +} + +.device.sensor .color-temp { + display: none; +} + +.device .states { + display: flex; + flex-flow: column; + align-content: start; + align-items: start; +} + +.contact-state { + width: 100%; +} + +.sensor-name { + font-weight: bold; + margin-top: 5px; + display: inline-block; +} + +.contact-state .radio-group { + display: flex; + flex-flow: row; + align-items: start; + justify-content: space-between; +} + +.temp-state { + width: 100%; + flex-flow: column; + display: flex; + justify-content: space-between; +} + +.temp-info { + display: flex; + flex-flow: row; + width: 100%; + justify-content: space-between; +} + +.temp-input-container input { + width: 50px; +} + +.temp-info .radio-group { + width: 75px; + display: flex; + flex-flow: row; + +} + +.air-state input { + width: 50px; +} + ]] +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 = []; +const SENSOR_CLASS = "sensor"; +const EXTEND_CLASS = "extended"; +const PROFILE_CLASS_MAP = Object.freeze({ + ["http_sensor.v1"]: SENSOR_CLASS, + ["http_sensor2.v1"]: SENSOR_CLASS, + ["http_sensor-ext.v1"]: EXTEND_CLASS, + ["http_sensor-ext2.v1"]: EXTEND_CLASS, +}); +const NOT_CLASS_MAP = Object.freeze({ + [SENSOR_CLASS]: EXTEND_CLASS, + [EXTEND_CLASS]: SENSOR_CLASS, +}); + +let PROP = Object.freeze({ + CONTACT: "contact", + TEMP: "temp", + AIR: "air", + SWITCH: "switch", + LEVEL: "level", + COLOR_TEMP: "colorTemp", +}); + +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); + switch (info.event) { + case "report": { + return console.log(info); + } + 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); + } + break; + } + case "profile": { + let new_class = PROFILE_CLASS_MAP[info.profile]; + let old_class = NOT_CLASS_MAP[new_class]; + element.classList.add(new_class); + element.classList.remove(old_class); + update_device_card(element, info); + } + } +} + +function update_device_card(element, info, register_handlers) { + console.log("update_device_card", info); + let device_id = info.device_id; + let title_span = element.querySelector(".title"); + title_span.innerText = info.device_name; + /** @type HTMLInputElement */ + let contact_open = element.querySelector(".contact-open"); + let contact_closed = element.querySelector(".contact-closed"); + let is_open = info.state.contact === "open"; + contact_open.checked = is_open; + contact_closed.checked = !is_open; + contact_open.name = `${device_id}-contact`; + contact_closed.name = `${device_id}-contact`; + + /** @type HTMLInputElement */ + let temp_value = element.querySelector(".temp-value"); + temp_value.value = info.state.temp.value; + + let temp_c = element.querySelector(".temp-c"); + let temp_f = element.querySelector(".temp-f"); + temp_c.name = `${device_id}-temp-unit`; + temp_f.name = `${device_id}-temp-unit`; + let is_f = info.state.temp.unit === "F"; + temp_c.checked = !is_f; + temp_f.checked = is_f; + + let air_value = element.querySelector(".air-value"); + air_value.value = info.state.air; + + 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 = `${device_id}-switch-state`; + switch_state_off.name = `${device_id}-switch-state`; + + let switch_level = element.querySelector(".switch-level"); + switch_level.value = info.state.switch_level; + + let current_class = PROFILE_CLASS_MAP[info.profile] ?? SENSOR_CLASS; + let stale_class = NOT_CLASS_MAP[current_class] ?? EXTEND_CLASS; + element.classList.add(current_class); + element.classList.remove(stale_class); + + if (current_class == EXTEND_CLASS) { + let color_temp = element.querySelector(".color-temp-value"); + color_temp.value = info.state.color_temp; + if (register_handlers) { + color_temp.addEventListener("change", () => + handle_change(device_id, PROP.COLOR_TEMP), + ); + } + } + + if (register_handlers) { + temp_value.addEventListener("change", () => + handle_change(device_id, PROP.TEMP), + ); + temp_c.addEventListener("change", () => + handle_change(device_id, PROP.TEMP), + ); + air_value.addEventListener("change", () => + handle_change(device_id, PROP.AIR), + ); + contact_open.parentElement.addEventListener("click", () => + handle_change(device_id, PROP.CONTACT), + ); + contact_closed.parentElement.addEventListener("click", () => + handle_change(device_id, PROP.CONTACT), + ); + switch_state_on.parentElement.addEventListener("click", () => + handle_change(device_id, PROP.SWITCH), + ); + switch_state_off.parentElement.addEventListener("click", () => + handle_change(device_id, PROP.SWITCH), + ); + switch_level.addEventListener("change", () => + handle_change(device_id, PROP.LEVEL), + ); + } +} + +/** + * 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); + } +} + +/** + * + * @param {string} device_id + * @param {string} profile + */ +async function send_profile_update(device_id, profile) { + return await make_request("/profile", "PUT", { + device_id, + profile, + }); +} + +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 || ["contact", "temp", "air", "switch", "level"]; + let state = {}; + for (let prop of props) { + switch (prop) { + case "contact": { + state.contact = get_binary_value( + device_card, + ".contact-open", + "open", + "closed", + ); + break; + } + case "temp": { + state.temp = { + value: get_float_value(device_card, ".temp-value"), + unit: get_binary_value(device_card, ".temp-f", "F", "C"), + }; + break; + } + case "air": { + state.air = get_float_value(device_card, ".air-value"); + break; + } + case "switch": { + state["switch"] = get_binary_value( + device_card, + ".switch-on", + "on", + "off", + ); + break; + } + case "level": { + state.level = get_float_value(device_card, ".switch-level"); + 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; +} + +async function async_loop() { + let device = document.querySelector(".device"); + if (!device) { + return console.warn("tried to start async loop w/o a device in the DOM"); + } + let on_radio = device.querySelector(".switch-on"); + let off_radio = device.querySelector(".switch-off"); + let on = on_radio.checked; + for (;;) { + on = !on; + if (on) { + off_radio.parentElement.click(); + } else { + on_radio.parentElement.click(); + } + await Promise.sleep(1000); + } +} + +(() => { + 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 [[ + + + +
+ + +