-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path980.Setting_Overview.fqa
More file actions
1 lines (1 loc) · 150 KB
/
Copy path980.Setting_Overview.fqa
File metadata and controls
1 lines (1 loc) · 150 KB
1
{"name":"980.Setting Overview ","type":"com.fibaro.multilevelSensor","apiVersion":"1.3","initialProperties":{"viewLayout":{"$jason":{"body":{"header":{"style":{"height":"0"},"title":"quickApp_device_893"},"sections":{"items":[{"components":[{"name":"info_cpu","style":{"weight":"1.2"},"text":"info_cpu","type":"label","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"components":[{"name":"update","style":{"weight":"0.50"},"text":"Update","type":"switch","value":"false","visible":true},{"name":"button_ID_8_2","style":{"weight":"0.50"},"text":" ","type":"button","visible":false}],"style":{"weight":"1.2"},"type":"horizontal"},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"info_zwave","style":{"weight":"1.2"},"text":"info_zwave","type":"label","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"info_deaddevices","style":{"weight":"1.2"},"text":"info_deaddevices","type":"label","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"info_localaccess","style":{"weight":"1.2"},"text":"info_localaccess","type":"label","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"Info","style":{"weight":"1.2"},"text":"Info","type":"label","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"max":"100","min":"0","name":"led_brightness","style":{"weight":"1.2"},"text":"","type":"slider","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"info_users","style":{"weight":"1.2"},"text":"info_users","type":"label","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"info_installer","style":{"weight":"1.2"},"text":"info_installer","type":"label","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"installer_access24h","style":{"weight":"1.2"},"text":"Grant Installer Access 24h","type":"switch","value":"false","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"space","style":{"weight":"1.2"},"text":"space","type":"label","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"info_language","style":{"weight":"1.2"},"text":"info_language","type":"label","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"components":[{"name":"language_save","style":{"weight":"0.50"},"text":"language_save","type":"switch","value":"false","visible":true},{"name":"button_ID_12_2","style":{"weight":"0.50"},"text":" ","type":"switch","value":"false","visible":false}],"style":{"weight":"1.2"},"type":"horizontal"},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"language_select","options":[],"selectionType":"single","style":{"weight":"1.2"},"text":"language_select","type":"select","values":[],"visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"info_Update","style":{"weight":"1.2"},"text":"info_Update","type":"label","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"components":[{"name":"Auto_Update","style":{"weight":"0.50"},"text":"Auto_Update","type":"switch","value":"false","visible":true},{"name":"button_ID_17_2","style":{"weight":"0.50"},"text":" ","type":"button","visible":false}],"style":{"weight":"1.2"},"type":"horizontal"},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"components":[{"name":"Update_Now","style":{"weight":"0.50"},"text":"Update_Now","type":"switch","value":"false","visible":true},{"name":"Check_Update","style":{"weight":"0.50"},"text":"Check_Update","type":"switch","value":"false","visible":true}],"style":{"weight":"1.2"},"type":"horizontal"},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"},{"components":[{"name":"space2","style":{"weight":"1.2"},"text":"space2","type":"label","visible":true},{"style":{"weight":"0.5"},"type":"space"}],"style":{"weight":"1.2"},"type":"vertical"}]}},"head":{"title":"quickApp_device_893"}}},"uiView":[{"components":[{"name":"info_cpu","style":{"weight":"1.0"},"text":"info_cpu","type":"label","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"eventBinding":{"onLongPressDown":[{"params":{"actionName":"UIAction","args":["onLongPressDown","update","$event.value"]},"type":"deviceAction"}],"onLongPressReleased":[{"params":{"actionName":"UIAction","args":["onLongPressReleased","update","$event.value"]},"type":"deviceAction"}],"onReleased":[{"params":{"actionName":"UIAction","args":["onReleased","update","$event.value"]},"type":"deviceAction"}]},"name":"update","style":{"weight":"0.50"},"text":"Update","type":"switch","value":"false","visible":true},{"eventBinding":{"onLongPressDown":[{"params":{"actionName":"UIAction","args":["onLongPressDown","button_ID_8_2"]},"type":"deviceAction"}],"onLongPressReleased":[{"params":{"actionName":"UIAction","args":["onLongPressReleased","button_ID_8_2"]},"type":"deviceAction"}],"onReleased":[{"params":{"actionName":"UIAction","args":["onReleased","button_ID_8_2"]},"type":"deviceAction"}]},"name":"button_ID_8_2","style":{"weight":"0.50"},"text":" ","type":"button","visible":false}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"name":"info_zwave","style":{"weight":"1.0"},"text":"info_zwave","type":"label","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"name":"info_deaddevices","style":{"weight":"1.0"},"text":"info_deaddevices","type":"label","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"name":"info_localaccess","style":{"weight":"1.0"},"text":"info_localaccess","type":"label","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"name":"Info","style":{"weight":"1.0"},"text":"Info","type":"label","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"eventBinding":{"onChanged":[{"params":{"actionName":"UIAction","args":["onChanged","led_brightness","$event.value"]},"type":"deviceAction"}]},"max":"100","min":"0","name":"led_brightness","style":{"weight":"1.0"},"text":"","type":"slider","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"name":"info_users","style":{"weight":"1.0"},"text":"info_users","type":"label","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"name":"info_installer","style":{"weight":"1.0"},"text":"info_installer","type":"label","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"eventBinding":{"onLongPressDown":[{"params":{"actionName":"UIAction","args":["onLongPressDown","installer_access24h","$event.value"]},"type":"deviceAction"}],"onLongPressReleased":[{"params":{"actionName":"UIAction","args":["onLongPressReleased","installer_access24h","$event.value"]},"type":"deviceAction"}],"onReleased":[{"params":{"actionName":"UIAction","args":["onReleased","installer_access24h","$event.value"]},"type":"deviceAction"}]},"name":"installer_access24h","style":{"weight":"1.0"},"text":"Grant Installer Access 24h","type":"switch","value":"false","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"name":"space","style":{"weight":"1.0"},"text":"space","type":"label","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"name":"info_language","style":{"weight":"1.0"},"text":"info_language","type":"label","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"eventBinding":{"onLongPressDown":[{"params":{"actionName":"UIAction","args":["onLongPressDown","language_save","$event.value"]},"type":"deviceAction"}],"onLongPressReleased":[{"params":{"actionName":"UIAction","args":["onLongPressReleased","language_save","$event.value"]},"type":"deviceAction"}],"onReleased":[{"params":{"actionName":"UIAction","args":["onReleased","language_save","$event.value"]},"type":"deviceAction"}]},"name":"language_save","style":{"weight":"0.50"},"text":"language_save","type":"switch","value":"false","visible":true},{"eventBinding":{"onLongPressDown":[{"params":{"actionName":"UIAction","args":["onLongPressDown","button_ID_12_2","$event.value"]},"type":"deviceAction"}],"onLongPressReleased":[{"params":{"actionName":"UIAction","args":["onLongPressReleased","button_ID_12_2","$event.value"]},"type":"deviceAction"}],"onReleased":[{"params":{"actionName":"UIAction","args":["onReleased","button_ID_12_2","$event.value"]},"type":"deviceAction"}]},"name":"button_ID_12_2","style":{"weight":"0.50"},"text":" ","type":"switch","value":"false","visible":false}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"eventBinding":{"onToggled":[{"params":{"actionName":"UIAction","args":["onToggled","language_select","$event.value"]},"type":"deviceAction"}]},"name":"language_select","options":[],"selectionType":"single","style":{"weight":"1.0"},"text":"language_select","type":"select","values":[],"visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"name":"info_Update","style":{"weight":"1.0"},"text":"info_Update","type":"label","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"eventBinding":{"onLongPressDown":[{"params":{"actionName":"UIAction","args":["onLongPressDown","Auto_Update","$event.value"]},"type":"deviceAction"}],"onLongPressReleased":[{"params":{"actionName":"UIAction","args":["onLongPressReleased","Auto_Update","$event.value"]},"type":"deviceAction"}],"onReleased":[{"params":{"actionName":"UIAction","args":["onReleased","Auto_Update","$event.value"]},"type":"deviceAction"}]},"name":"Auto_Update","style":{"weight":"0.50"},"text":"Auto_Update","type":"switch","value":"false","visible":true},{"eventBinding":{"onLongPressDown":[{"params":{"actionName":"UIAction","args":["onLongPressDown","button_ID_17_2"]},"type":"deviceAction"}],"onLongPressReleased":[{"params":{"actionName":"UIAction","args":["onLongPressReleased","button_ID_17_2"]},"type":"deviceAction"}],"onReleased":[{"params":{"actionName":"UIAction","args":["onReleased","button_ID_17_2"]},"type":"deviceAction"}]},"name":"button_ID_17_2","style":{"weight":"0.50"},"text":" ","type":"button","visible":false}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"eventBinding":{"onLongPressDown":[{"params":{"actionName":"UIAction","args":["onLongPressDown","Update_Now","$event.value"]},"type":"deviceAction"}],"onLongPressReleased":[{"params":{"actionName":"UIAction","args":["onLongPressReleased","Update_Now","$event.value"]},"type":"deviceAction"}],"onReleased":[{"params":{"actionName":"UIAction","args":["onReleased","Update_Now","$event.value"]},"type":"deviceAction"}]},"name":"Update_Now","style":{"weight":"0.50"},"text":"Update_Now","type":"switch","value":"false","visible":true},{"eventBinding":{"onLongPressDown":[{"params":{"actionName":"UIAction","args":["onLongPressDown","Check_Update","$event.value"]},"type":"deviceAction"}],"onLongPressReleased":[{"params":{"actionName":"UIAction","args":["onLongPressReleased","Check_Update","$event.value"]},"type":"deviceAction"}],"onReleased":[{"params":{"actionName":"UIAction","args":["onReleased","Check_Update","$event.value"]},"type":"deviceAction"}]},"name":"Check_Update","style":{"weight":"0.50"},"text":"Check_Update","type":"switch","value":"false","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"},{"components":[{"name":"space2","style":{"weight":"1.0"},"text":"space2","type":"label","visible":true}],"style":{"weight":"1.0"},"type":"horizontal"}],"useUiView":false,"uiCallbacks":[{"callback":"update","eventType":"onReleased","name":"update"},{"callback":"update_ld","eventType":"onLongPressDown","name":"update"},{"callback":"update_lr","eventType":"onLongPressReleased","name":"update"},{"callback":"a","eventType":"onReleased","name":"button_ID_8_2"},{"callback":"","eventType":"onLongPressDown","name":"button_ID_8_2"},{"callback":"","eventType":"onLongPressReleased","name":"button_ID_8_2"},{"callback":"led_brightness","eventType":"onChanged","name":"led_brightness"},{"callback":"installer_access24h","eventType":"onReleased","name":"installer_access24h"},{"callback":"installer_access24h_ld","eventType":"onLongPressDown","name":"installer_access24h"},{"callback":"installer_access24h_lr","eventType":"onLongPressReleased","name":"installer_access24h"},{"callback":"language_save","eventType":"onReleased","name":"language_save"},{"callback":"language_save_ld","eventType":"onLongPressDown","name":"language_save"},{"callback":"language_save_lr","eventType":"onLongPressReleased","name":"language_save"},{"callback":"a","eventType":"onReleased","name":"button_ID_12_2"},{"callback":"a","eventType":"onLongPressDown","name":"button_ID_12_2"},{"callback":"a","eventType":"onLongPressReleased","name":"button_ID_12_2"},{"callback":"language_select","eventType":"onToggled","name":"language_select"},{"callback":"Auto_Update","eventType":"onReleased","name":"Auto_Update"},{"callback":"Auto_Update_ld","eventType":"onLongPressDown","name":"Auto_Update"},{"callback":"Auto_Update_lr","eventType":"onLongPressReleased","name":"Auto_Update"},{"callback":"","eventType":"onReleased","name":"button_ID_17_2"},{"callback":"","eventType":"onLongPressDown","name":"button_ID_17_2"},{"callback":"","eventType":"onLongPressReleased","name":"button_ID_17_2"},{"callback":"Update_Now","eventType":"onReleased","name":"Update_Now"},{"callback":"Update_Now_ld","eventType":"onLongPressDown","name":"Update_Now"},{"callback":"Update_Now_lr","eventType":"onLongPressReleased","name":"Update_Now"},{"callback":"Check_Update","eventType":"onReleased","name":"Check_Update"},{"callback":"Check_Update_ld","eventType":"onLongPressDown","name":"Check_Update"},{"callback":"Check_Update_lr","eventType":"onLongPressReleased","name":"Check_Update"}],"quickAppVariables":[{"name":"Username","type":"password","value":"********"},{"name":"Password","type":"password","value":"********"}],"typeTemplateInitialized":true,"userDescription":""},"initialInterfaces":[],"files":[{"name":"main","isMain":true,"isOpen":true,"content":"--%%name:980.CPU Watch HC3\n--%%type:com.fibaro.multilevelSensor\n--%%manufacturer:JBS\n--%%model:HC3 CPU Monitor\n--%%description:Polls /diagnostics and displays CPU, memory and storage usage\n\n--%%u:{label=\"info_cpu\",text=\"Loading...\"}\n--%%u:{button=\"update\",text=\"Update\"}\n\n--%%u:{label=\"info_zwave\",text=\"Loading...\"}\n--%%u:{label=\"info_deaddevices\",text=\"Loading...\"}\n--%%u:{label=\"info_localaccess\",text=\"Loading...\"}\n\n--%%u:{label=\"Info\",text=\"Loading...\"}\n--%%u:{slider=\"led_brightness\",text=\"Hub LED Brightness\",min=0,max=100}\n\n--%%u:{label=\"info_users\",text=\"Loading...\"}\n--%%u:{label=\"info_installer\",text=\"Loading...\"}\n--%%u:{button=\"installer_access24h\",text=\"Grant Installer Access 24h\"}\n\n--%%u:{label=\"info_Update\",text=\"Loading...\"}\n--%%u:{{switch=\"Check_Update\",text=\"Check Update\",value=\"false\",onReleased=\"Check_Update\"},{switch=\"Auto_Update\",text=\"Auto Update\",value=\"false\",onReleased=\"Auto_Update\"}}\n--%%u:{switch=\"Update_Now\",text=\"Update Now\",value=\"false\",onReleased=\"Update_Now\"}\n\n--%%u:{label=\"space\",text=\"\"}\n\n--%%u:{label=\"info_manual\",text=\"\"}\n--%%u:{label=\"info_language\",text=\"\"}\n--%%u:{switch=\"language_save\",text=\"💾 Save Language\",onReleased=\"language_save\",onLongPressDown=\"language_save_ld\",onLongPressReleased=\"language_save_lr\"}\n--%%u:{select=\"language_select\",text=\"Language\",value=\"\",onToggled=\"language_select\",options={}}\n\n--%%u:{label=\"space2\",text=\"\"}\n\n--%%var:Username=\"\"\n--%%var:Password=\"\"\n--%%var:AutoUpdateTime=\"03:33\"\n--%%file:$fibaro.lib.eventlib,Eventlib\n--%%file:C:\\Users\\GREENCOM\\Workspace\\PLUA\\980.Setting Overview/980.Setting Overview_language.lua,Language\n\n--%%debug:true\n--%%desktop:true\n--%%offline:false\n\n---@diagnostic disable\n\n-- MIT License\n--\n-- Copyright (c) 2026 Joachim Brørs Sommerfeld\n--\n-- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n--\n-- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n--\n-- THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n--\n-- (Source: opensource.org)\n\nlocal qa_type = \"980.Setting Overview\"\nlocal version = \"v1.6.2\"\nlocal revision_date = \"18/07/26 05:02:57\"\n\nlocal quickAppUuid = \"JBS980\"\nlocal CONFIG = {\n deadDeviceLogDays = 7,\n cpuPersistIntervalSeconds = 10 * 60,\n ledDebounceMs = 750,\n qaDistManagerUuid = \"UPD896450032517893\",\n qaDistManagerForumUrl = \"https://forum.fibaro.com/topic/80052-qa-dist-manager/\",\n defaultAutoUpdateTime = \"03:33\",\n autoUpdatePrecheckSeconds = 5 * 60,\n startupUpdateCheckDelayMs = 60 * 1000,\n updateRequestTimeoutMs = 5 * 60 * 1000,\n}\nlocal C_LABEL = \"#ff9933\"\nlocal C_VALUE = \"#99ff33\"\nlocal C_RED = \"#ff0000\"\nlocal C_GREEN = \"#00ff00\"\nlocal POLL_MS = 20*1000\nlocal ZWAVE_POLL_MS = 60 * 60 * 1000\nlocal ZWAVE_STARTUP_DELAY_MS = 5 * 1000\nlocal ZWAVE_AUTH_MAX_ATTEMPTS = 2\nlocal STORAGE_KEYS = {\n avgSnapshot = \"avg_snapshot\",\n cpuHistory = \"cpu_history\",\n prevCpu = \"prev_cpu\",\n mobileDeviceMeta = \"mobile_device_meta\",\n languageCode = \"ui_language\",\n deadDeviceHistory = \"dead_device_history\",\n autoUpdateEnabled = \"auto_update\",\n}\n\nlocal HUB_LANGUAGE_CHOICES = {\n { code = \"en\", label = \"English\" },\n { code = \"no\", label = \"Norwegian\" },\n { code = \"en_US\", label = \"English US\" },\n { code = \"pl\", label = \"Polish\" },\n { code = \"de\", label = \"German\" },\n { code = \"fr\", label = \"French\" },\n { code = \"es\", label = \"Spanish\" },\n { code = \"it\", label = \"Italian\" },\n { code = \"pt\", label = \"Portuguese\" },\n { code = \"pt_br\", label = \"Portuguese Brazil\" },\n { code = \"nl\", label = \"Dutch\" },\n { code = \"da\", label = \"Danish\" },\n { code = \"fi\", label = \"Finnish\" },\n { code = \"sv\", label = \"Swedish\" },\n { code = \"cs\", label = \"Czech\" },\n { code = \"ru\", label = \"Russian\" },\n { code = \"et\", label = \"Estonian\" },\n { code = \"lt\", label = \"Lithuanian\" },\n { code = \"hu\", label = \"Hungarian\" },\n { code = \"sk\", label = \"Slovak\" },\n { code = \"ka\", label = \"Georgian\" },\n { code = \"ro\", label = \"Romanian\" },\n { code = \"tr\", label = \"Turkish\" },\n { code = \"zh_hans\", label = \"Chinese Simplified\" },\n { code = \"ja\", label = \"Japanese\" },\n { code = \"ar\", label = \"Arabic\" },\n}\n\nlocal HUB_LANGUAGE_INDEX = {}\nlocal LANGUAGE_DROPDOWN_OPTIONS = {}\nlocal CUSTOM_TRANSLATION_LANGUAGES = { en = true, no = true }\n\nfor index, item in ipairs(HUB_LANGUAGE_CHOICES) do\n HUB_LANGUAGE_INDEX[string.lower(item.code)] = item.code\n HUB_LANGUAGE_INDEX[tostring(index)] = item.code\n LANGUAGE_DROPDOWN_OPTIONS[#LANGUAGE_DROPDOWN_OPTIONS + 1] = {\n text = string.format(\"%s (%s)\", item.label, item.code),\n type = \"option\",\n value = item.code,\n }\nend\n\n-- Rolling-average ring buffer\nlocal POLL_S = POLL_MS / 1000\nlocal W_15M = math.ceil(15 * 60 / POLL_S) -- 90 samples\nlocal W_30M = math.ceil(30 * 60 / POLL_S) -- 180 samples\nlocal W_1H = math.ceil(60 * 60 / POLL_S) -- 360 samples\nlocal W_1D = math.ceil(24 * 60 * 60 / POLL_S) -- 8640 samples\n\nlocal function rbNew(max)\n return { data = {}, wp = 1, size = 0, max = max }\nend\n\nlocal function rbPush(rb, val)\n rb.data[rb.wp] = val\n rb.wp = (rb.wp % rb.max) + 1\n if rb.size < rb.max then rb.size = rb.size + 1 end\nend\n\n-- Returns average of the last `n` samples, or nil if ring is empty.\n-- When the ring has fewer than `n` samples the available ones are used\n-- and a \"(Xm)\" suffix string is returned as the second value.\nlocal function rbAvg(rb, n)\n if rb.size == 0 then return nil, nil end\n local count = math.min(n, rb.size)\n local sum = 0\n for i = 1, count do\n local pos = ((rb.wp - 1 - i + rb.max * 2) % rb.max) + 1\n sum = sum + rb.data[pos]\n end\n local avg = sum / count\n local suffix = (count < n) and string.format(\" (%dm)\", math.floor(count * POLL_S / 60)) or nil\n return avg, suffix\nend\n\nlocal function toNum(v)\n return tonumber(v) or 0\nend\n\nlocal function round1(v)\n return math.floor(v * 10 + 0.5) / 10\nend\n\nlocal function cpuTotal(c)\n return toNum(c.user) + toNum(c.system) + toNum(c.nice) + toNum(c.idle)\nend\n\nlocal function calcCpuPercent(prev, curr)\n local deltaTotal = cpuTotal(curr) - cpuTotal(prev)\n local deltaIdle = toNum(curr.idle) - toNum(prev.idle)\n if deltaTotal <= 0 then return 0 end\n return ((deltaTotal - deltaIdle) / deltaTotal) * 100\nend\n\nlocal function escHtml(s)\n s = tostring(s or \"\")\n s = s:gsub(\"&\", \"&\")\n s = s:gsub(\"<\", \"<\")\n s = s:gsub(\">\", \">\")\n return s\nend\n\nlocal function escHtmlNoWrap(s)\n s = escHtml(s)\n s = s:gsub(\"%-\", \"‑\")\n s = s:gsub(\" \", \" \")\n return s\nend\n\nlocal function trimText(value)\n return tostring(value or \"\"):gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\nend\n\nlocal function buildLabelCell(icon, name)\n local iconText = tostring(icon or \"\")\n if iconText ~= \"\" then\n iconText = escHtml(iconText) .. \" \"\n end\n return string.format(\n \"<span style='white-space:nowrap;'>%s<font color='%s'>%s:</font></span>\",\n iconText, C_LABEL, escHtmlNoWrap(name)\n )\nend\n\nlocal function wrapBlock(innerHtml)\n return \"<table width='100%' border='0' cellpadding='0' cellspacing='0'>\" .. innerHtml .. \"</table>\"\nend\n\nlocal function fullRow(content, align)\n return wrapBlock(string.format(\"<tr><td align='%s'>%s</td></tr>\", align or \"left\", content))\nend\n\nlocal SPACER = fullRow(\" \")\nlocal SEPARATOR = fullRow(\"<hr style='border:1px solid #ff9933;'>\")\n\nlocal function title(text)\n return fullRow(\n string.format(\"<font color='%s' size='4'><b>%s</b></font>\", C_LABEL, escHtml(text)),\n \"center\"\n )\nend\n\nlocal function versionLine(value)\n return fullRow(\n string.format(\"<font color='%s' size='2'>%s</font>\", C_VALUE, escHtml(value)),\n \"left\"\n )\nend\n\nlocal function row(icon, name, value)\n return wrapBlock(string.format(\n \"<tr><td align='left' style='white-space:nowrap;padding-right:10px;'>%s <font color='%s'>%s:</font></td><td align='right' style='white-space:nowrap;'><font color='%s'>%s</font></td></tr>\",\n escHtml(icon), C_LABEL, escHtml(name), C_VALUE, escHtml(value)\n ))\nend\n\nlocal function rowNoWrap(icon, name, value)\n return wrapBlock(string.format(\n \"<tr><td align='left' style='white-space:nowrap;padding-right:10px;'>%s</td><td align='right' style='white-space:nowrap;'><font color='%s'>%s</font></td></tr>\",\n buildLabelCell(icon, name), C_VALUE, escHtml(value)\n ))\nend\n\nlocal function zwaveNodeRow(icon, nodeId, nodeName, value)\n local label = \"Node \" .. tostring(nodeId or \"n/a\")\n local nameText = trimText(nodeName)\n if nameText ~= \"\" then\n nameText = \" \" .. escHtml(nameText)\n end\n return wrapBlock(string.format(\n \"<tr><td align='left' style='white-space:nowrap;padding-right:10px;'>%s <font color='%s'>%s:</font>%s</td><td align='right' style='white-space:nowrap;'><font color='%s'>%s</font></td></tr>\",\n escHtml(icon), C_LABEL, escHtml(label), nameText, C_VALUE, escHtml(value)\n ))\nend\n\nlocal function centeredMessage(color, text)\n return fullRow(\n string.format(\"<font color='%s' size='3'><b>%s</b></font>\", color, escHtml(text)),\n \"center\"\n )\nend\n\nlocal function italicNote(text)\n return fullRow(\n string.format(\"<font color='%s'><i>%s</i></font>\", C_LABEL, escHtml(text)),\n \"left\"\n )\nend\n\nlocal C_LINK = \"#5599ff\"\n\nlocal function linkRow(icon, name, url, description)\n local html = string.format(\n \"<tr><td align='left'>%s <font color='%s'>%s:</font></td></tr>\"\n .. \"<tr><td align='center'><a href='%s'><font color='%s' size='3'>%s</font></a></td></tr>\",\n escHtml(icon), C_LABEL, escHtml(name), escHtml(url), C_LINK, escHtml(url)\n )\n if description and description ~= \"\" then\n html = html .. string.format(\n \"<tr><td align='left'><font color='%s' size='2'><i>%s</i></font></td></tr>\",\n C_LINK, escHtml(description)\n )\n end\n return wrapBlock(html)\nend\n\nlocal function renderHtml(lines)\n return \"<table width='400px' border='0' cellpadding='1' cellspacing='0'><tr><td>\"\n .. table.concat(lines, \"\")\n .. \"</td></tr></table>\"\nend\n\nlocal currentLanguage, tt\n\n-- Builds one dead-device history entry with an orange name/ID and red timestamp.\n-------------------------------------------------------------------------------------------\nlocal function deadDeviceRow(entry)\n local deviceLabel = string.format(\"%s %s.\", escHtml(entry.name), escHtml(entry.id))\n local timestamp = os.date(\"%d.%m %H:%M\", tonumber(entry.lastDead) or os.time())\n return wrapBlock(string.format(\n \"<tr><td align='left'><font color='%s'><b>%s</b></font></td>\"\n .. \"<td align='right'><font color='%s'><b>%s</b></font></td></tr>\",\n C_LABEL, deviceLabel, C_RED, timestamp\n ))\nend\n\n-- Builds the persistent dead-device history panel.\n-------------------------------------------------------------------------------------------\nlocal function buildDeadDevicePanel(self)\n local panel = { title(tt(self, \"dead_devices\", \"Dead Devices\")) }\n local history = self.runtime and self.runtime.deadDeviceHistory or {}\n local cutoff = os.time() - (CONFIG.deadDeviceLogDays * 24 * 60 * 60)\n local entries = {}\n for _, entry in pairs(history) do\n if entry.visible == true and (tonumber(entry.lastDead) or 0) >= cutoff then\n entries[#entries + 1] = entry\n end\n end\n table.sort(entries, function(a, b)\n return (tonumber(a.lastDead) or 0) > (tonumber(b.lastDead) or 0)\n end)\n\n if #entries == 0 then\n panel[#panel + 1] = centeredMessage(C_GREEN, tt(self, \"no_dead_devices\", \"No dead devices recorded\"))\n return panel\n end\n\n local timestampTitle = string.format(\n tt(self, \"timestamp_log\", \"Timestamp (%dDays Log)\"),\n CONFIG.deadDeviceLogDays\n )\n panel[#panel + 1] = row(\"\", tt(self, \"name_id\", \"Name | ID\"), timestampTitle)\n for index, entry in ipairs(entries) do\n if index > 1 then panel[#panel + 1] = SEPARATOR end\n panel[#panel + 1] = deadDeviceRow(entry)\n end\n return panel\nend\n\n-- Builds a small centered section header inside an info card.\nlocal function sectionTitle(text)\n return fullRow(\n string.format(\"<font color='%s' size='3'><b>%s</b></font>\", C_LABEL, escHtml(text)),\n \"center\"\n )\nend\n\n-- Builds a left-aligned section header inside an info card.\nlocal function sectionTitleLeft(text)\n return fullRow(\n string.format(\"<font color='%s' size='3'><b>%s</b></font>\", C_LABEL, escHtml(text)),\n \"left\"\n )\nend\n\n-- Formats a Unix timestamp in milliseconds for local UI display.\nlocal function formatUnixMs(timestampMs)\n local value = tonumber(timestampMs)\n if not value or value <= 0 then return \"n/a\" end\n return os.date(\"%Y-%m-%d %H:%M:%S\", math.floor(value / 1000))\nend\n\n-- Formats the current hub/local clock for the CPU panel.\nlocal function formatHubClock()\n local now = os.date(\"*t\")\n if type(now) ~= \"table\" then return \"n/a\" end\n return string.format(\"%02d:%02d | %d.%d.%04d\", now.hour, now.min, now.day, now.month, now.year)\nend\n\n-- Returns the number of hours between two Unix timestamps in milliseconds.\nlocal function hoursBetweenUnixMs(fromMs, toMs)\n local fromValue = tonumber(fromMs)\n local toValue = tonumber(toMs)\n if not fromValue or not toValue or toValue <= fromValue then return nil end\n return (toValue - fromValue) / (1000 * 60 * 60)\nend\n\n-- Formats an hour span for compact UI display.\nlocal function formatHours(hours)\n if not hours then return \"n/a\" end\n return string.format(\"%.1fh\", hours)\nend\n\nlocal function normalizeLanguageCode(value)\n local raw = tostring(value or \"\"):gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\n if raw == \"\" then return nil end\n return HUB_LANGUAGE_INDEX[string.lower(raw)] or HUB_LANGUAGE_INDEX[raw]\nend\n\nlocal function getStoredLanguage(self)\n if self.savedLanguageCode == false then return nil end\n if type(self.savedLanguageCode) == \"string\" then return self.savedLanguageCode end\n\n local raw = self:internalStorageGet(STORAGE_KEYS.languageCode)\n if type(raw) ~= \"string\" then return nil end\n raw = raw:gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\n if raw == \"\" then\n self.savedLanguageCode = false\n return nil\n end\n self.savedLanguageCode = raw\n return raw\nend\n\nlocal function setStoredLanguage(self, languageCode)\n local normalized = normalizeLanguageCode(languageCode)\n if not normalized then\n self.savedLanguageCode = false\n self:internalStorageSet(STORAGE_KEYS.languageCode, \"\")\n return\n end\n self.savedLanguageCode = normalized\n self:internalStorageSet(STORAGE_KEYS.languageCode, normalized)\nend\n\nlocal function getHubDefaultLanguage(self)\n local info = self.currentData and self.currentData.settingsInfo\n or self.staticData and self.staticData.settingsInfo\n local raw = type(info) == \"table\" and tostring(info.defaultLanguage or \"\") or \"\"\n raw = raw:gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\n if raw == \"\" then return nil end\n return normalizeLanguageCode(raw) or raw\nend\n\nlocal function getDropdownLanguage(self)\n return normalizeLanguageCode(self.pendingLanguageCode)\n or normalizeLanguageCode(getStoredLanguage(self))\n or normalizeLanguageCode(getHubDefaultLanguage(self))\n or \"en\"\nend\n\nlocal function buildLanguagePanel(self)\n local qaLanguage = getStoredLanguage(self) or getHubDefaultLanguage(self) or \"en\"\n local currentLanguageValue = normalizeLanguageCode(qaLanguage) or qaLanguage\n local hubLanguage = getHubDefaultLanguage(self) or \"n/a\"\n local selectedLanguage = getDropdownLanguage(self)\n local panel = { title(tt(self, \"language\", \"Language\")) }\n\n panel[#panel + 1] = row(\"🌐\", tt(self, \"qa_language\", \"QA Language\"), qaLanguage)\n panel[#panel + 1] = row(\"🏠\", tt(self, \"hub_default_language\", \"Hub Default\"), hubLanguage)\n panel[#panel + 1] = row(\"📝\", tt(self, \"selected_language\", \"Selected\"), selectedLanguage)\n\n if selectedLanguage ~= currentLanguageValue then\n panel[#panel + 1] = row(\"💾\", tt(self, \"status\", \"Status\"), tt(self, \"pending_save\", \"Pending save\"))\n elseif not CUSTOM_TRANSLATION_LANGUAGES[string.lower(currentLanguageValue)] then\n panel[#panel + 1] = row(\"ℹ️\", tt(self, \"status\", \"Status\"), tt(self, \"english_fallback\", \"Uses English fallback\"))\n end\n\n return panel\nend\n\nlocal function refreshLanguageControls(self)\n local selectedLanguage = getDropdownLanguage(self)\n self:updateView(\"language_select\", \"options\", LANGUAGE_DROPDOWN_OPTIONS)\n self:updateView(\"language_select\", \"selectedItem\", selectedLanguage)\n self:updateView(\"language_select\", \"text\", tt(self, \"language\", \"Language\"))\n self:updateView(\"language_save\", \"text\", \"💾 \" .. tt(self, \"save_language\", \"Save Language\"))\n self:updateView(\"language_save\", \"value\", \"false\")\n self:updateView(\"info_language\", \"text\", renderHtml(buildLanguagePanel(self)))\nend\n\n-- Returns true for default HC3 names that are less useful in compact node rows.\nlocal function isGenericZwaveDeviceName(name, nodeId)\n local text = trimText(name)\n if text == \"\" then return true end\n if text == \"Z-Wave Node \" .. tostring(nodeId) then return true end\n if text:match(\"^\" .. tostring(nodeId) .. \"%.%d+%s+\") then return true end\n return false\nend\n\n-- Builds nodeId -> friendly device name from the full /devices list.\nlocal function buildZwaveNodeNameMap(devices)\n local bestByNode = {}\n if type(devices) ~= \"table\" then return bestByNode end\n\n for _, device in ipairs(devices) do\n if type(device) == \"table\" then\n local nodeId = tonumber(device.properties and device.properties.nodeId)\n local name = trimText(device.name)\n if nodeId and name ~= \"\" then\n local genericName = isGenericZwaveDeviceName(name, nodeId)\n local priority = 4\n if device.visible == true and not genericName then\n priority = 1\n elseif device.visible == true then\n priority = 2\n elseif not genericName then\n priority = 3\n end\n\n local current = bestByNode[nodeId]\n if not current or priority < current.priority or (priority == current.priority and toNum(device.id) < current.id) then\n bestByNode[nodeId] = { name = name, priority = priority, id = toNum(device.id) }\n end\n end\n end\n end\n\n local nodeNames = {}\n for nodeId, item in pairs(bestByNode) do\n nodeNames[tostring(nodeId)] = item.name\n end\n return nodeNames\nend\n\n-- Adds cached Z-Wave node names to a static-data table when /devices is available.\nlocal function addZwaveNodeNames(data)\n local ok, devices = pcall(function() return api.get(\"/devices\") end)\n if ok and type(devices) == \"table\" then\n data.zwaveNodeNames = buildZwaveNodeNameMap(devices)\n end\nend\n\n-- Backfills Z-Wave node names when staticData was restored from old storage.\nlocal function ensureZwaveNodeNames(self)\n self.staticData = self.staticData or {}\n if type(self.staticData.zwaveNodeNames) == \"table\" and next(self.staticData.zwaveNodeNames) ~= nil then\n return\n end\n\n addZwaveNodeNames(self.staticData)\n if type(self.staticData.zwaveNodeNames) == \"table\" and next(self.staticData.zwaveNodeNames) ~= nil then\n self:saveStaticData()\n end\nend\n\n-- Returns the cached friendly name for a Z-Wave node when available.\nlocal function getZwaveNodeName(self, nodeId)\n local nodeNames = self.staticData and self.staticData.zwaveNodeNames\n local nodeName = type(nodeNames) == \"table\" and nodeNames[tostring(nodeId or \"\")] or nil\n return trimText(nodeName)\nend\n\n-- Builds the Z-Wave transmission diagnostics panel shown in the QA UI.\nlocal function buildZwaveDiagnosticPanel(self, zwaveData)\n local panel = { title(tt(self, \"z_wave_diagnostic\", \"Z-Wave Diagnostic\")) }\n if type(zwaveData) ~= \"table\" then\n panel[#panel + 1] = row(\"📡\", tt(self, \"transmissions\", \"Transmissions\"), \"n/a\")\n return panel\n end\n\n panel[#panel + 1] = sectionTitle(tt(self, \"data\", \"Data\"))\n panel[#panel + 1] = row(\"🕒\", tt(self, \"from\", \"From\"), formatUnixMs(zwaveData.since))\n panel[#panel + 1] = row(\"🕒\", tt(self, \"to\", \"To\"), formatUnixMs(zwaveData[\"until\"]))\n local durationHours = hoursBetweenUnixMs(zwaveData.since, zwaveData[\"until\"])\n panel[#panel + 1] = row(\"⏱️\", tt(self, \"duration\", \"Duration\"), formatHours(durationHours))\n panel[#panel + 1] = SPACER\n panel[#panel + 1] = sectionTitleLeft(tt(self, \"top_10\", \"Top 10\"))\n panel[#panel + 1] = row(\"\", \"nodeId / Name\", tt(self, \"incoming_total_each_hours\", \"incomingTotal/Each Hours\"))\n\n local items = {}\n if type(zwaveData.items) == \"table\" then\n for _, item in ipairs(zwaveData.items) do\n items[#items + 1] = item\n end\n end\n\n table.sort(items, function(a, b)\n local incomingA = toNum(a and a.incomingTotal)\n local incomingB = toNum(b and b.incomingTotal)\n if incomingA == incomingB then\n return toNum(a and a.nodeId) < toNum(b and b.nodeId)\n end\n return incomingA > incomingB\n end)\n\n if #items == 0 then\n panel[#panel + 1] = row(\"📡\", tt(self, \"nodes\", \"Nodes\"), \"n/a\")\n return panel\n end\n\n for i = 1, math.min(10, #items) do\n local item = items[i]\n local incomingTotal = math.floor(toNum(item.incomingTotal))\n local valueText = durationHours\n and string.format(\"%.2f\", incomingTotal / durationHours)\n or string.format(\"%d\", incomingTotal)\n panel[#panel + 1] = zwaveNodeRow(\n \"📶\",\n item.nodeId,\n getZwaveNodeName(self, item.nodeId),\n valueText\n )\n end\n return panel\nend\n\n-- Builds the Z-Wave status panel shown before data is available.\nlocal function buildZwavePendingPanel(self, statusText, statusKey)\n local resolvedStatus = statusKey and tt(self, statusKey, statusText) or tostring(statusText or tt(self, \"status_waiting_for_startup_fetch\", \"Waiting for startup fetch...\"))\n return {\n title(tt(self, \"z_wave_diagnostic\", \"Z-Wave Diagnostic\")),\n row(\"📡\", tt(self, \"status\", \"Status\"), resolvedStatus)\n }\nend\n\n-- Formats HTTP status codes for clearer QA diagnostics.\nlocal function formatHttpStatus(status)\n local code = tonumber(status)\n local labels = {\n [400] = \"Bad Request\",\n [401] = \"Unauthorized\",\n [403] = \"Forbidden\",\n [404] = \"Not Found\",\n [405] = \"Method Not Allowed\",\n [406] = \"Not Acceptable\",\n [408] = \"Request Timeout\",\n [409] = \"Conflict\",\n [415] = \"Unsupported Media Type\",\n [429] = \"Too Many Requests\",\n }\n if code and labels[code] then\n return string.format(\"HTTP %d %s\", code, labels[code])\n end\n return \"HTTP \" .. tostring(status or \"n/a\")\nend\n\n-- Returns the active QA language code.\ncurrentLanguage = function(self)\n local qaLanguage = getStoredLanguage(self)\n if qaLanguage then return string.lower(qaLanguage) end\n local hubLanguage = getHubDefaultLanguage(self)\n if hubLanguage then return string.lower(hubLanguage) end\n return \"en\"\nend\n\n-- Returns translated UI text when a language dictionary is available.\ntt = function(self, key, fallback)\n if type(SettingsOverviewLanguage) == \"table\" and type(SettingsOverviewLanguage.text) == \"function\" then\n return SettingsOverviewLanguage:text(currentLanguage(self), key, fallback)\n end\n return fallback or key\nend\n\nlocal function readStoredTable(self, key)\n local raw = self:internalStorageGet(key)\n if type(raw) == \"table\" then return raw end\n if type(raw) ~= \"string\" or raw == \"\" then return nil end\n local ok, decoded = pcall(json.decode, raw)\n if ok and type(decoded) == \"table\" then return decoded end\n return nil\nend\n\nfunction QuickApp:writeStoredTable(key, value)\n local ok, encoded = pcall(json.encode, value)\n if ok then self:internalStorageSet(key, encoded) end\nend\n\n-- Converts HC3 dead-property values to a strict boolean.\n-------------------------------------------------------------------------------------------\nlocal function normalizeDeadValue(value)\n if value == true or value == 1 then return true end\n if value == false or value == 0 or value == nil then return false end\n local text = string.lower(trimText(value))\n return text == \"true\" or text == \"1\" or text == \"yes\"\nend\n\n-- Sanitizes persisted dead-device records before they enter the runtime cache.\n-------------------------------------------------------------------------------------------\nlocal function normalizeDeadDeviceHistory(history)\n local normalized = {}\n if type(history) ~= \"table\" then return normalized end\n for storedId, entry in pairs(history) do\n if type(entry) == \"table\" then\n local deviceId = tonumber(entry.id) or tonumber(storedId) or storedId\n local count = tonumber(entry.count) or 0\n if count > 0 then\n normalized[tostring(deviceId)] = {\n id = deviceId,\n name = trimText(entry.name) ~= \"\" and trimText(entry.name) or (\"Device \" .. tostring(deviceId)),\n count = math.floor(count),\n lastDead = tonumber(entry.lastDead) or os.time(),\n isDead = normalizeDeadValue(entry.isDead),\n visible = normalizeDeadValue(entry.visible),\n }\n end\n end\n end\n return normalized\nend\n\n-- Returns the HC3 modification time for the dead property when available.\n-------------------------------------------------------------------------------------------\nlocal function getDeadPropertyTimestamp(deviceId)\n local ok, _, modified = pcall(fibaro.get, deviceId, \"dead\")\n local timestamp = ok and tonumber(modified) or nil\n if timestamp and timestamp > 100000000000 then timestamp = math.floor(timestamp / 1000) end\n return timestamp or os.time()\nend\n\n-- Persists the cached history only after a real state or metadata change.\n-------------------------------------------------------------------------------------------\nlocal function saveDeadDeviceHistory(self)\n self:writeStoredTable(STORAGE_KEYS.deadDeviceHistory, self.runtime.deadDeviceHistory)\nend\n\n-- Removes dead-device records older than the configured retention period.\n-------------------------------------------------------------------------------------------\nlocal function pruneDeadDeviceHistory(self)\n local history = self.runtime and self.runtime.deadDeviceHistory or {}\n local cutoff = os.time() - (CONFIG.deadDeviceLogDays * 24 * 60 * 60)\n local changed = false\n for key, entry in pairs(history) do\n if type(entry) ~= \"table\" or (tonumber(entry.lastDead) or 0) < cutoff then\n history[key], changed = nil, true\n end\n end\n return changed\nend\n\n-- Applies one dead/alive state and returns true when the history changed.\n-------------------------------------------------------------------------------------------\nlocal function applyDeadDeviceState(self, deviceId, deviceName, deviceVisible, isDead, timestamp)\n local key = tostring(tonumber(deviceId) or deviceId)\n local history = self.runtime.deadDeviceHistory\n local entry = history[key]\n if not entry and not isDead then return false end\n if not entry then\n entry = { id = tonumber(deviceId) or deviceId, count = 0, isDead = false }\n history[key] = entry\n end\n\n local changed = false\n local cleanName = trimText(deviceName)\n if cleanName ~= \"\" and entry.name ~= cleanName then entry.name, changed = cleanName, true end\n if deviceVisible ~= nil then\n local visible = normalizeDeadValue(deviceVisible)\n if entry.visible ~= visible then entry.visible, changed = visible, true end\n end\n if isDead and entry.isDead ~= true then\n entry.count = (tonumber(entry.count) or 0) + 1\n entry.lastDead, entry.isDead, changed = tonumber(timestamp) or os.time(), true, true\n elseif not isDead and entry.isDead == true then\n entry.isDead, changed = false, true\n end\n return changed\nend\n\n-- Refreshes only the dedicated dead-device label.\n-------------------------------------------------------------------------------------------\nlocal function refreshDeadDevicePanel(self)\n self:updateView(\"info_deaddevices\", \"text\", renderHtml(buildDeadDevicePanel(self)))\nend\n\n-- Reconciles persisted history with the current /devices dead properties at startup.\n-------------------------------------------------------------------------------------------\nlocal function scanDeadDevices(self)\n local ok, devices = pcall(function() return api.get(\"/devices\") end)\n if not ok or type(devices) ~= \"table\" then\n self:warning(\"Dead-device startup scan failed: \" .. tostring(devices))\n return\n end\n\n local changed = false\n for _, device in ipairs(devices) do\n local deadValue = device.properties and device.properties.dead\n local known = self.runtime.deadDeviceHistory[tostring(device.id)] ~= nil\n if deadValue ~= nil and (known or normalizeDeadValue(deadValue)) then\n local timestamp = normalizeDeadValue(deadValue) and getDeadPropertyTimestamp(device.id) or os.time()\n changed = applyDeadDeviceState(\n self, device.id, device.name, device.visible, normalizeDeadValue(deadValue), timestamp\n ) or changed\n end\n end\n changed = pruneDeadDeviceHistory(self) or changed\n if changed then saveDeadDeviceHistory(self) end\n refreshDeadDevicePanel(self)\nend\n\n-- Records Eventlib dead-property transitions and keeps historic devices visible.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:onDeadDeviceEvent(event)\n self.runtime = self.runtime or {}\n self.runtime.deadDeviceHistory = self.runtime.deadDeviceHistory or {}\n local historyPruned = pruneDeadDeviceHistory(self)\n local deviceId = tonumber(event and event.id)\n if not deviceId then\n if historyPruned then saveDeadDeviceHistory(self) end\n return\n end\n\n local existing = self.runtime.deadDeviceHistory[tostring(deviceId)]\n local ok, device = pcall(function() return api.get(\"/devices/\" .. deviceId) end)\n local deviceName = existing and existing.name\n local deviceVisible = existing and existing.visible\n if ok and type(device) == \"table\" then\n deviceName, deviceVisible = device.name, device.visible\n end\n local isDead = normalizeDeadValue(event.value)\n local changed = applyDeadDeviceState(\n self, deviceId, deviceName, deviceVisible, isDead, getDeadPropertyTimestamp(deviceId)\n )\n if not changed and not historyPruned then return end\n saveDeadDeviceHistory(self)\n refreshDeadDevicePanel(self)\n if isDead then self:warning(string.format(\"Device %s (%d) went dead\", tostring(deviceName), deviceId)) end\nend\n\nlocal function sanitizeMobileDeviceDetail(deviceId, details)\n if type(details) ~= \"table\" then return nil end\n local props = type(details.properties) == \"table\" and details.properties or {}\n local normalizedId = tonumber(details.id) or tonumber(deviceId) or deviceId\n return {\n id = normalizedId,\n properties = {\n appId = props.appId,\n lastLoggedUser = props.lastLoggedUser,\n system = props.system,\n },\n }\nend\n\nlocal function normalizeDeviceDetailsMap(deviceDetails)\n local normalized = {}\n if type(deviceDetails) ~= \"table\" then return normalized end\n for deviceId, details in pairs(deviceDetails) do\n local safeDetails = sanitizeMobileDeviceDetail(deviceId, details)\n if safeDetails then\n normalized[safeDetails.id] = safeDetails\n end\n end\n return normalized\nend\n\nlocal function getStoredDeviceDetails(deviceDetails, deviceId)\n if type(deviceDetails) ~= \"table\" then return nil end\n return deviceDetails[deviceId] or deviceDetails[tostring(deviceId)]\nend\n\nlocal function restoreDeviceDetailsFromMeta(self)\n local restored = {}\n local meta = readStoredTable(self, STORAGE_KEYS.mobileDeviceMeta)\n if type(meta) ~= \"table\" then return restored end\n for _, item in ipairs(meta) do\n local deviceId = tonumber(item.id) or item.id\n if deviceId ~= nil then\n restored[deviceId] = {\n id = deviceId,\n properties = {\n appId = item.appType ~= \"n/a\" and item.appType or nil,\n lastLoggedUser = item.lastLoggedUser,\n system = item.system ~= \"n/a\" and item.system or nil,\n },\n }\n end\n end\n return restored\nend\n\nlocal function buildMobileDeviceMeta(iosDevices, deviceDetails)\n local meta = {}\n if type(iosDevices) ~= \"table\" then return meta end\n\n for _, device in ipairs(iosDevices) do\n local details = getStoredDeviceDetails(deviceDetails, device.id)\n local props = details and details.properties or nil\n meta[#meta + 1] = {\n id = (details and details.id) or device.id,\n appType = (props and props.appId) or \"n/a\",\n lastLoggedUser = (props and props.lastLoggedUser) or nil,\n system = (props and props.system) or \"n/a\",\n }\n end\n return meta\nend\n\nfunction QuickApp:saveMobileDeviceMeta()\n local iosDevices = self.staticData and self.staticData.iosDevices or {}\n local deviceDetails = self.staticData and self.staticData.deviceDetails or {}\n local meta = buildMobileDeviceMeta(iosDevices, deviceDetails)\n self:writeStoredTable(STORAGE_KEYS.mobileDeviceMeta, meta)\nend\n\nfunction QuickApp:saveStaticData()\n local payload = {}\n if type(self.staticData) == \"table\" then\n for key, value in pairs(self.staticData) do\n payload[key] = value\n end\n payload.deviceDetails = normalizeDeviceDetailsMap(self.staticData.deviceDetails)\n end\n local ok, encoded = pcall(json.encode, payload)\n if ok then\n self:internalStorageSet(\"staticData\", encoded)\n end\n self:saveMobileDeviceMeta()\nend\n\nfunction QuickApp:loadStaticData()\n local raw = self:internalStorageGet(\"staticData\")\n if type(raw) == \"string\" then\n local ok, decoded = pcall(json.decode, raw)\n if ok and type(decoded) == \"table\" then\n decoded.deviceDetails = normalizeDeviceDetailsMap(decoded.deviceDetails)\n if next(decoded.deviceDetails) == nil then\n decoded.deviceDetails = restoreDeviceDetailsFromMeta(self)\n end\n self.staticData = decoded\n return true\n end\n end\n return false\nend\n\nlocal function restoreRingBuffer(saved)\n local rb = rbNew(W_1D)\n if type(saved) ~= \"table\" then return rb end\n\n local data = type(saved.data) == \"table\" and saved.data or {}\n local limit = math.min(#data, W_1D)\n for i = 1, limit do\n rb.data[i] = tonumber(data[i]) or 0\n end\n\n rb.wp = math.max(1, math.min(tonumber(saved.wp) or 1, W_1D))\n rb.size = math.max(0, math.min(tonumber(saved.size) or 0, W_1D))\n return rb\nend\n\nlocal function loadInstallerTimeline(self)\n local raw = self:internalStorageGet(\"installerTimeline\")\n if type(raw) ~= \"string\" or raw == \"\" then\n return { grantedAt = nil, expiresAt = nil, withdrawnAt = nil }\n end\n local ok, decoded = pcall(json.decode, raw)\n if not ok or type(decoded) ~= \"table\" then\n return { grantedAt = nil, expiresAt = nil, withdrawnAt = nil }\n end\n return {\n grantedAt = decoded.grantedAt,\n expiresAt = decoded.expiresAt,\n withdrawnAt = decoded.withdrawnAt,\n }\nend\n\nlocal function saveInstallerTimeline(self)\n local payload = {\n grantedAt = self.installerGrantedAt,\n expiresAt = self.installerExpiresAt,\n withdrawnAt = self.installerWithdrawnAt,\n }\n local ok, encoded = pcall(json.encode, payload)\n if ok then\n self:internalStorageSet(\"installerTimeline\", encoded)\n end\nend\n\nlocal function base64Encode(data)\n local b='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'\n return ((data:gsub('.', function(x)\n local r,bits='',''\n local byte=string.byte(x)\n for i=8,1,-1 do bits=bits..(math.floor(byte / 2^(i-1)) % 2) end\n return bits\n end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x)\n if #x < 6 then return '' end\n local c=0\n for i=1,6 do c=c + (x:sub(i,i)=='1' and 2^(6-i) or 0) end\n return b:sub(c+1, c+1)\n end)..({ '', '==', '=' })[#data % 3 + 1])\nend\n\nlocal function parseBoolFromEvent(event)\n if type(event) ~= \"table\" or type(event.values) ~= \"table\" then\n return nil\n end\n local raw = event.values[1]\n if type(raw) == \"boolean\" then\n return raw\n end\n local normalized = tostring(raw or \"\"):lower()\n if normalized == \"true\" or normalized == \"1\" or normalized == \"on\" then\n return true\n end\n if normalized == \"false\" or normalized == \"0\" or normalized == \"off\" then\n return false\n end\n return nil\nend\n\nlocal function setInstallerAccessButtonText(self, enabled)\n local installerStatus = enabled and \"✅ true\" or \"❌ false\"\n self:updateView(\"installer_access24h\", \"text\", string.format(\"Installer Remote Access (%s)\", installerStatus))\nend\n\nlocal function setInstallerAccessButtonValue(self, enabled)\n self:updateView(\"installer_access24h\", \"value\", enabled and \"true\" or \"false\")\nend\n\nlocal function refreshInstallerAccessButton(self, enabled)\n setInstallerAccessButtonText(self, enabled)\n setInstallerAccessButtonValue(self, enabled)\nend\n\nlocal function refreshLedBrightnessSlider(self, brightness)\n self:updateView(\"led_brightness\", \"value\", tostring(toNum(brightness)))\nend\n\nlocal function syncInstallationAccessState(self, newEnabled, now)\n local ok, res = pcall(function()\n return api.put(\"/settings/installationAccess\", {\n installationAccess = newEnabled,\n installationAccessExpireTime = newEnabled and (now + 24 * 3600) or 0,\n })\n end)\n if not ok then\n self:debug(\"Could not sync settings/installationAccess (error): \" .. tostring(res))\n return false\n end\n self:debug(\"settings/installationAccess sync succeeded\")\n return true\nend\n\nlocal function requestInstallerAccessWithAdmin(self, newEnabled, previousEnabled)\n local adminUser = tostring(self:getVariable(\"Username\") or \"\")\n local adminPassword = tostring(self:getVariable(\"Password\") or \"\")\n if adminUser == \"\" or adminPassword == \"\" then\n self:error(\"Admin credentials not set (Username/Password)\")\n setInstallerAccessButtonValue(self, previousEnabled)\n return false\n end\n\n local authHeader = \"Basic \" .. base64Encode(adminUser .. \":\" .. adminPassword)\n self.http = self.http or net.HTTPClient()\n local options = {\n options = {\n method = \"PUT\",\n headers = {\n [\"Content-Type\"] = \"application/json\",\n [\"Authorization\"] = authHeader,\n },\n checkCertificate = true,\n },\n data = json.encode({ remoteAccess = newEnabled }),\n success = function(resp)\n self:debug(\"Installer access set via admin auth, status\", tostring(resp.status))\n if resp.status == 200 or resp.status == 204 then\n self.installerInfo = self.installerInfo or {}\n self.installerInfo.remoteAccess = newEnabled\n local now = os.time()\n if newEnabled then\n self.installerGrantedAt = now\n self.installerExpiresAt = now + 24 * 3600\n self.installerWithdrawnAt = nil\n else\n self.installerWithdrawnAt = now\n self.installerExpiresAt = nil\n end\n saveInstallerTimeline(self)\n self.credentialError = false -- success\n syncInstallationAccessState(self, newEnabled, now)\n setInstallerAccessButtonText(self, newEnabled)\n self:scheduleDiagnosticsPoll(2000)\n else\n self:error(\"Admin installer access request failed, status \" .. tostring(resp.status))\n self.credentialError = true -- error\n setInstallerAccessButtonValue(self, previousEnabled)\n end\n end,\n error = function(err)\n self:error(\"Admin installer access HTTP error: \" .. tostring(err))\n self.credentialError = true -- error\n setInstallerAccessButtonValue(self, previousEnabled)\n end,\n }\n\n local url = \"https://127.0.0.1/installer\"\n self.http:request(url, options)\n return true\nend\n\nlocal function avgText(value, suffix)\n return string.format(\"%.1f%%%s\", round1(value), suffix or \"\")\nend\n\nlocal function buildAvgSnapshot(self, avgPct)\n local avg15, s15 = rbAvg(self.cpuHistory, W_15M)\n local avg30, s30 = rbAvg(self.cpuHistory, W_30M)\n local avg1h, s1h = rbAvg(self.cpuHistory, W_1H)\n local avg1d, s1d = rbAvg(self.cpuHistory, W_1D)\n return {\n cpuAvg = avgPct,\n avg15 = avg15, avg15Sfx = s15,\n avg30 = avg30, avg30Sfx = s30,\n avg1h = avg1h, avg1hSfx = s1h,\n avg1d = avg1d, avg1dSfx = s1d,\n }\nend\n\nlocal function buildAvgRows(self, snapshot)\n local rows = {}\n if type(snapshot) ~= \"table\" then return rows end\n\n if tonumber(snapshot.cpuAvg) then\n rows[#rows + 1] = row(\"📊\", tt(self, \"cpu_avg\", \"CPU avg\"), avgText(snapshot.cpuAvg))\n end\n if tonumber(snapshot.avg15) then\n rows[#rows + 1] = row(\"📈\", tt(self, \"avg_15m\", \"15m avg\"), avgText(snapshot.avg15, snapshot.avg15Sfx))\n end\n if tonumber(snapshot.avg30) then\n rows[#rows + 1] = row(\"📈\", tt(self, \"avg_30m\", \"30m avg\"), avgText(snapshot.avg30, snapshot.avg30Sfx))\n end\n if tonumber(snapshot.avg1h) then\n rows[#rows + 1] = row(\"📈\", tt(self, \"avg_1h\", \"1h avg\"), avgText(snapshot.avg1h, snapshot.avg1hSfx))\n end\n if tonumber(snapshot.avg1d) then\n rows[#rows + 1] = row(\"📈\", tt(self, \"avg_24h\", \"24h avg\"), avgText(snapshot.avg1d, snapshot.avg1dSfx))\n end\n return rows\nend\n\nlocal function persistCpuState(self, force)\n self.runtime = self.runtime or {}\n local now = os.time()\n local lastPersistedAt = tonumber(self.runtime.cpuPersistedAt) or 0\n if not force and now - lastPersistedAt < CONFIG.cpuPersistIntervalSeconds then return false end\n\n self:writeStoredTable(STORAGE_KEYS.prevCpu, self.prevCpu or {})\n self:writeStoredTable(STORAGE_KEYS.avgSnapshot, self.avgSnapshot or {})\n self:writeStoredTable(STORAGE_KEYS.cpuHistory, {\n data = self.cpuHistory and self.cpuHistory.data or {},\n wp = self.cpuHistory and self.cpuHistory.wp or 1,\n size = self.cpuHistory and self.cpuHistory.size or 0,\n })\n self.runtime.cpuPersistedAt = now\n return true\nend\n\nlocal function sampleThisQaCpu(self)\n local nowCpu = os.clock()\n local nowWall = os.time()\n\n if not self.prevQaCpuClock or not self.prevQaWallTime then\n self.prevQaCpuClock = nowCpu\n self.prevQaWallTime = nowWall\n return nil\n end\n\n local deltaWall = nowWall - self.prevQaWallTime\n local deltaCpu = nowCpu - self.prevQaCpuClock\n self.prevQaCpuClock = nowCpu\n self.prevQaWallTime = nowWall\n\n if deltaWall <= 0 then return nil end\n return math.max(0, (deltaCpu / deltaWall) * 100)\nend\n\nlocal function fetchMobileDeviceDetails(self, devices)\n if type(devices) ~= \"table\" then return end\n self.staticData = self.staticData or {}\n self.staticData.deviceDetails = self.staticData.deviceDetails or {}\n\n for _, device in ipairs(devices) do\n local ok, dev = pcall(function() return api.get(\"/devices/\" .. device.id) end)\n if ok and type(dev) == \"table\" then\n self.staticData.deviceDetails[device.id] = sanitizeMobileDeviceDetail(device.id, dev)\n end\n end\n\n self:saveStaticData()\n refreshLanguageControls(self)\nend\n\nfunction QuickApp:fetchStaticData(stagger, startupDelayDeviceDetails)\n local data = {}\n for key, value in pairs(self.staticData or {}) do data[key] = value end\n if not stagger then\n -- Synchronous calls for startup and button\n local ok2, info = pcall(function() return api.get(\"/settings/info\") end)\n if ok2 and type(info) == \"table\" then\n data.settingsInfo = info\n end\n\n local ok3, led = pcall(function() return api.get(\"/settings/led\") end)\n if ok3 and type(led) == \"table\" then\n data.ledBrightness = led.brightness\n refreshLedBrightnessSlider(self, led.brightness)\n end\n\n local ok4, net = pcall(function() return api.get(\"/settings/network\") end)\n if ok4 and type(net) == \"table\" then\n data.networkConfig = net.networkConfig\n end\n\n local ok5, users = pcall(function() return api.get(\"/users?includeInstaller=true\") end)\n if ok5 and type(users) == \"table\" then\n data.users = users\n end\n\n addZwaveNodeNames(data)\n\n local ok6, instAccess = pcall(function() return api.get(\"/settings/installationAccess\") end)\n if ok6 and type(instAccess) == \"table\" then\n data.installerAccess = instAccess\n end\n\n local ok7, installerInfo = pcall(function() return api.get(\"/installer\") end)\n if ok7 and type(installerInfo) == \"table\" then\n data.installerInfo = installerInfo\n self.installerInfo = installerInfo\n else\n self.installerInfo = self.installerInfo or nil\n end\n\n local iosDevicesList = nil\n local ok8, iosDevices = pcall(function() return api.get(\"/iosDevices\") end)\n if ok8 and type(iosDevices) == \"table\" then\n iosDevicesList = iosDevices\n data.iosDevices = iosDevices\n local previousDetails = normalizeDeviceDetailsMap(data.deviceDetails)\n data.deviceDetails = {}\n for _, device in ipairs(iosDevices) do\n local previous = getStoredDeviceDetails(previousDetails, device.id)\n if previous then data.deviceDetails[device.id] = previous end\n end\n if not startupDelayDeviceDetails then\n for _, device in ipairs(iosDevices) do\n local ok, dev = pcall(function() return api.get(\"/devices/\" .. device.id) end)\n if ok and type(dev) == \"table\" then\n data.deviceDetails[device.id] = sanitizeMobileDeviceDetail(device.id, dev)\n end\n end\n end\n end\n\n self.staticData = data\n self:saveStaticData()\n refreshLanguageControls(self)\n\n if startupDelayDeviceDetails and iosDevicesList and #iosDevicesList > 0 then\n setTimeout(function()\n self:debug(\"Startup delayed fetch: /devices/{id} for mobile devices\")\n fetchMobileDeviceDetails(self, iosDevicesList)\n self:scheduleDiagnosticsPoll(0)\n end, 10000)\n end\n else\n -- Staggered calls for midnight refresh\n local fetchSettings, fetchLed, fetchNetwork, fetchUsers, fetchInstAccess, fetchInstallerInfo, fetchIosDevices\n\n fetchSettings = function()\n local ok, info = pcall(function() return api.get(\"/settings/info\") end)\n if ok and type(info) == \"table\" then data.settingsInfo = info end\n setTimeout(function() fetchLed() end, 5000)\n end\n\n fetchLed = function()\n local ok, led = pcall(function() return api.get(\"/settings/led\") end)\n if ok and type(led) == \"table\" then\n data.ledBrightness = led.brightness\n refreshLedBrightnessSlider(self, led.brightness)\n end\n setTimeout(function() fetchNetwork() end, 5000)\n end\n\n fetchNetwork = function()\n local ok, net = pcall(function() return api.get(\"/settings/network\") end)\n if ok and type(net) == \"table\" then data.networkConfig = net.networkConfig end\n setTimeout(function() fetchUsers() end, 5000)\n end\n\n fetchUsers = function()\n local ok, users = pcall(function() return api.get(\"/users?includeInstaller=true\") end)\n if ok and type(users) == \"table\" then data.users = users end\n addZwaveNodeNames(data)\n setTimeout(function() fetchInstAccess() end, 5000)\n end\n\n fetchInstAccess = function()\n local ok, instAccess = pcall(function() return api.get(\"/settings/installationAccess\") end)\n if ok and type(instAccess) == \"table\" then data.installerAccess = instAccess end\n setTimeout(function() fetchInstallerInfo() end, 5000)\n end\n\n fetchInstallerInfo = function()\n local ok, installerInfo = pcall(function() return api.get(\"/installer\") end)\n if ok and type(installerInfo) == \"table\" then\n data.installerInfo = installerInfo\n self.installerInfo = installerInfo\n else\n self.installerInfo = self.installerInfo or nil\n end\n setTimeout(function() fetchIosDevices() end, 5000)\n end\n\n fetchIosDevices = function()\n local ok, iosDevices = pcall(function() return api.get(\"/iosDevices\") end)\n if ok and type(iosDevices) == \"table\" then\n data.iosDevices = iosDevices\n local previousDetails = normalizeDeviceDetailsMap(data.deviceDetails)\n data.deviceDetails = {}\n for _, device in ipairs(iosDevices) do\n local previous = getStoredDeviceDetails(previousDetails, device.id)\n if previous then data.deviceDetails[device.id] = previous end\n end\n local devices = iosDevices\n local function fetchDeviceDetail(i)\n if i > #devices then\n self.staticData = data\n self:saveStaticData()\n refreshLanguageControls(self)\n return\n end\n local device = devices[i]\n local ok, dev = pcall(function() return api.get(\"/devices/\" .. device.id) end)\n if ok and type(dev) == \"table\" then\n data.deviceDetails[device.id] = sanitizeMobileDeviceDetail(device.id, dev)\n end\n setTimeout(function() fetchDeviceDetail(i+1) end, 5000)\n end\n if #devices > 0 then\n fetchDeviceDetail(1)\n else\n self.staticData = data\n self:saveStaticData()\n refreshLanguageControls(self)\n end\n else\n self.staticData = data\n self:saveStaticData()\n refreshLanguageControls(self)\n end\n end\n\n fetchSettings()\n end\nend\n\n-- =========================================================\n-- -------Staggered API Refresh for All APIs---------\n-- =========================================================\nfunction QuickApp:fetchAllApis()\n self:debug(\"Refreshing all APIs with 5s stagger between calls\")\n self:fetchStaticData(true)\nend\n\n-- Refreshes only the dedicated Z-Wave diagnostics label.\nfunction QuickApp:updateZwaveDiagnosticLabel()\n local panel = type(self.zwaveTransmissions) == \"table\"\n and buildZwaveDiagnosticPanel(self, self.zwaveTransmissions)\n or buildZwavePendingPanel(self, self.zwaveStatusText, self.zwaveStatusKey)\n self:updateView(\"info_zwave\", \"text\", renderHtml(panel))\nend\n\n-- Reads Z-Wave transmissions and refreshes the dedicated diagnostics card.\nfunction QuickApp:fetchZwaveTransmissions()\n local username = tostring(self:getVariable(\"Username\") or \"\")\n local password = tostring(self:getVariable(\"Password\") or \"\")\n local credentialKey = username .. \"\\0\" .. password\n\n if self.zwaveCredentialKey ~= credentialKey then\n self.zwaveCredentialKey = credentialKey\n self.zwaveAuthFailures = 0\n self.zwaveFetchPaused = false\n end\n\n if username == \"\" or password == \"\" then\n self.zwaveStatusKey = \"status_username_password_not_set\"\n self.zwaveStatusText = nil\n self:updateZwaveDiagnosticLabel()\n return\n end\n\n if self.zwaveFetchPaused then\n self.zwaveStatusKey = \"status_auth_failed_twice\"\n self.zwaveStatusText = nil\n self:updateZwaveDiagnosticLabel()\n return\n end\n\n local authHeader = \"Basic \" .. base64Encode(username .. \":\" .. password)\n self.zwaveHttp = self.zwaveHttp or net.HTTPClient()\n self.zwaveHttp:request(\"https://127.0.0.1/api/zwave/nodes/diagnostics/transmissions\", {\n options = {\n method = \"GET\",\n headers = {\n [\"accept\"] = \"application/json\",\n [\"X-Fibaro-Version\"] = \"2\",\n [\"Accept-language\"] = \"en\",\n [\"Authorization\"] = authHeader,\n },\n checkCertificate = false,\n },\n success = function(response)\n if response.status ~= 200 then\n local statusText = formatHttpStatus(response.status)\n local statusCode = tonumber(response.status)\n self.zwaveStatusKey = nil\n if statusCode == 401 or statusCode == 403 then\n self.zwaveAuthFailures = (self.zwaveAuthFailures or 0) + 1\n if self.zwaveAuthFailures >= ZWAVE_AUTH_MAX_ATTEMPTS then\n self.zwaveFetchPaused = true\n self.zwaveStatusText = statusText .. \" - stopped after 2 tries\"\n else\n self.zwaveStatusText = string.format(\n \"%s (%d/%d)\",\n statusText,\n self.zwaveAuthFailures,\n ZWAVE_AUTH_MAX_ATTEMPTS\n )\n end\n else\n self.zwaveStatusText = statusText\n end\n self:updateZwaveDiagnosticLabel()\n self:warning(\"Failed to read /api/zwave/nodes/diagnostics/transmissions: \" .. self.zwaveStatusText)\n return\n end\n\n local ok, decoded = pcall(json.decode, response.data or \"\")\n if not ok or type(decoded) ~= \"table\" then\n self.zwaveStatusKey = nil\n self.zwaveStatusText = \"Invalid JSON response\"\n self:updateZwaveDiagnosticLabel()\n self:warning(\"Failed to decode /api/zwave/nodes/diagnostics/transmissions response\")\n return\n end\n\n self.zwaveAuthFailures = 0\n self.zwaveFetchPaused = false\n self.zwaveStatusKey = nil\n self.zwaveStatusText = nil\n self.zwaveTransmissions = decoded\n if self.currentData then\n self.currentData.zwaveTransmissions = decoded\n end\n self:updateZwaveDiagnosticLabel()\n end,\n error = function(err)\n self.zwaveStatusKey = nil\n self.zwaveStatusText = \"HTTP request failed\"\n self:updateZwaveDiagnosticLabel()\n self:warning(\"Failed to read /api/zwave/nodes/diagnostics/transmissions: \" .. tostring(err))\n end,\n })\nend\n\n-- Schedules the next Z-Wave transmissions refresh.\nfunction QuickApp:scheduleZwaveRefresh(delayMs)\n setTimeout(function()\n self:fetchZwaveTransmissions()\n self:scheduleZwaveRefresh(ZWAVE_POLL_MS)\n end, delayMs or ZWAVE_POLL_MS)\nend\n\n-- ===========================================================\n-- ---------------QA Dist Manager Updates--------------------\n-- ===========================================================\n\n-- Finds an enabled QA Dist Manager by its permanent quickAppUuid.\n-------------------------------------------------------------------------------------------\nlocal function findQaDistManager()\n local ok, devices = pcall(function() return api.get(\"/devices?interface=quickApp\") end)\n if not ok or type(devices) ~= \"table\" then return nil, nil, tostring(devices) end\n\n local matchedId, matchedName\n for _, device in ipairs(devices) do\n local properties = type(device.properties) == \"table\" and device.properties or {}\n if tostring(properties.quickAppUuid or \"\") == CONFIG.qaDistManagerUuid\n and device.enabled ~= false\n and (not matchedId or tonumber(device.id) < tonumber(matchedId)) then\n matchedId, matchedName = device.id, device.name\n end\n end\n return matchedId, matchedName\nend\n\n-- Formats the current translated update status specification.\n-------------------------------------------------------------------------------------------\nlocal function getUpdateStatusText(self)\n local status = self.runtime and self.runtime.updateStatus or {}\n local template = tt(self, status.key or \"update_ready\", status.fallback or \"Ready\")\n local args = type(status.args) == \"table\" and status.args or {}\n if #args == 0 then return template end\n local ok, formatted = pcall(string.format, template, table.unpack(args))\n return ok and formatted or template\nend\n\n-- Validates an HH:MM clock string and returns a normalized value.\n-------------------------------------------------------------------------------------------\nlocal function normalizeAutoUpdateTime(value)\n local hour, minute = tostring(value or \"\"):match(\"^(%d%d?):(%d%d)$\")\n hour, minute = tonumber(hour), tonumber(minute)\n if not hour or not minute or hour > 23 or minute > 59 then\n return CONFIG.defaultAutoUpdateTime, false\n end\n return string.format(\"%02d:%02d\", hour, minute), true\nend\n\n-- Calculates the next local occurrence of the configured automatic update time.\n-------------------------------------------------------------------------------------------\nlocal function nextAutoUpdateOccurrence(clockText)\n local normalized = normalizeAutoUpdateTime(clockText)\n local hour, minute = normalized:match(\"^(%d%d):(%d%d)$\")\n local now = os.time()\n local target = os.date(\"*t\", now)\n target.hour, target.min, target.sec = tonumber(hour), tonumber(minute), 0\n local targetEpoch = os.time(target)\n if targetEpoch <= now then\n target.day = target.day + 1\n targetEpoch = os.time(target)\n end\n return targetEpoch, math.max(0, (targetEpoch - now) * 1000)\nend\n\n-- Returns true only when a semantic candidate version is newer than the running version.\n-------------------------------------------------------------------------------------------\nlocal function isNewerVersionTag(candidate, current)\n local function parts(value)\n local major, minor, patch = tostring(value or \"\"):match(\"[vV]?(%d+)%.(%d+)%.(%d+)\")\n if not major then return nil end\n return { tonumber(major), tonumber(minor), tonumber(patch) }\n end\n\n local candidateParts, currentParts = parts(candidate), parts(current)\n if not candidateParts or not currentParts then\n return tostring(candidate):gsub(\"^[vV]\", \"\") ~= tostring(current):gsub(\"^[vV]\", \"\")\n end\n for index = 1, 3 do\n if candidateParts[index] ~= currentParts[index] then\n return candidateParts[index] > currentParts[index]\n end\n end\n return false\nend\n\n-- Refreshes the update label and all three update switches.\n-------------------------------------------------------------------------------------------\nlocal function refreshUpdatePanel(self)\n self.runtime = self.runtime or {}\n local managerText = tt(self, \"not_found\", \"Not found\")\n if self.runtime.qaDistManagerId then\n managerText = string.format(\"%s (ID %s)\",\n tostring(self.runtime.qaDistManagerName or \"QA Dist Manager\"),\n tostring(self.runtime.qaDistManagerId))\n end\n local autoText = self.runtime.autoUpdateEnabled\n and tt(self, \"enabled\", \"Enabled\") or tt(self, \"disabled\", \"Disabled\")\n local nextUpdateText = \"-\"\n if self.runtime.autoUpdateEnabled and self.runtime.nextAutoUpdateAt then\n nextUpdateText = os.date(\"%d.%m.%Y %H:%M\", self.runtime.nextAutoUpdateAt)\n end\n local updateCheckText = \"-\"\n if self.runtime.autoUpdateEnabled and self.runtime.autoUpdateCheckAt then\n updateCheckText = os.date(\"%d.%m.%Y %H:%M\", self.runtime.autoUpdateCheckAt)\n end\n local panel = { title(tt(self, \"updates\", \"Updates\")) }\n panel[#panel + 1] = row(\"\", \"QA Dist Manager\", managerText)\n panel[#panel + 1] = row(\"\", tt(self, \"current_version\", \"Current Version\"), version)\n panel[#panel + 1] = row(\n \"\", tt(self, \"available_version\", \"Available Version\"),\n tostring(self.runtime.latestAvailableTag or tt(self, \"not_checked\", \"Not checked\"))\n )\n panel[#panel + 1] = row(\"\", tt(self, \"auto_update\", \"Auto Update\"), autoText)\n panel[#panel + 1] = row(\n \"\", tt(self, \"auto_update_time\", \"Auto Update Time\"),\n tostring(self.runtime.autoUpdateTime or CONFIG.defaultAutoUpdateTime)\n )\n panel[#panel + 1] = row(\n \"\", tt(self, \"next_auto_update\", \"Next Auto Update\"), nextUpdateText\n )\n panel[#panel + 1] = row(\n \"\", tt(self, \"next_update_check\", \"Update Check\"), updateCheckText\n )\n panel[#panel + 1] = centeredMessage(\n self.runtime.updateStatusColor or C_LINK,\n getUpdateStatusText(self)\n )\n if not self.runtime.qaDistManagerId then\n panel[#panel + 1] = centeredMessage(\n C_RED,\n tt(self, \"update_manager_warning\", \"No QA Dist Manager Detected on the HC3, update and Auto Update Wont be possible without it.\")\n )\n panel[#panel + 1] = linkRow(\n \"!\", tt(self, \"download_qa_dist_manager\", \"Download QA Dist Manager\"),\n CONFIG.qaDistManagerForumUrl,\n tt(self, \"update_manager_download\", \"Go to URL above to download the QA and upload it to the HC3.\")\n )\n end\n self:updateView(\"info_Update\", \"text\", renderHtml(panel))\n self:updateView(\"Check_Update\", \"text\", tt(self, \"check_update\", \"Check Update\"))\n self:updateView(\"Check_Update\", \"value\", \"false\")\n self:updateView(\"Auto_Update\", \"text\", tt(self, \"auto_update\", \"Auto Update\"))\n self:updateView(\"Auto_Update\", \"value\", self.runtime.autoUpdateEnabled and \"true\" or \"false\")\n self:updateView(\"Update_Now\", \"text\", tt(self, \"update_now\", \"Update Now\"))\n self:updateView(\"Update_Now\", \"value\", \"false\")\nend\n\n-- Stores a translated status descriptor and redraws the update panel.\n-------------------------------------------------------------------------------------------\nlocal function setUpdateStatus(self, key, fallback, color, ...)\n self.runtime = self.runtime or {}\n self.runtime.updateStatus = { key = key, fallback = fallback, args = { ... } }\n self.runtime.updateStatusColor = color or C_LINK\n refreshUpdatePanel(self)\nend\n\n-- Clears the local request guard and its timeout.\n-------------------------------------------------------------------------------------------\nlocal function clearUpdateRequest(self)\n if self.runtime and self.runtime.updateRequestTimer then\n clearTimeout(self.runtime.updateRequestTimer)\n self.runtime.updateRequestTimer = nil\n end\n if self.runtime then self.runtime.updateRequestPending = false end\nend\n\nlocal scheduleAutoTargetAction\n\n-- Starts a guarded asynchronous request to the dynamically discovered manager.\n-------------------------------------------------------------------------------------------\nlocal function beginUpdateRequest(self, statusKey, fallback)\n self.runtime = self.runtime or {}\n if self.runtime.updateRequestPending then\n setUpdateStatus(self, \"update_busy_local\", \"An update request is already pending\", C_LABEL)\n return nil\n end\n\n local managerId, managerName, findError = findQaDistManager()\n self.runtime.qaDistManagerId, self.runtime.qaDistManagerName = managerId, managerName\n if not managerId then\n setUpdateStatus(self, \"update_manager_missing\", \"QA Dist Manager not found\", C_RED)\n self:warning(\"QA Dist Manager not found: \" .. tostring(findError or CONFIG.qaDistManagerUuid))\n return nil\n end\n\n self.runtime.updateRequestPending = true\n setUpdateStatus(self, statusKey, fallback, C_LINK)\n self.runtime.updateRequestTimer = setTimeout(function()\n local autoUpdateTarget = self.runtime and self.runtime.autoUpdateCheckTarget\n if self.runtime then self.runtime.autoUpdateCheckTarget = nil end\n clearUpdateRequest(self)\n setUpdateStatus(self, \"update_timeout\", \"Update request timed out\", C_RED)\n if autoUpdateTarget and self.runtime.autoUpdateEnabled then\n scheduleAutoTargetAction(self, autoUpdateTarget, false)\n end\n end, CONFIG.updateRequestTimeoutMs)\n return managerId\nend\n\n-- Fetches available releases without installing or changing this QuickApp.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:checkQaDistReleases(refresh, autoUpdateTarget)\n local statusKey = autoUpdateTarget and \"auto_update_precheck\" or \"update_checking\"\n local fallback = autoUpdateTarget\n and \"Checking for an update before the automatic update time...\"\n or \"Checking for updates...\"\n local managerId = beginUpdateRequest(self, statusKey, fallback)\n if not managerId then return false end\n self.runtime.autoUpdateCheckTarget = tonumber(autoUpdateTarget)\n\n local ok, err = pcall(function()\n fibaro.call(managerId, \"apiListReleases\", {\n uid = quickAppUuid,\n refresh = refresh == true,\n callbackId = self.id,\n callbackMethod = \"onReleaseListResult\",\n })\n end)\n if ok then return true end\n self.runtime.autoUpdateCheckTarget = nil\n clearUpdateRequest(self)\n setUpdateStatus(self, \"update_request_failed\", \"Update request failed: %s\", C_RED, tostring(err))\n self:error(\"QA Dist Manager release check failed: \" .. tostring(err))\n return false\nend\n\n-- Requests installation of the latest registered distribution.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:requestQaDistUpdate(force)\n local statusKey = force and \"update_forcing\" or \"update_checking\"\n local fallback = force and \"Requesting latest update...\" or \"Checking for updates...\"\n local managerId = beginUpdateRequest(self, statusKey, fallback)\n if not managerId then return false end\n\n local ok, err = pcall(function()\n fibaro.call(managerId, \"apiUpdate\", {\n deviceId = self.id,\n uid = quickAppUuid,\n tag = \"latest\",\n force = force == true,\n callbackId = self.id,\n callbackMethod = \"onUpdateResult\",\n })\n end)\n if ok then return true end\n clearUpdateRequest(self)\n setUpdateStatus(self, \"update_request_failed\", \"Update request failed: %s\", C_RED, tostring(err))\n self:error(\"QA Dist Manager request failed: \" .. tostring(err))\n return false\nend\n\n-- Waits until the configured target, optionally updates, then starts the next daily cycle.\n-------------------------------------------------------------------------------------------\nscheduleAutoTargetAction = function(self, targetEpoch, shouldUpdate)\n if self.runtime.autoUpdateTimer then clearTimeout(self.runtime.autoUpdateTimer) end\n self.runtime.autoUpdateTimer = nil\n self.runtime.autoUpdateCheckAt = nil\n self.runtime.nextAutoUpdateAt = targetEpoch\n refreshUpdatePanel(self)\n\n local runTargetAction\n runTargetAction = function()\n self.runtime.autoUpdateTimer = nil\n if not self.runtime.autoUpdateEnabled then\n self.runtime.nextAutoUpdateAt = nil\n refreshUpdatePanel(self)\n return\n end\n if shouldUpdate and self.runtime.updateRequestPending then\n self.runtime.nextAutoUpdateAt = os.time() + 60\n refreshUpdatePanel(self)\n self.runtime.autoUpdateTimer = setTimeout(runTargetAction, 60 * 1000)\n return\n end\n\n self.runtime.nextAutoUpdateAt = nil\n if shouldUpdate then self:requestQaDistUpdate(false) end\n self:scheduleAutoUpdate()\n end\n\n local delayMs = math.max(0, (targetEpoch - os.time()) * 1000)\n self.runtime.autoUpdateTimer = setTimeout(runTargetAction, delayMs)\nend\n\n-- Schedules a release check five minutes before the next configured update time.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:scheduleAutoUpdate()\n self.runtime = self.runtime or {}\n if self.runtime.autoUpdateTimer then clearTimeout(self.runtime.autoUpdateTimer) end\n self.runtime.autoUpdateTimer = nil\n self.runtime.nextAutoUpdateAt = nil\n self.runtime.autoUpdateCheckAt = nil\n self.runtime.autoUpdateCheckTarget = nil\n if not self.runtime.autoUpdateEnabled then\n refreshUpdatePanel(self)\n return\n end\n\n local targetEpoch = nextAutoUpdateOccurrence(self.runtime.autoUpdateTime)\n local checkEpoch = targetEpoch - CONFIG.autoUpdatePrecheckSeconds\n if checkEpoch < os.time() then checkEpoch = os.time() end\n self.runtime.nextAutoUpdateAt = targetEpoch\n self.runtime.autoUpdateCheckAt = checkEpoch\n refreshUpdatePanel(self)\n\n local runPrecheck\n runPrecheck = function()\n self.runtime.autoUpdateTimer = nil\n if not self.runtime.autoUpdateEnabled then return end\n if self.runtime.updateRequestPending and os.time() < targetEpoch then\n self.runtime.autoUpdateCheckAt = math.min(targetEpoch, os.time() + 60)\n refreshUpdatePanel(self)\n self.runtime.autoUpdateTimer = setTimeout(runPrecheck, 60 * 1000)\n return\n end\n\n self.runtime.autoUpdateCheckAt = nil\n if os.time() >= targetEpoch or not self:checkQaDistReleases(true, targetEpoch) then\n scheduleAutoTargetAction(self, targetEpoch, false)\n end\n end\n\n self.runtime.autoUpdateTimer = setTimeout(\n runPrecheck,\n math.max(0, (checkEpoch - os.time()) * 1000)\n )\nend\n\n-- Handles the manual non-forced update check switch.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:Check_Update(event)\n self:updateView(\"Check_Update\", \"value\", \"false\")\n if parseBoolFromEvent(event) == false then return end\n self:checkQaDistReleases(true)\nend\n\n-- Persists and schedules the automatic daily update switch.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:Auto_Update(event)\n local enabled = parseBoolFromEvent(event)\n if enabled == nil then enabled = not (self.runtime and self.runtime.autoUpdateEnabled) end\n self.runtime.autoUpdateEnabled = enabled == true\n self:internalStorageSet(STORAGE_KEYS.autoUpdateEnabled, self.runtime.autoUpdateEnabled)\n if self.runtime.autoUpdateEnabled then\n setUpdateStatus(\n self, \"auto_update_enabled_at\", \"Auto Update enabled for %s\", C_GREEN,\n self.runtime.autoUpdateTime\n )\n self:scheduleAutoUpdate()\n else\n self:scheduleAutoUpdate()\n setUpdateStatus(self, \"auto_update_disabled\", \"Auto Update disabled\", C_LINK)\n end\nend\n\n-- Handles a forced installation of the latest registered distribution.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:Update_Now(event)\n self:updateView(\"Update_Now\", \"value\", \"false\")\n if parseBoolFromEvent(event) == false then return end\n self:requestQaDistUpdate(true)\nend\n\n-- Receives the asynchronous result from QA Dist Manager.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:onUpdateResult(result)\n clearUpdateRequest(self)\n result = type(result) == \"table\" and result or {}\n local tag = tostring(result.tag or \"latest\")\n if tag ~= \"latest\" then self.runtime.latestAvailableTag = tag end\n if result.upToDate then\n setUpdateStatus(self, \"update_up_to_date\", \"Up to date: %s\", C_GREEN, tag)\n self:debug(\"Already at \" .. tag .. \" - no update needed\")\n elseif result.ok then\n setUpdateStatus(self, \"update_installed\", \"Updated to %s. QA will restart.\", C_GREEN, tag)\n self:debug(\"Updated to \" .. tag .. \". HC3 will restart the QA\")\n else\n local message = tostring(result.msg or \"unknown error\")\n setUpdateStatus(self, \"update_failed\", \"Update failed: %s\", C_RED, message)\n self:error(\"Update failed: \" .. message)\n end\nend\n\n-- Receives the non-installing release-list result from QA Dist Manager.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:onReleaseListResult(result)\n local autoUpdateTarget = self.runtime and self.runtime.autoUpdateCheckTarget\n if self.runtime then self.runtime.autoUpdateCheckTarget = nil end\n clearUpdateRequest(self)\n result = type(result) == \"table\" and result or {}\n if not result.ok then\n local message = tostring(result.msg or \"unknown error\")\n setUpdateStatus(self, \"update_check_failed\", \"Update check failed: %s\", C_RED, message)\n self:error(\"Update check failed: \" .. message)\n if autoUpdateTarget and self.runtime.autoUpdateEnabled then\n scheduleAutoTargetAction(self, autoUpdateTarget, false)\n end\n return\n end\n\n local releases = type(result.releases) == \"table\" and result.releases or {}\n local latest = type(releases[1]) == \"table\" and releases[1].tag or nil\n if not latest or tostring(latest) == \"\" then\n self.runtime.latestAvailableTag = tt(self, \"none\", \"None\")\n setUpdateStatus(self, \"no_update_releases\", \"No releases available\", C_LABEL)\n if autoUpdateTarget and self.runtime.autoUpdateEnabled then\n scheduleAutoTargetAction(self, autoUpdateTarget, false)\n end\n return\n end\n\n self.runtime.latestAvailableTag = tostring(latest)\n local currentComparable = tostring(version):gsub(\"^[vV]\", \"\")\n local latestComparable = tostring(latest):gsub(\"^[vV]\", \"\")\n if currentComparable == latestComparable then\n setUpdateStatus(self, \"update_up_to_date\", \"Up to date: %s\", C_GREEN, tostring(latest))\n if autoUpdateTarget and self.runtime.autoUpdateEnabled then\n scheduleAutoTargetAction(self, autoUpdateTarget, false)\n end\n elseif isNewerVersionTag(latest, version) then\n setUpdateStatus(self, \"update_available\", \"Update available: %s\", C_LABEL, tostring(latest))\n if autoUpdateTarget and self.runtime.autoUpdateEnabled then\n scheduleAutoTargetAction(self, autoUpdateTarget, true)\n end\n else\n setUpdateStatus(\n self, \"no_newer_update\", \"No newer update (latest release: %s)\", C_GREEN,\n tostring(latest)\n )\n if autoUpdateTarget and self.runtime.autoUpdateEnabled then\n scheduleAutoTargetAction(self, autoUpdateTarget, false)\n end\n end\nend\n\n-- ===========================================================\n-- ---------------Dead Device Eventlib Listeners--------------\n-- ===========================================================\n\nlocal Event = Event_std\n\nif Event then\n Event.id = \"setting_overview_start\"\n Event{type = \"QAstart\"}\n -- Starts Eventlib's HC3 refresh-state subscriber.\n -------------------------------------------------------------------------------------------\n function Event:handler(_)\n local ok, err = pcall(function() Event:attachRefreshstate() end)\n if not ok and quickApp then quickApp:warning(\"Eventlib startup failed: \" .. tostring(err)) end\n end\n\n Event.id = \"dead_device_change\"\n Event{type = \"device\", property = \"dead\"}\n -- Forwards dead-property changes to the QuickApp history handler.\n -------------------------------------------------------------------------------------------\n function Event:handler(event)\n if quickApp then quickApp:onDeadDeviceEvent(event) end\n end\nend\n\n-- Uses HC3's native subscriber when Eventlib is not included in the installed QA.\n-------------------------------------------------------------------------------------------\nlocal function startDeadDeviceListenerFallback(self)\n if Event then return end\n if RefreshStateSubscriber == nil then\n self:warning(\"Dead-device listener unavailable: Eventlib and RefreshStateSubscriber are missing\")\n return\n end\n\n local ok, subscriberOrError = pcall(function()\n local subscriber = RefreshStateSubscriber()\n subscriber:subscribe(\n function(event)\n return event.type == \"DevicePropertyUpdatedEvent\"\n and type(event.data) == \"table\"\n and event.data.property == \"dead\"\n end,\n function(event)\n self:onDeadDeviceEvent({\n id = event.data.id,\n property = event.data.property,\n value = event.data.newValue,\n old = event.data.oldValue,\n })\n end\n )\n subscriber:run()\n return subscriber\n end)\n if not ok then\n self:warning(\"Dead-device fallback listener failed: \" .. tostring(subscriberOrError))\n return\n end\n self.runtime.deadDeviceSubscriber = subscriberOrError\n self:debug(\"Dead-device listener started with RefreshStateSubscriber fallback\")\nend\n\n-- Keep the QA quickAppUuid property in sync with the configured local value.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:syncQuickAppUuid()\n local configuredUuid = tostring(quickAppUuid or \"\")\n if configuredUuid == \"\" then return end\n\n local ok, err = pcall(function()\n self:updateProperty(\"quickAppUuid\", configuredUuid)\n end)\n\n if not ok then\n self:warning(\"syncQuickAppUuid failed to updateProperty quickAppUuid: \" .. tostring(err))\n return\n end\n\n self:debug(\"quickAppUuid set to \" .. configuredUuid)\nend\n\nfunction QuickApp:onInit()\n quickApp = self\n self.runtime = self.runtime or {}\n self.runtime.deadDeviceHistory = normalizeDeadDeviceHistory(\n readStoredTable(self, STORAGE_KEYS.deadDeviceHistory)\n )\n if pruneDeadDeviceHistory(self) then saveDeadDeviceHistory(self) end\n startDeadDeviceListenerFallback(self)\n self.savedLanguageCode = normalizeLanguageCode(self:internalStorageGet(STORAGE_KEYS.languageCode)) or false\n self.runtime.autoUpdateEnabled = normalizeDeadValue(\n self:internalStorageGet(STORAGE_KEYS.autoUpdateEnabled)\n )\n local configuredAutoTime = self:getVariable(\"AutoUpdateTime\")\n local autoTimeValid\n self.runtime.autoUpdateTime, autoTimeValid = normalizeAutoUpdateTime(configuredAutoTime)\n if not autoTimeValid then\n self:warning(string.format(\n \"Invalid AutoUpdateTime '%s'; using %s\",\n tostring(configuredAutoTime), self.runtime.autoUpdateTime\n ))\n end\n self.runtime.qaDistManagerId, self.runtime.qaDistManagerName = findQaDistManager()\n if self.runtime.qaDistManagerId then\n self.runtime.updateStatus = { key = \"update_ready\", fallback = \"Ready\", args = {} }\n self.runtime.updateStatusColor = C_GREEN\n else\n self.runtime.updateStatus = {\n key = \"update_manager_missing\", fallback = \"QA Dist Manager not found\", args = {}\n }\n self.runtime.updateStatusColor = C_RED\n end\n self.prevCpu = readStoredTable(self, STORAGE_KEYS.prevCpu)\n if type(self.prevCpu) ~= \"table\" or #self.prevCpu == 0 then self.prevCpu = nil end\n self.avgSnapshot = readStoredTable(self, STORAGE_KEYS.avgSnapshot) or {}\n self.cpuHistory = restoreRingBuffer(readStoredTable(self, STORAGE_KEYS.cpuHistory))\n\n local installerTimeline = loadInstallerTimeline(self)\n self.installerGrantedAt = installerTimeline.grantedAt\n self.installerExpiresAt = installerTimeline.expiresAt\n self.installerWithdrawnAt = installerTimeline.withdrawnAt\n\n self.credentialError = false -- flag for credential issues\n self.updateInProgress = false\n\n self.prevQaCpuClock = os.clock()\n self.prevQaWallTime = os.time()\n self:updateProperty(\"unit\", \"%\")\n self:syncQuickAppUuid()\n self:debug(\"HC3 CPU Watch started — property: Avg cpu %\")\n self:updateView(\"update\", \"text\", \"Update\")\n self:updateView(\"update\", \"value\", \"false\")\n self:updateView(\"info_zwave\", \"text\", renderHtml(buildZwavePendingPanel(self)))\n refreshDeadDevicePanel(self)\n self:updateView(\"info_manual\", \"text\", renderHtml(self:buildManualPanel()))\n self:updateView(\"installer_access24h\", \"value\", \"false\")\n local staticDataLoaded = self:loadStaticData()\n ensureZwaveNodeNames(self)\n refreshLanguageControls(self)\n refreshUpdatePanel(self)\n if self.staticData and self.staticData.ledBrightness ~= nil then\n refreshLedBrightnessSlider(self, self.staticData.ledBrightness)\n end\n if not staticDataLoaded then\n self:fetchStaticData(false, true)\n end\n self:pollDiagnostics()\n self:scheduleZwaveRefresh(ZWAVE_STARTUP_DELAY_MS)\n scanDeadDevices(self)\n self:scheduleMidnightRefresh()\n if self.runtime.autoUpdateEnabled then\n self:scheduleAutoUpdate()\n end\n self.runtime.startupUpdateCheckTimer = setTimeout(function()\n self.runtime.startupUpdateCheckTimer = nil\n self:checkQaDistReleases(true)\n end, CONFIG.startupUpdateCheckDelayMs)\nend\n\nfunction QuickApp:scheduleMidnightRefresh()\n local now = os.time()\n local t = os.date(\"*t\", now)\n t.hour, t.min, t.sec = 0, 0, 30\n local midnight30 = os.time(t)\n if midnight30 <= now then\n midnight30 = midnight30 + 24 * 60 * 60\n end\n local delayMs = (midnight30 - now) * 1000\n setTimeout(function()\n self:debug(\"Midnight refresh — updating settings info\")\n local ok, info = pcall(function() return api.get(\"/settings/info\") end)\n if ok and type(info) == \"table\" then\n self:debug(string.format(\"Sunrise: %s, Sunset: %s\", tostring(info.sunriseHour), tostring(info.sunsetHour)))\n end\n self:fetchStaticData(true)\n self:scheduleMidnightRefresh()\n end, delayMs)\n self:debug(string.format(\"Next midnight refresh in %.0f min\", delayMs / 60000))\nend\n\nfunction QuickApp:buildInfoPanel(data)\n local infoPanel = {}\n infoPanel[#infoPanel + 1] = title(tt(self, \"settings\", \"Settings\"))\n\n local info = data.settingsInfo\n if not info then\n infoPanel[#infoPanel + 1] = row(\"🏠\", \"HC3 Info\", \"n/a\")\n else\n infoPanel[#infoPanel + 1] = row(\"🏷️\", tt(self, \"hub_name\", \"Hub Name\"), tostring(info.hcName or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🏠\", \"Platform\", tostring(info.platform or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🏠\", \"Serial Nr\", tostring(info.serialNumber or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🏠\", \"MAC\", tostring(info.mac or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"📡\", \"Zigbee\", tostring(info.zigbeeStatus or false))\n infoPanel[#infoPanel + 1] = row(\"🌍\", \"Online\", tostring(info.online or false))\n local tzHours = (tonumber(info.timezoneOffset) or 0) / 3600\n infoPanel[#infoPanel + 1] = row(\"🕐\", tt(self, \"timezone\", \"Timezone\"), string.format(\"UTC%+.0f\", tzHours))\n infoPanel[#infoPanel + 1] = row(\"🏠\", \"Z-Wave Engine\", tostring(info.zwaveEngineVersion or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🏠\", \"Z-Wave Version\", tostring(info.zwaveVersion or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🏠\", \"Z-Wave Region\", tostring(info.zwaveRegion or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🌐\", tt(self, \"language\", \"Language\"), tostring(info.defaultLanguage or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🌅\", tt(self, \"sunrise\", \"Sunrise\"), tostring(info.sunriseHour or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🌇\", tt(self, \"sunset\", \"Sunset\"), tostring(info.sunsetHour or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🌡️\", tt(self, \"temp_unit\", \"Temp Unit\"), tostring(info.temperatureUnit or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🕐\", tt(self, \"time_format\", \"Time Format\"), tostring(info.timeFormat or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"💰\", tt(self, \"currency\", \"Currency\"), tostring(info.currency or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"📦\", tt(self, \"software\", \"Software\"), tostring(info.softVersion or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🧪\", tt(self, \"running_beta\", \"Running Beta\"), tostring(info.beta or false))\n infoPanel[#infoPanel + 1] = row(\"🎨\", tt(self, \"skin\", \"Skin\"), tostring(info.skin or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🎨\", tt(self, \"skin_setting\", \"Skin Setting\"), tostring(info.skinSetting or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"🔄\", tt(self, \"stable_update\", \"Stable Update\"), tostring(info.updateStableAvailable or false))\n infoPanel[#infoPanel + 1] = row(\"🔄\", tt(self, \"new_beta_available\", \"New Beta Available\"), tostring(info.updateBetaAvailable or false))\n infoPanel[#infoPanel + 1] = row(\"📦\", tt(self, \"newest_stable\", \"Newest Stable\"), tostring(info.newestStableVersion or \"n/a\"))\n infoPanel[#infoPanel + 1] = row(\"📦\", tt(self, \"newest_beta\", \"Newest Beta\"), tostring(info.newestBetaVersion or \"n/a\"))\n end\n\n infoPanel[#infoPanel + 1] = SPACER -- spacer before LED\n\n -- ── LED Brightness ───────────────────────────────────────────────────\n local ledBrightness = data.ledBrightness\n if ledBrightness ~= nil then\n infoPanel[#infoPanel + 1] = row(\"💡\", tt(self, \"hub_led_brightness\", \"Hub LED Brightness\"), string.format(\"%d%%\", toNum(ledBrightness)))\n else\n infoPanel[#infoPanel + 1] = row(\"💡\", tt(self, \"hub_led_brightness\", \"Hub LED Brightness\"), \"n/a\")\n end\n\n return infoPanel\nend\n\nfunction QuickApp:buildManualPanel()\n local manualPanel = {}\n manualPanel[#manualPanel + 1] = title(tt(self, \"manual\", \"Manual\"))\n manualPanel[#manualPanel + 1] = row(\"📦\", tt(self, \"qa_version\", \"QA version\"), version)\n return manualPanel\nend\n\nfunction QuickApp:buildLines(data)\n local cpuPanel = {}\n local infoPanel = self:buildInfoPanel(data)\n local manualPanel = self:buildManualPanel()\n local networkPanel = {}\n local cpuLines = {}\n\n cpuPanel[#cpuPanel + 1] = versionLine(version)\n cpuPanel[#cpuPanel + 1] = title(tt(self, \"cpu_info\", \"CPU Info\"))\n\n -- ── CPU ──────────────────────────────────────────────────────────────\n local cpuNow = data.cpuLoad\n local avgPct = nil\n\n if not cpuNow or #cpuNow == 0 then\n cpuLines[#cpuLines + 1] = row(\"🖥️\", \"CPU\", \"n/a\")\n elseif not self.prevCpu then\n self.prevCpu = cpuNow\n cpuLines[#cpuLines + 1] = row(\"🖥️\", \"CPU\", \"waiting for 2nd sample...\")\n else\n local totalPct = 0\n local count = math.min(#cpuNow, #self.prevCpu)\n\n for i = 1, count do\n local pct = round1(calcCpuPercent(self.prevCpu[i], cpuNow[i]))\n totalPct = totalPct + pct\n local name = cpuNow[i].name or (\"cpu\" .. (i - 1))\n cpuLines[#cpuLines + 1] = row(\"🖥️\", name, string.format(\"%.1f%%\", pct))\n end\n\n self.prevCpu = cpuNow\n avgPct = round1(totalPct / count)\n rbPush(self.cpuHistory, avgPct)\n\n self.avgSnapshot = buildAvgSnapshot(self, avgPct)\n end\n\n for _, avgLine in ipairs(buildAvgRows(self, self.avgSnapshot)) do\n cpuPanel[#cpuPanel + 1] = avgLine\n end\n if #cpuPanel > 0 then cpuPanel[#cpuPanel + 1] = SPACER end\n for _, cpuLine in ipairs(cpuLines) do\n cpuPanel[#cpuPanel + 1] = cpuLine\n end\n\n cpuPanel[#cpuPanel + 1] = SPACER -- spacer before memory\n\n -- ── Memory ───────────────────────────────────────────────────────────\n local mem = data.memory\n if not mem then\n cpuPanel[#cpuPanel + 1] = row(\"💾\", \"Memory\", \"n/a\")\n else\n local memNotUsed = math.max(0, round1(100 - toNum(mem.used) - toNum(mem.cache) - toNum(mem.buffers)))\n cpuPanel[#cpuPanel + 1] = row(\"💾\", tt(self, \"mem_used\", \"MEM used\"), string.format(\"%.1f%%\", toNum(mem.used)))\n cpuPanel[#cpuPanel + 1] = row(\"💾\", tt(self, \"mem_free\", \"MEM free\"), string.format(\"%.1f%%\", toNum(mem.free)))\n cpuPanel[#cpuPanel + 1] = row(\"💾\", tt(self, \"mem_cache\", \"MEM cache\"), string.format(\"%.1f%%\", toNum(mem.cache)))\n cpuPanel[#cpuPanel + 1] = row(\"💾\", tt(self, \"mem_buffers\", \"MEM buffers\"), string.format(\"%.1f%%\", toNum(mem.buffers)))\n cpuPanel[#cpuPanel + 1] = row(\"💾\", tt(self, \"mem_not_used\", \"MEM not used\"), string.format(\"%.1f%%\", memNotUsed))\n end\n\n cpuPanel[#cpuPanel + 1] = SPACER -- spacer before storage\n\n -- ── Storage ──────────────────────────────────────────────────────────\n local storage = data.storage\n if not storage or not storage.internal or #storage.internal == 0 then\n cpuPanel[#cpuPanel + 1] = row(\"💿\", \"Storage\", \"n/a\")\n else\n for _, disk in ipairs(storage.internal) do\n cpuPanel[#cpuPanel + 1] = row(\"💿\", disk.name or \"disk\", string.format(\"%.1f%%\", toNum(disk.used)))\n end\n end\n\n cpuPanel[#cpuPanel + 1] = SPACER -- spacer before QA self-stats\n\n -- ── This QuickApp ────────────────────────────────────────────────────\n local hubDate = formatHubClock()\n cpuPanel[#cpuPanel + 1] = row(\"🕐\", \"Hub Time\", tostring(hubDate))\n local qaCpuPct = sampleThisQaCpu(self)\n local luaKB = collectgarbage(\"count\")\n cpuPanel[#cpuPanel + 1] = row(\"⚙️\", tt(self, \"this_qa_loop_interval\", \"This QA — Loop interval\"), string.format(\"%.0f s\", POLL_S))\n cpuPanel[#cpuPanel + 1] = row(\"⚙️\", tt(self, \"this_qa_cpu\", \"This QA — CPU\"), qaCpuPct and string.format(\"%.3f%%\", qaCpuPct) or \"waiting...\")\n cpuPanel[#cpuPanel + 1] = row(\"⚙️\", tt(self, \"this_qa_lua_mem\", \"This QA — Lua mem\"), string.format(\"%.1f kB\", luaKB))\n\n -- ── Network ──────────────────────────────────────────────────────────\n networkPanel[#networkPanel + 1] = title(tt(self, \"network\", \"Network\"))\n\n local net = data.networkConfig\n if not net then\n networkPanel[#networkPanel + 1] = row(\"🌐\", \"Network\", \"n/a\")\n else\n local eth0 = net.eth0\n if eth0 and eth0.ipConfig then\n networkPanel[#networkPanel + 1] = row(\"🔌\", \"Ethernet IP\", tostring(eth0.ipConfig.ip or \"n/a\"))\n networkPanel[#networkPanel + 1] = row(\"🔌\", \"Ethernet MAC\", tostring(eth0.ipConfig.macAddress or \"n/a\"))\n end\n\n networkPanel[#networkPanel + 1] = SPACER\n\n local wlan0 = net.wlan0\n if wlan0 and wlan0.ipConfig then\n networkPanel[#networkPanel + 1] = row(\"📶\", tt(self, \"main_wifi_ip\", \"Main Wifi IP\"), tostring(wlan0.ipConfig.ip or \"n/a\"))\n networkPanel[#networkPanel + 1] = row(\"📶\", tt(self, \"main_wifi_mac\", \"Main Wifi MAC\"), tostring(wlan0.ipConfig.macAddress or \"n/a\"))\n end\n\n networkPanel[#networkPanel + 1] = SPACER\n\n local wlan1 = net.wlan1\n if wlan1 and wlan1.ipConfig then\n networkPanel[#networkPanel + 1] = row(\"📶\", \"Backup Wifi IP\", tostring(wlan1.ipConfig.ip or \"n/a\"))\n networkPanel[#networkPanel + 1] = row(\"📶\", \"Backup Wifi MAC\", tostring(wlan1.ipConfig.macAddress or \"n/a\"))\n end\n end\n\n -- ── Local Access ─────────────────────────────────────────────────────\n networkPanel[#networkPanel + 1] = SPACER\n networkPanel[#networkPanel + 1] = SEPARATOR\n networkPanel[#networkPanel + 1] = title(tt(self, \"local_access\", \"Local Access\"))\n\n local serial = data.settingsInfo and data.settingsInfo.serialNumber or \"HC3-00000000\"\n local serialLower = string.lower(serial)\n local ethIp = data.networkConfig and data.networkConfig.eth0\n and data.networkConfig.eth0.ipConfig and data.networkConfig.eth0.ipConfig.ip\n\n networkPanel[#networkPanel + 1] = linkRow(\n \"🔍\",\n tt(self, \"find_gateways\", \"Find Gateways\"),\n \"https://find.fibaro.com\",\n \"URL to find hubs on your local Network /LAN\"\n )\n networkPanel[#networkPanel + 1] = SPACER\n networkPanel[#networkPanel + 1] = linkRow(\n \"🔗\",\n \"mDNS login\",\n string.format(\"http://%s.local\", serialLower),\n \"URL to access this Hub if you are on the same Network /LAN with Multicast DNS\"\n )\n networkPanel[#networkPanel + 1] = SPACER\n networkPanel[#networkPanel + 1] = linkRow(\n \"🔗\",\n \"IP Login\",\n string.format(\"http://%s/login\", tostring(ethIp or \"n/a\")),\n \"URL to access this Hub if you are on the same Network /LAN with the IP.\"\n )\n networkPanel[#networkPanel + 1] = SPACER\n networkPanel[#networkPanel + 1] = italicNote(tt(self, \"remember_local_user_password\", \"Remember to use Local user and password!\"))\n\n -- ── Remote Access ────────────────────────────────────────────────────\n networkPanel[#networkPanel + 1] = SPACER\n networkPanel[#networkPanel + 1] = SEPARATOR\n networkPanel[#networkPanel + 1] = title(tt(self, \"remote_access\", \"Remote Access\"))\n networkPanel[#networkPanel + 1] = linkRow(\n \"☁️\",\n \"Yubii ID\",\n \"https://id.cloud.fibaro.com/\",\n \"URL for Remote Access with Yubii ID\"\n )\n\n -- ── Fibaro Support ──────────────────────────────────────────────────\n networkPanel[#networkPanel + 1] = SPACER\n networkPanel[#networkPanel + 1] = SEPARATOR\n networkPanel[#networkPanel + 1] = title(tt(self, \"fibaro_support\", \"Fibaro Support\"))\n networkPanel[#networkPanel + 1] = linkRow(\"🛟\", \"Support\", \"https://www.fibaro.com/en/support/\")\n networkPanel[#networkPanel + 1] = linkRow(\"📖\", \"Manual\", \"https://manuals.fibaro.com/home-center-3/\")\n\n -- ── Users ────────────────────────────────────────────────────────────\n local usersPanel = {}\n usersPanel[#usersPanel + 1] = title(tt(self, \"users\", \"Users\"))\n\n local users = data.users or {}\n local normalUsers = {}\n local installerUsers = {}\n for _, user in ipairs(users) do\n if tostring(user.type) == \"installer\" then\n table.insert(installerUsers, user)\n else\n table.insert(normalUsers, user)\n end\n end\n\n -- Build userMap early so it's available for mobile devices\n local userMap = {}\n for _, u in ipairs(users) do\n userMap[u.id] = u.name\n end\n\n if #users > 0 then\n usersPanel[#usersPanel + 1] = row(\"👥\", tt(self, \"total_users\", \"Total Users\"), tostring(#users))\n end\n\n if #normalUsers == 0 then\n usersPanel[#usersPanel + 1] = row(\"👤\", tt(self, \"users\", \"Users\"), \"n/a\")\n else\n for i, user in ipairs(normalUsers) do\n if i > 1 then\n usersPanel[#usersPanel + 1] = SPACER\n usersPanel[#usersPanel + 1] = SEPARATOR\n end\n usersPanel[#usersPanel + 1] = row(\"👤\", tt(self, \"name\", \"Name\"), tostring(user.name or \"n/a\"))\n usersPanel[#usersPanel + 1] = row(\"🆔\", tt(self, \"id\", \"ID\"), tostring(user.id or \"n/a\"))\n usersPanel[#usersPanel + 1] = row(\"🔑\", tt(self, \"type\", \"Type\"), tostring(user.type or \"n/a\"))\n usersPanel[#usersPanel + 1] = row(\"📧\", tt(self, \"email\", \"Email\"), tostring(user.email or \"n/a\"))\n usersPanel[#usersPanel + 1] = row(\"🏠\", tt(self, \"at_home\", \"At Home\"), tostring(user.atHome or false))\n usersPanel[#usersPanel + 1] = row(\"☁️\", tt(self, \"ra_status\", \"RA Status\"), tostring(user.raStatus or \"n/a\"))\n usersPanel[#usersPanel + 1] = row(\"🔗\", tt(self, \"fibaro_id_role\", \"Fibaro ID Role\"), tostring(user.fidRole or \"n/a\"))\n usersPanel[#usersPanel + 1] = row(\"🔔\", tt(self, \"notifications\", \"Notifications\"), tostring(user.sendNotifications or false))\n usersPanel[#usersPanel + 1] = row(\"🎨\", tt(self, \"skin\", \"Skin\"), tostring(user.skin or \"n/a\"))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"🎨\", tt(self, \"skin_setting\", \"Skin Setting\"), tostring(user.skinSetting or \"n/a\"))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"🔐\", tt(self, \"optional_arm_pin\", \"Optional Arm Pin\"), tostring(user.useOptionalArmPin or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"🔐\", tt(self, \"integration_pin\", \"Integration Pin\"), tostring(user.useIntegrationPin or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"📜\", tt(self, \"tos_accepted\", \"TOS Accepted\"), tostring(user.tosAccepted or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"🔒\", tt(self, \"privacy_accepted\", \"Privacy Accepted\"), tostring(user.privacyPolicyAccepted or false))\n\n -- Rights\n if user.rights then\n usersPanel[#usersPanel + 1] = SPACER\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"rights_devices_all\", \"Rights - Devices All\"), tostring(user.rights.devices and user.rights.devices.all or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"rights_scenes_all\", \"Rights - Scenes All\"), tostring(user.rights.scenes and user.rights.scenes.all or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"rights_alarm_all\", \"Rights - Alarm All\"), tostring(user.rights.alarmPartitions and user.rights.alarmPartitions.all or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"rights_profiles_all\", \"Rights - Profiles All\"), tostring(user.rights.profiles and user.rights.profiles.all or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"rights_climate_all\", \"Rights - Climate All\"), tostring(user.rights.climateZones and user.rights.climateZones.all or false))\n if user.rights.advanced then\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_devices\", \"Adv - Devices\"), tostring(user.rights.advanced.devices or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_rooms\", \"Adv - Rooms\"), tostring(user.rights.advanced.rooms or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_scenes\", \"Adv - Scenes\"), tostring(user.rights.advanced.blockScenes or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_scenarios\", \"Adv - Scenarios\"), tostring(user.rights.advanced.scenarios or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_profiles\", \"Adv - Profiles\"), tostring(user.rights.advanced.profiles or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_vars_events\", \"Adv - Vars/Events\"), tostring(user.rights.advanced.variablesAndEvents or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_access\", \"Adv - Access\"), tostring(user.rights.advanced.access or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_update\", \"Adv - Update\"), tostring(user.rights.advanced.update or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_backup\", \"Adv - Backup\"), tostring(user.rights.advanced.backup or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_network\", \"Adv - Network\"), tostring(user.rights.advanced.network or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_diagnostics\", \"Adv - Diagnostics\"), tostring(user.rights.advanced.diagnostics or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_z_wave\", \"Adv - Z-Wave\"), tostring(user.rights.advanced.zWave or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_nice_elero\", \"Adv - Nice/Elero\"), tostring(user.rights.advanced.niceAndElero or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_climate\", \"Adv - Climate\"), tostring(user.rights.advanced.climate or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_alarm\", \"Adv - Alarm\"), tostring(user.rights.advanced.alarm or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_garden\", \"Adv - Garden\"), tostring(user.rights.advanced.garden or false))\n usersPanel[#usersPanel + 1] = rowNoWrap(\"⚙️\", tt(self, \"adv_knx\", \"Adv - KNX\"), tostring(user.rights.advanced.knx or false))\n end\n end\n end\n end\n\n -- ── Mobile Devices ───────────────────────────────────────────────────\n usersPanel[#usersPanel + 1] = SPACER\n usersPanel[#usersPanel + 1] = SEPARATOR\n usersPanel[#usersPanel + 1] = title(tt(self, \"mobile_devices\", \"Mobile Devices\"))\n\n if data.iosDevices and #data.iosDevices > 0 then\n usersPanel[#usersPanel + 1] = row(\"📱\", tt(self, \"total_devices\", \"Total Devices\"), tostring(#data.iosDevices))\n for _, device in ipairs(data.iosDevices) do\n local details = getStoredDeviceDetails(data.deviceDetails, device.id)\n local deviceId = (details and details.id) or device.id or \"n/a\"\n usersPanel[#usersPanel + 1] = row(\"📱\", tt(self, \"device_name\", \"Device Name\"), device.name or \"n/a\")\n usersPanel[#usersPanel + 1] = row(\"\", tt(self, \"device_id\", \"Device ID\"), tostring(deviceId))\n usersPanel[#usersPanel + 1] = row(\"\", tt(self, \"push\", \"Push\"), tostring(device.push or false))\n if details and details.properties then\n local props = details.properties\n local appId = props.appId or \"n/a\"\n if appId == \"com.niceforyou.yubii\" then appId = \"Yubii Home\" end\n usersPanel[#usersPanel + 1] = row(\"\", tt(self, \"app_id\", \"App ID\"), appId)\n local lastUserId = props.lastLoggedUser\n local userName = (userMap and userMap[lastUserId]) or \"n/a\"\n usersPanel[#usersPanel + 1] = row(\"\", tt(self, \"user_logged\", \"User Logged\"), userName)\n usersPanel[#usersPanel + 1] = row(\"\", tt(self, \"system\", \"System\"), props.system or \"n/a\")\n end\n usersPanel[#usersPanel + 1] = SPACER\n end\n else\n usersPanel[#usersPanel + 1] = row(\"📱\", tt(self, \"mobile_devices\", \"Mobile Devices\"), \"n/a\")\n end\n\n -- installer users will be rendered in the installer panel below\n\n\n -- ── Installer Access ────────────────────────────────────────────────\n local installerPanel = {}\n installerPanel[#installerPanel + 1] = title(tt(self, \"installer_access\", \"Installer Access\"))\n\n local instAccess = data.installerAccess\n if not instAccess then\n installerPanel[#installerPanel + 1] = row(\"🔧\", tt(self, \"installer_access\", \"Installer Access\"), \"n/a\")\n else\n local enabled = instAccess.installationAccess or false\n local enabledIcon = enabled and \"✅\" or \"❌\"\n installerPanel[#installerPanel + 1] = row(enabledIcon, tt(self, \"access_enabled\", \"Access Enabled\"), tostring(enabled))\n installerPanel[#installerPanel + 1] = row(\"🕒\", tt(self, \"expire_time\", \"Expire Time\"), instAccess.installationAccessExpireTime and os.date(\"%Y-%m-%d %H:%M:%S\", instAccess.installationAccessExpireTime) or \"n/a\")\n if enabled and instAccess.installationAccessExpireTime then\n local expireTime = tonumber(instAccess.installationAccessExpireTime) or 0\n if expireTime > 0 then\n local remaining = expireTime - os.time()\n if remaining > 0 then\n local hours = math.floor(remaining / 3600)\n local mins = math.floor((remaining % 3600) / 60)\n installerPanel[#installerPanel + 1] = row(\"⏱️\", tt(self, \"expires_in\", \"Expires in\"), string.format(\"%dh %dm\", hours, mins))\n else\n installerPanel[#installerPanel + 1] = row(\"⏱️\", tt(self, \"status\", \"Status\"), tt(self, \"expired\", \"Expired\"))\n end\n end\n end\n end\n\n local installerInfo = data.installerInfo\n if installerInfo then\n installerPanel[#installerPanel + 1] = SPACER\n installerPanel[#installerPanel + 1] = row(\"📧\", tt(self, \"installer_email\", \"Installer Email\"), tostring(installerInfo.email or \"n/a\"))\n installerPanel[#installerPanel + 1] = row(\"👤\", tt(self, \"installer_name\", \"Installer Name\"), tostring((installerInfo.firstName or \"\") .. \" \" .. (installerInfo.lastName or \"\")))\n installerPanel[#installerPanel + 1] = row(\"🛡️\", tt(self, \"certified\", \"Certified\"), tostring(installerInfo.isCertified or false))\n installerPanel[#installerPanel + 1] = row(\"🔄\", tt(self, \"synchronized\", \"Synchronized\"), tostring(installerInfo.isSynchronized or false))\n installerPanel[#installerPanel + 1] = row(\"🌐\", tt(self, \"remote_access\", \"Remote Access\"), tostring(installerInfo.remoteAccess or false))\n installerPanel[#installerPanel + 1] = row(\"📊\", tt(self, \"monitoring\", \"Monitoring\"), tostring(installerInfo.monitoring or false))\n end\n\n installerPanel[#installerPanel + 1] = SPACER\n installerPanel[#installerPanel + 1] = row(\"⏳\", tt(self, \"remote_access_granted\", \"Remote Access Granted\"), self.installerGrantedAt and os.date(\"%Y-%m-%d %H:%M:%S\", self.installerGrantedAt) or \"n/a\")\n installerPanel[#installerPanel + 1] = row(\"⏱\", tt(self, \"remote_access_expires\", \"Remote Access Expires\"), self.installerExpiresAt and os.date(\"%Y-%m-%d %H:%M:%S\", self.installerExpiresAt) or \"n/a\")\n installerPanel[#installerPanel + 1] = row(\"🚫\", tt(self, \"remote_access_withdrawn\", \"Remote Access Withdrawn\"), self.installerWithdrawnAt and os.date(\"%Y-%m-%d %H:%M:%S\", self.installerWithdrawnAt) or \"n/a\")\n\n if #installerUsers > 0 then\n installerPanel[#installerPanel + 1] = SPACER\n for _, user in ipairs(installerUsers) do\n installerPanel[#installerPanel + 1] = SPACER\n installerPanel[#installerPanel + 1] = row(\"👤\", tt(self, \"name\", \"Name\"), tostring(user.name or \"n/a\"))\n installerPanel[#installerPanel + 1] = row(\"🆔\", tt(self, \"id\", \"ID\"), tostring(user.id or \"n/a\"))\n installerPanel[#installerPanel + 1] = row(\"🔑\", tt(self, \"type\", \"Type\"), tostring(user.type or \"n/a\"))\n installerPanel[#installerPanel + 1] = row(\"📧\", tt(self, \"email\", \"Email\"), tostring(user.email or \"n/a\"))\n installerPanel[#installerPanel + 1] = row(\"🏠\", tt(self, \"at_home\", \"At Home\"), tostring(user.atHome or false))\n installerPanel[#installerPanel + 1] = row(\"☁️\", tt(self, \"ra_status\", \"RA Status\"), tostring(user.raStatus or \"n/a\"))\n installerPanel[#installerPanel + 1] = row(\"🔗\", tt(self, \"fibaro_id_role\", \"Fibaro ID Role\"), tostring(user.fidRole or \"n/a\"))\n installerPanel[#installerPanel + 1] = row(\"🔔\", tt(self, \"notifications\", \"Notifications\"), tostring(user.sendNotifications or false))\n installerPanel[#installerPanel + 1] = row(\"🎨\", tt(self, \"skin\", \"Skin\"), tostring(user.skin or \"n/a\"))\n end\n end\n\n -- Credential status message\n local username = tostring(self:getVariable(\"Username\") or \"\")\n local password = tostring(self:getVariable(\"Password\") or \"\")\n if username == \"\" or password == \"\" then\n installerPanel[#installerPanel + 1] = SPACER\n installerPanel[#installerPanel + 1] = centeredMessage(\n C_RED,\n tt(self, \"installer_credentials_missing\", \"Username and Password isnt set and cant grant access for installers without it from this Quickapp!\")\n )\n elseif self.credentialError then\n installerPanel[#installerPanel + 1] = SPACER\n installerPanel[#installerPanel + 1] = centeredMessage(\n C_RED,\n tt(self, \"installer_credentials_error\", \"Credentials isn't right or something.\")\n )\n else\n installerPanel[#installerPanel + 1] = SPACER\n installerPanel[#installerPanel + 1] = centeredMessage(\n C_GREEN,\n tt(self, \"installer_credentials_ready\", \"Username and password are set try to grant access for Installer:\")\n )\n end\n\n return cpuPanel, infoPanel, manualPanel, networkPanel, usersPanel, installerPanel, avgPct\nend\n\n-- Keeps exactly one pending diagnostics poll timer.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:scheduleDiagnosticsPoll(delayMs)\n self.runtime = self.runtime or {}\n if self.runtime.diagnosticsTimer then clearTimeout(self.runtime.diagnosticsTimer) end\n self.runtime.diagnosticsTimer = setTimeout(function()\n self.runtime.diagnosticsTimer = nil\n self:pollDiagnostics()\n end, math.max(0, tonumber(delayMs) or POLL_MS))\nend\n\nfunction QuickApp:pollDiagnostics()\n self.runtime = self.runtime or {}\n if self.runtime.diagnosticsTimer then\n clearTimeout(self.runtime.diagnosticsTimer)\n self.runtime.diagnosticsTimer = nil\n end\n local ok, data = pcall(function() return api.get(\"/diagnostics\") end)\n\n if not ok or not data then\n self:error(\"Failed to read /diagnostics\")\n self:updateView(\"Info\", \"text\", renderHtml({ row(\"❗\", \"Error\", \"Failed to read /diagnostics\") }))\n self:scheduleDiagnosticsPoll(POLL_MS)\n return\n end\n\n -- Merge with static data\n if self.staticData then\n for k, v in pairs(self.staticData) do\n data[k] = v\n end\n end\n\n local cpuPanel, infoPanel, manualPanel, networkPanel, usersPanel, installerPanel, avgPct = self:buildLines(data)\n\n -- add 20 blank lines to the dedicated \"space\" label control\n local blankLines = string.rep(\"\\n\", 20)\n self:updateView(\"space\", \"text\", blankLines)\n self:updateView(\"space2\", \"text\", blankLines)\n\n self:updateView(\"info_cpu\", \"text\", renderHtml(cpuPanel))\n self:updateView(\"Info\", \"text\", renderHtml(infoPanel))\n self:updateView(\"info_manual\", \"text\", renderHtml(manualPanel))\n self:updateView(\"info_localaccess\", \"text\", renderHtml(networkPanel))\n self:updateView(\"info_users\", \"text\", renderHtml(usersPanel))\n self:updateView(\"info_installer\", \"text\", renderHtml(installerPanel))\n\n if data.ledBrightness ~= nil then\n refreshLedBrightnessSlider(self, data.ledBrightness)\n end\n\n local remoteAccessEnabled = data.installerInfo and data.installerInfo.remoteAccess or false\n refreshInstallerAccessButton(self, remoteAccessEnabled)\n\n if avgPct then\n self:updateProperty(\"value\", avgPct)\n local avg1h = self.avgSnapshot and self.avgSnapshot.avg1h\n if avg1h then\n self:updateProperty(\"log\", string.format(\"1h avg: %.1f%%\", round1(avg1h)))\n else\n self:updateProperty(\"log\", string.format(\"CPU avg: %.1f%%\", avgPct))\n end\n end\n persistCpuState(self)\n\n self.currentData = data\n\n self:scheduleDiagnosticsPoll(POLL_MS)\nend\n\n-- Writes and verifies one debounced LED brightness request.\n-------------------------------------------------------------------------------------------\nlocal function applyLedBrightness(self, value, previousValue, generation)\n local ok, err = pcall(function() api.put(\"/settings/led\", { brightness = value }) end)\n if ok then\n self:debug(string.format(\"LED brightness set to %d%%\", value))\n setTimeout(function()\n if generation ~= self.runtime.ledRequestGeneration then return end\n local ok2, led = pcall(function() return api.get(\"/settings/led\") end)\n if ok2 and type(led) == \"table\" then\n refreshLedBrightnessSlider(self, led.brightness)\n self:debug(string.format(\"LED brightness verified: %d%%\", toNum(led.brightness)))\n if self.staticData then self.staticData.ledBrightness = led.brightness end\n if self.currentData then\n self.currentData.ledBrightness = led.brightness\n self:updateView(\"Info\", \"text\", renderHtml(self:buildInfoPanel(self.currentData)))\n end\n else\n self:error(\"Failed to verify LED brightness\")\n end\n end, 1000)\n else\n if generation == self.runtime.ledRequestGeneration and previousValue ~= nil then\n refreshLedBrightnessSlider(self, previousValue)\n end\n self:error(\"Failed to set LED brightness: \" .. tostring(err))\n end\nend\n\n-- Debounces rapid slider events so only the final value is written to HC3.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:led_brightness(event)\n local rawValue = event and event.values and event.values[1]\n local value = math.max(0, math.min(100, math.floor(tonumber(rawValue) or 0)))\n self.runtime = self.runtime or {}\n self.runtime.ledRequestGeneration = (self.runtime.ledRequestGeneration or 0) + 1\n local generation = self.runtime.ledRequestGeneration\n\n if not self.runtime.ledPreviousCaptured then\n self.runtime.ledPreviousValue = self.currentData and self.currentData.ledBrightness\n or self.staticData and self.staticData.ledBrightness\n self.runtime.ledPreviousCaptured = true\n end\n refreshLedBrightnessSlider(self, value)\n if self.runtime.ledDebounceTimer then clearTimeout(self.runtime.ledDebounceTimer) end\n self.runtime.ledDebounceTimer = setTimeout(function()\n self.runtime.ledDebounceTimer = nil\n local previousValue = self.runtime.ledPreviousValue\n self.runtime.ledPreviousValue, self.runtime.ledPreviousCaptured = nil, false\n applyLedBrightness(self, value, previousValue, generation)\n end, CONFIG.ledDebounceMs)\nend\n\nfunction QuickApp:installer_access24h(event)\n local currentEnabled = self.installerInfo and self.installerInfo.remoteAccess or false\n local eventEnabled = parseBoolFromEvent(event)\n local newEnabled\n if eventEnabled == nil then\n newEnabled = not currentEnabled\n elseif eventEnabled == currentEnabled then\n -- Button callbacks can echo current value; treat that as a toggle press.\n newEnabled = not currentEnabled\n else\n newEnabled = eventEnabled\n end\n setInstallerAccessButtonValue(self, newEnabled)\n\n local ok, res = pcall(function()\n return api.put(\"/installer\", { remoteAccess = newEnabled })\n end)\n\n if not ok then\n -- fallback to older path style\n ok, res = pcall(function()\n return api.put(\"/api/installer\", { remoteAccess = newEnabled })\n end)\n end\n\n if not ok then\n setInstallerAccessButtonValue(self, currentEnabled)\n self:error(\"Failed to set installer remote access (api.put error): \" .. tostring(res))\n self:error(\"Hint: installer access API may require admin privileges.\")\n return\n end\n\n local statusCode = nil\n if type(res) == \"table\" and res.status then\n statusCode = tonumber(res.status)\n end\n if statusCode == 401 or statusCode == 403 then\n self:error(\"Installer access request was unauthorized (\" .. tostring(statusCode) .. \").\")\n self:error(\"Attempting admin credential fallback...\")\n self.credentialError = true -- mark error\n if requestInstallerAccessWithAdmin(self, newEnabled, currentEnabled) then\n return\n end\n setInstallerAccessButtonValue(self, currentEnabled)\n self:error(\"Set Username/Password QA variables with an admin account to proceed.\")\n return\n end\n\n -- Some HC3 APIs return no body (nil) on success; that's OK.\n if res and type(res) == \"table\" and res.remoteAccess ~= nil then\n self.installerInfo = self.installerInfo or {}\n self.installerInfo.remoteAccess = res.remoteAccess\n else\n self.installerInfo = self.installerInfo or {}\n self.installerInfo.remoteAccess = newEnabled\n end\n\n self.credentialError = false -- success\n setInstallerAccessButtonText(self, self.installerInfo.remoteAccess and true or false)\n\n local now = os.time()\n if newEnabled then\n self.installerGrantedAt = now\n self.installerExpiresAt = now + 24 * 3600\n self.installerWithdrawnAt = nil\n else\n self.installerWithdrawnAt = now\n self.installerExpiresAt = nil\n end\n saveInstallerTimeline(self)\n\n -- keep settings/installationAccess in sync so \"Access Enabled\" updates correctly\n syncInstallationAccessState(self, newEnabled, now)\n\n self:debug(string.format(\"Installer remote access set to %s\", tostring(newEnabled)))\n self:scheduleDiagnosticsPoll(2000)\n -- Verify access after 10 seconds\n setTimeout(function() self:fetchStaticData(false) end, 10000)\nend\n\nfunction QuickApp:update(event)\n if self.updateInProgress then\n self:debug(\"Manual update already running\")\n return\n end\n\n self.updateInProgress = true\n self:updateView(\"update\", \"value\", \"true\")\n self:debug(\"Manual update triggered\")\n local ok, err = pcall(function()\n self:fetchStaticData(false)\n self:fetchZwaveTransmissions()\n self:pollDiagnostics()\n end)\n if not ok then\n self:error(\"Manual update failed: \" .. tostring(err))\n end\n\n self.updateInProgress = false\n self:updateView(\"update\", \"value\", \"false\")\nend\n\nfunction QuickApp:language_select(event)\n local selectedLanguage = normalizeLanguageCode(event and event.values and event.values[1])\n if not selectedLanguage then\n refreshLanguageControls(self)\n return\n end\n\n self.pendingLanguageCode = selectedLanguage\n refreshLanguageControls(self)\nend\n\nfunction QuickApp:language_save(event)\n local pressed = event and event.values and event.values[1] == true\n self:updateView(\"language_save\", \"value\", \"false\")\n if not pressed then return end\n\n local selectedLanguage = getDropdownLanguage(self)\n local currentQaLanguage = normalizeLanguageCode(getStoredLanguage(self)) or getStoredLanguage(self) or \"en\"\n local currentHubLanguage = normalizeLanguageCode(getHubDefaultLanguage(self)) or getHubDefaultLanguage(self) or \"en\"\n if selectedLanguage == currentQaLanguage and selectedLanguage == currentHubLanguage then\n self.pendingLanguageCode = nil\n refreshLanguageControls(self)\n return\n end\n\n local ok, result = pcall(function()\n return api.put(\"/settings/info\", { defaultLanguage = selectedLanguage })\n end)\n if not ok then\n self:error(\"Failed to set /settings/info defaultLanguage: \" .. tostring(result))\n refreshLanguageControls(self)\n return\n end\n\n setStoredLanguage(self, selectedLanguage)\n self.pendingLanguageCode = nil\n\n self.staticData = self.staticData or {}\n self.staticData.settingsInfo = self.staticData.settingsInfo or {}\n self.staticData.settingsInfo.defaultLanguage = selectedLanguage\n if self.currentData then\n self.currentData.settingsInfo = self.currentData.settingsInfo or {}\n self.currentData.settingsInfo.defaultLanguage = selectedLanguage\n end\n\n refreshLanguageControls(self)\n self:debug(\"Hub default language set to \" .. tostring(selectedLanguage))\n\n if self.currentData then\n self:updateView(\"Info\", \"text\", renderHtml(self:buildInfoPanel(self.currentData)))\n end\n self:updateView(\"info_manual\", \"text\", renderHtml(self:buildManualPanel()))\n self:updateZwaveDiagnosticLabel()\n refreshDeadDevicePanel(self)\n refreshUpdatePanel(self)\nend\n\nfunction QuickApp:language_save_ld(event)\n self:updateView(\"language_save\", \"value\", \"true\")\nend\n\nfunction QuickApp:language_save_lr(event)\n self:language_save({ values = { true } })\nend\n\n-- Stops the native fallback subscriber when the QuickApp is destroyed.\n-------------------------------------------------------------------------------------------\nfunction QuickApp:onDestroy()\n if self.runtime and self.runtime.diagnosticsTimer then\n clearTimeout(self.runtime.diagnosticsTimer)\n self.runtime.diagnosticsTimer = nil\n end\n if self.runtime and self.runtime.ledDebounceTimer then\n clearTimeout(self.runtime.ledDebounceTimer)\n self.runtime.ledDebounceTimer = nil\n end\n if self.runtime and self.runtime.autoUpdateTimer then\n clearTimeout(self.runtime.autoUpdateTimer)\n self.runtime.autoUpdateTimer = nil\n end\n if self.runtime and self.runtime.startupUpdateCheckTimer then\n clearTimeout(self.runtime.startupUpdateCheckTimer)\n self.runtime.startupUpdateCheckTimer = nil\n end\n clearUpdateRequest(self)\n pcall(function() persistCpuState(self, true) end)\n local subscriber = self.runtime and self.runtime.deadDeviceSubscriber\n if subscriber and type(subscriber.stop) == \"function\" then\n pcall(function() subscriber:stop() end)\n end\nend\n"},{"name":"Language","isMain":false,"isOpen":false,"content":"local qa_type = \"980.Setting Overview Language\"\r\nlocal version = \"v1.8.0\"\r\nlocal revision_date = \"18/07/26 05:05:55\"\r\n\r\n--[[\r\nMIT License\r\n\r\nCopyright (c) 2026 Joachim Brors Sommerfeld\r\n\r\nPermission is hereby granted, free of charge, to any person obtaining a copy\r\nof this software and associated documentation files (the \"Software\"), to deal\r\nin the Software without restriction, including without limitation the rights\r\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r\ncopies of the Software, and to permit persons to whom the Software is\r\nfurnished to do so, subject to the following conditions:\r\n\r\nThe above copyright notice and this permission notice shall be included in all\r\ncopies or substantial portions of the Software.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r\nSOFTWARE.\r\n\r\n(Source: opensource.org)\r\n]]\r\n\r\n-- ===========================================================\r\n-- ---------------Language Dictionary----------------\r\n-- ===========================================================\r\n\r\nSettingsOverviewLanguage = SettingsOverviewLanguage or {}\r\n\r\nlocal TRANSLATIONS = {\r\n en = {\r\n settings = \"Settings\",\r\n cpu_info = \"CPU Info\",\r\n mem_used = \"MEM used\",\r\n mem_free = \"MEM free\",\r\n mem_cache = \"MEM cache\",\r\n mem_buffers = \"MEM buffers\",\r\n mem_not_used = \"MEM not used\",\r\n cpu_avg = \"CPU avg\",\r\n avg_15m = \"15m avg\",\r\n avg_30m = \"30m avg\",\r\n avg_1h = \"1h avg\",\r\n avg_24h = \"24h avg\",\r\n network = \"Network\",\r\n timezone = \"Timezone\",\r\n language = \"Language\",\r\n manual = \"Manual\",\r\n qa_version = \"QA version\",\r\n save_language = \"Save Language\",\r\n qa_language = \"QA Language\",\r\n hub_default_language = \"Hub Default\",\r\n selected_language = \"Selected\",\r\n pending_save = \"Pending save\",\r\n english_fallback = \"Uses English fallback\",\r\n sunrise = \"Sunrise\",\r\n sunset = \"Sunset\",\r\n temp_unit = \"Temp Unit\",\r\n time_format = \"Time Format\",\r\n currency = \"Currency\",\r\n software = \"Software\",\r\n running_beta = \"Running Beta\",\r\n stable_update = \"Stable Update\",\r\n new_beta_available = \"New Beta Available\",\r\n newest_stable = \"Newest Stable\",\r\n newest_beta = \"Newest Beta\",\r\n hub_led_brightness = \"Hub LED Brightness\",\r\n local_access = \"Local Access\",\r\n remote_access = \"Remote Access\",\r\n fibaro_support = \"Fibaro Support\",\r\n users = \"Users\",\r\n total_users = \"Total Users\",\r\n rights_devices_all = \"Rights - Devices All\",\r\n rights_scenes_all = \"Rights - Scenes All\",\r\n rights_alarm_all = \"Rights - Alarm All\",\r\n rights_profiles_all = \"Rights - Profiles All\",\r\n rights_climate_all = \"Rights - Climate All\",\r\n adv_devices = \"Adv - Devices\",\r\n adv_rooms = \"Adv - Rooms\",\r\n adv_scenes = \"Adv - Scenes\",\r\n adv_scenarios = \"Adv - Scenarios\",\r\n adv_profiles = \"Adv - Profiles\",\r\n adv_vars_events = \"Adv - Vars/Events\",\r\n adv_access = \"Adv - Access\",\r\n adv_update = \"Adv - Update\",\r\n adv_backup = \"Adv - Backup\",\r\n adv_network = \"Adv - Network\",\r\n adv_diagnostics = \"Adv - Diagnostics\",\r\n adv_z_wave = \"Adv - Z-Wave\",\r\n adv_nice_elero = \"Adv - Nice/Elero\",\r\n adv_climate = \"Adv - Climate\",\r\n adv_alarm = \"Adv - Alarm\",\r\n adv_garden = \"Adv - Garden\",\r\n adv_knx = \"Adv - KNX\",\r\n name = \"Name\",\r\n id = \"ID\",\r\n type = \"Type\",\r\n email = \"Email\",\r\n at_home = \"At Home\",\r\n ra_status = \"RA Status\",\r\n fibaro_id_role = \"Fibaro ID Role\",\r\n notifications = \"Notifications\",\r\n skin = \"Skin\",\r\n skin_setting = \"Skin Setting\",\r\n optional_arm_pin = \"Optional Arm Pin\",\r\n integration_pin = \"Integration Pin\",\r\n tos_accepted = \"TOS Accepted\",\r\n privacy_accepted = \"Privacy Accepted\",\r\n mobile_devices = \"Mobile Devices\",\r\n total_devices = \"Total Devices\",\r\n device_name = \"Device Name\",\r\n device_id = \"Device ID\",\r\n push = \"Push\",\r\n app_id = \"App ID\",\r\n user_logged = \"User Logged\",\r\n system = \"System\",\r\n installer_access = \"Installer Access\",\r\n access_enabled = \"Access Enabled\",\r\n expire_time = \"Expire Time\",\r\n expires_in = \"Expires in\",\r\n expired = \"Expired\",\r\n installer_email = \"Installer Email\",\r\n installer_name = \"Installer Name\",\r\n certified = \"Certified\",\r\n synchronized = \"Synchronized\",\r\n monitoring = \"Monitoring\",\r\n remote_access_granted = \"Remote Access Granted\",\r\n remote_access_expires = \"Remote Access Expires\",\r\n remote_access_withdrawn = \"Remote Access Withdrawn\",\r\n installer_credentials_missing = \"Username and Password isnt set and cant grant access for installers without it from this Quickapp!\",\r\n installer_credentials_error = \"Credentials isn't right or something.\",\r\n installer_credentials_ready = \"Username and password are set try to grant access for Installer:\",\r\n hub_name = \"Hub Name\",\r\n this_qa_loop_interval = \"This QA — Loop interval\",\r\n this_qa_cpu = \"This QA — CPU\",\r\n this_qa_lua_mem = \"This QA — Lua mem\",\r\n main_wifi_ip = \"Main Wifi IP\",\r\n main_wifi_mac = \"Main Wifi MAC\",\r\n find_gateways = \"Find Gateways\",\r\n remember_local_user_password = \"Remember to use Local user and password!\",\r\n z_wave_diagnostic = \"Z-Wave Diagnostic\",\r\n data = \"Data\",\r\n from = \"From\",\r\n to = \"To\",\r\n duration = \"Duration\",\r\n top_10 = \"Top 10\",\r\n status = \"Status\",\r\n transmissions = \"Transmissions\",\r\n nodes = \"Nodes\",\r\n incoming_total_each_hours = \"incomingTotal/Each Hours\",\r\n status_waiting_for_startup_fetch = \"Waiting for startup fetch...\",\r\n status_username_password_not_set = \"Username/Password not set\",\r\n status_auth_failed_twice = \"Auth failed twice - fetch paused\",\r\n dead_devices = \"Dead Devices\",\r\n name_id = \"Name | ID\",\r\n timestamp = \"Timestamp\",\r\n timestamp_log = \"Timestamp (%dDays Log)\",\r\n no_dead_devices = \"No dead devices recorded\",\r\n updates = \"QA Update\",\r\n not_found = \"Not found\",\r\n enabled = \"Enabled\",\r\n disabled = \"Disabled\",\r\n auto_update = \"Auto Update\",\r\n current_version = \"Current Version\",\r\n available_version = \"Available Version\",\r\n not_checked = \"Not checked\",\r\n auto_update_time = \"Auto Update Time\",\r\n next_auto_update = \"Next Auto Update\",\r\n next_update_check = \"Update Check\",\r\n check_update = \"Check Update\",\r\n update_now = \"Update Now\",\r\n update_ready = \"Ready\",\r\n update_busy_local = \"An update request is already pending\",\r\n update_manager_missing = \"QA Dist Manager not found\",\r\n update_forcing = \"Requesting latest update...\",\r\n update_checking = \"Checking for updates...\",\r\n auto_update_precheck = \"Checking for an update before the automatic update time...\",\r\n update_timeout = \"Update request timed out\",\r\n update_request_failed = \"Update request failed: %s\",\r\n auto_update_enabled = \"Auto Update enabled\",\r\n auto_update_enabled_at = \"Auto Update enabled for %s\",\r\n auto_update_disabled = \"Auto Update disabled\",\r\n update_up_to_date = \"Up to date: %s\",\r\n update_available = \"Update available: %s\",\r\n no_newer_update = \"No newer update (latest release: %s)\",\r\n update_check_failed = \"Update check failed: %s\",\r\n no_update_releases = \"No releases available\",\r\n none = \"None\",\r\n update_installed = \"Updated to %s. QA will restart.\",\r\n update_failed = \"Update failed: %s\",\r\n update_manager_warning = \"No QA Dist Manager Detected on the HC3, update and Auto Update Wont be possible without it.\",\r\n download_qa_dist_manager = \"Download QA Dist Manager\",\r\n update_manager_download = \"Go to URL above to download the QA and upload it to the HC3.\",\r\n },\r\n no = {\r\n settings = \"Innstillinger\",\r\n cpu_info = \"CPU Info\",\r\n mem_used = \"MEM brukt\",\r\n mem_free = \"MEM ledig\",\r\n mem_cache = \"MEM cache\",\r\n mem_buffers = \"MEM buffere\",\r\n mem_not_used = \"MEM ubrukt\",\r\n cpu_avg = \"CPU snitt\",\r\n avg_15m = \"15m snitt\",\r\n avg_30m = \"30m snitt\",\r\n avg_1h = \"1t snitt\",\r\n avg_24h = \"24t snitt\",\r\n network = \"Nettverk\",\r\n timezone = \"Tidsone\",\r\n language = \"Språk\",\r\n manual = \"Manual\",\r\n qa_version = \"QA versjon\",\r\n save_language = \"Lagre språk\",\r\n qa_language = \"QA språk\",\r\n hub_default_language = \"Hub standardspråk\",\r\n selected_language = \"Valgt\",\r\n pending_save = \"Venter pa lagring\",\r\n english_fallback = \"Bruker engelsk reserve\",\r\n sunrise = \"Soloppgang\",\r\n sunset = \"Solnedgang\",\r\n temp_unit = \"Temp enhet\",\r\n time_format = \"Tidsformat\",\r\n currency = \"Valuta\",\r\n software = \"Programvare\",\r\n running_beta = \"Kjorer beta\",\r\n stable_update = \"Stabil oppdatering\",\r\n new_beta_available = \"Ny beta tilgjengelig\",\r\n newest_stable = \"Nyeste stabile\",\r\n newest_beta = \"Nyeste beta\",\r\n hub_led_brightness = \"Hub LED lysstyrke\",\r\n local_access = \"Lokal tilgang\",\r\n remote_access = \"Fjerntilgang\",\r\n fibaro_support = \"Fibaro Support\",\r\n users = \"Brukere\",\r\n total_users = \"Total Brukere\",\r\n rights_devices_all = \"Rettigheter - Alle enheter\",\r\n rights_scenes_all = \"Rettigheter - Alle scener\",\r\n rights_alarm_all = \"Rettigheter - Alle alarmer\",\r\n rights_profiles_all = \"Rettigheter - Alle profiler\",\r\n rights_climate_all = \"Rettigheter - Alle klima\",\r\n adv_devices = \"Avansert - Enheter\",\r\n adv_rooms = \"Avansert - Rom\",\r\n adv_scenes = \"Avansert - Scener\",\r\n adv_scenarios = \"Avansert - Scenarioer\",\r\n adv_profiles = \"Avansert - Profiler\",\r\n adv_vars_events = \"Avansert - Variabler/Hendelser\",\r\n adv_access = \"Avansert - Tilgang\",\r\n adv_update = \"Avansert - Oppdatering\",\r\n adv_backup = \"Avansert - Sikkerhetskopi\",\r\n adv_network = \"Avansert - Nettverk\",\r\n adv_diagnostics = \"Avansert - Diagnostikk\",\r\n adv_z_wave = \"Avansert - Z-Wave\",\r\n adv_nice_elero = \"Avansert - Nice/Elero\",\r\n adv_climate = \"Avansert - Klima\",\r\n adv_alarm = \"Avansert - Alarm\",\r\n adv_garden = \"Avansert - Hage\",\r\n adv_knx = \"Avansert - KNX\",\r\n name = \"Navn\",\r\n id = \"ID\",\r\n type = \"Type\",\r\n email = \"E-post\",\r\n at_home = \"Hjemme\",\r\n ra_status = \"RA Status\",\r\n fibaro_id_role = \"Fibaro ID Rolle\",\r\n notifications = \"Varsler\",\r\n skin = \"Skin\",\r\n skin_setting = \"Skin innstilling\",\r\n optional_arm_pin = \"Valgfri arm-pin\",\r\n integration_pin = \"Integrasjonspin\",\r\n tos_accepted = \"TOS godkjent\",\r\n privacy_accepted = \"Personvern godkjent\",\r\n mobile_devices = \"Mobile enheter\",\r\n total_devices = \"Total enheter\",\r\n device_name = \"Enhetsnavn\",\r\n device_id = \"Enhets-ID\",\r\n push = \"Push\",\r\n app_id = \"App-ID\",\r\n user_logged = \"Innlogget bruker\",\r\n system = \"System\",\r\n installer_access = \"Installatortilgang\",\r\n access_enabled = \"Tilgang aktiv\",\r\n expire_time = \"Utlopstid\",\r\n expires_in = \"Utloper om\",\r\n expired = \"Utlopt\",\r\n installer_email = \"Installator e-post\",\r\n installer_name = \"Installator navn\",\r\n certified = \"Sertifisert\",\r\n synchronized = \"Synkronisert\",\r\n monitoring = \"Overvakning\",\r\n remote_access_granted = \"Fjerntilgang gitt\",\r\n remote_access_expires = \"Fjerntilgang utloper\",\r\n remote_access_withdrawn = \"Fjerntilgang trukket tilbake\",\r\n installer_credentials_missing = \"Brukernavn og passord er ikke satt, og tilgang for installator kan ikke gis fra denne QuickAppen!\",\r\n installer_credentials_error = \"Brukernavn eller passord er feil, eller noe annet feilet.\",\r\n installer_credentials_ready = \"Brukernavn og passord er satt, prov aa gi tilgang til installator:\",\r\n hub_name = \"Hub Navn\",\r\n this_qa_loop_interval = \"Denne QA — Loop interval\",\r\n this_qa_cpu = \"Denne QA — CPU\",\r\n this_qa_lua_mem = \"Denne QA — Lua mem\",\r\n main_wifi_ip = \"Hoved Wifi IP\",\r\n main_wifi_mac = \"Hoved Wifi MAC\",\r\n find_gateways = \"Finn Gateways\",\r\n remember_local_user_password = \"Husk å bruk lokal bruker og passord!\",\r\n z_wave_diagnostic = \"Z-Wave diagnostikk\",\r\n data = \"Data\",\r\n from = \"Fra\",\r\n to = \"Til\",\r\n duration = \"Varighet\",\r\n top_10 = \"Topp 10\",\r\n status = \"Status\",\r\n transmissions = \"Overforinger\",\r\n nodes = \"Noder\",\r\n incoming_total_each_hours = \"Innkommende/Per Time\",\r\n status_waiting_for_startup_fetch = \"Venter pa oppstartshenting...\",\r\n status_username_password_not_set = \"Brukernavn/passord er ikke satt\",\r\n status_auth_failed_twice = \"Autentisering feilet to ganger - henting stoppet\",\r\n dead_devices = \"Dode enheter\",\r\n name_id = \"Navn | ID\",\r\n timestamp = \"Tidspunkt\",\r\n timestamp_log = \"Tidspunkt (%d dagers logg)\",\r\n no_dead_devices = \"Ingen dode enheter registrert\",\r\n updates = \"QA Update\",\r\n not_found = \"Ikke funnet\",\r\n enabled = \"Aktivert\",\r\n disabled = \"Deaktivert\",\r\n auto_update = \"Automatisk oppdatering\",\r\n current_version = \"Gjeldende versjon\",\r\n available_version = \"Tilgjengelig versjon\",\r\n not_checked = \"Ikke sjekket\",\r\n auto_update_time = \"Tid for automatisk oppdatering\",\r\n next_auto_update = \"Neste automatiske oppdatering\",\r\n next_update_check = \"Oppdateringssjekk\",\r\n check_update = \"Sjekk oppdatering\",\r\n update_now = \"Oppdater naa\",\r\n update_ready = \"Klar\",\r\n update_busy_local = \"En oppdateringsforesporsel venter allerede\",\r\n update_manager_missing = \"QA Dist Manager ble ikke funnet\",\r\n update_forcing = \"Ber om nyeste oppdatering...\",\r\n update_checking = \"Sjekker etter oppdateringer...\",\r\n auto_update_precheck = \"Sjekker etter oppdatering for automatisk oppdateringstid...\",\r\n update_timeout = \"Oppdateringsforesporselen fikk tidsavbrudd\",\r\n update_request_failed = \"Oppdateringsforesporsel feilet: %s\",\r\n auto_update_enabled = \"Automatisk oppdatering aktivert\",\r\n auto_update_enabled_at = \"Automatisk oppdatering aktivert for %s\",\r\n auto_update_disabled = \"Automatisk oppdatering deaktivert\",\r\n update_up_to_date = \"Oppdatert: %s\",\r\n update_available = \"Oppdatering tilgjengelig: %s\",\r\n no_newer_update = \"Ingen nyere oppdatering (nyeste utgivelse: %s)\",\r\n update_check_failed = \"Oppdateringssjekk feilet: %s\",\r\n no_update_releases = \"Ingen utgivelser tilgjengelig\",\r\n none = \"Ingen\",\r\n update_installed = \"Oppdatert til %s. QA starter paa nytt.\",\r\n update_failed = \"Oppdatering feilet: %s\",\r\n update_manager_warning = \"Ingen QA Dist Manager ble funnet paa HC3. Oppdatering og automatisk oppdatering er ikke mulig uten den.\",\r\n download_qa_dist_manager = \"Last ned QA Dist Manager\",\r\n update_manager_download = \"Gaa til URL-en over for aa laste ned QA-en og laste den opp til HC3.\",\r\n },\r\n}\r\n\r\n-- Returns the translation dictionary for the selected language.\r\nfunction SettingsOverviewLanguage:get(languageCode)\r\n local normalized = string.lower(tostring(languageCode or \"en\"))\r\n return TRANSLATIONS[normalized] or TRANSLATIONS.en\r\nend\r\n\r\n-- Returns one translated text value, or a fallback when missing.\r\nfunction SettingsOverviewLanguage:text(languageCode, key, fallback)\r\n local dictionary = self:get(languageCode)\r\n local value = dictionary[key]\r\n if value ~= nil then\r\n return value\r\n end\r\n return fallback or key\r\nend\r\n"}]}