{
  "wolfExport": "rule",
  "version": 1,
  "data": {
    "conf": {
      "script": "-- Version: 1.0.0\nlocal T0 = os.clock()\nif not rule.id then error(\"rule.id missing\") end\n\nlocal DASH_ID = \"emergency\"\nlocal DASH = \"/dashboards/status/\" .. DASH_ID .. \"/\"\nlocal ME = DASH .. \"watcher/\"\n\nlocal DASH_CONF = \"/dashboards/conf/\" .. DASH_ID\nif type(sys.get(DASH_CONF .. \"/title\")) ~= \"string\" then\n  sys.set(DASH_CONF, { title = \"Emergency lighting\", order = 1 })\nend\n\nlocal MONITOR_PATH, TESTS_PATH = \"/persist/dt1_monitor/\", \"/persist/dt1_tests/\"\nlocal settings, num = rule.settings, tonumber\nlocal sMailTo = settings.mailTo; if sMailTo == \"\" then sMailTo = nil end\nlocal sReportTo = settings.reportTo; if sReportTo == \"\" then sReportTo = nil end\nlocal sOfflineSeconds, sBatchMs, sMaxMailsPerHour = (num(settings.offlineMinutes) or 15) * 60, (num(settings.batchSeconds) or 60) * 1000, math.max(1, num(settings.maxEmailsPerHour) or 6)\nlocal sHeartbeatHour, sHeartbeatDays = num(settings.heartbeatHour) or 5, tostring(settings.heartbeatDays or \"\"):lower()\nlocal sMonthlyReport = settings.monthlyReport ~= \"off\"\nlocal siteName = sys.get(\"/site/name\"); if type(siteName) ~= \"string\" or siteName == \"\" then siteName = rule.name or \"Wolf\" end\nlocal FAILURE_WORDS = { \"circuit failure\", \"battery duration failure\", \"battery failure\", \"lamp failure\",\n  \"function test max delay exceeded\", \"duration test max delay exceeded\", \"function test failed\", \"duration test failed\" }\n\nlocal NOT_READ = \"not yet read\"\nlocal DAY_NAMES = { \"sunday\", \"monday\", \"tuesday\", \"wednesday\", \"thursday\", \"friday\", \"saturday\" }\nif sHeartbeatDays ~= \"\" and sHeartbeatDays ~= \"daily\" and sHeartbeatDays ~= \"off\" then\n  local d = sHeartbeatDays; sHeartbeatDays = nil\n  for i = 1, 7 do if DAY_NAMES[i]:sub(1, 3) == d:sub(1, 3) then sHeartbeatDays = DAY_NAMES[i] end end\n  if not sHeartbeatDays then rule.error(\"heartbeatDays '\" .. d .. \"' not understood, using daily\") end\nend\nif not sHeartbeatDays or sHeartbeatDays == \"\" then sHeartbeatDays = \"daily\" end\nlocal GROUPS = 4\nlocal FAILURE_BITS, OFFLINE_BIT, BATTERY_BIT = 255, 256, 512\nlocal MODE_REST, MODE_EMERGENCY, MODE_FT, MODE_DT, MODE_HW_INHIBIT = 1, 4, 16, 32, 64\nlocal MODE_TESTING = MODE_FT | MODE_DT\nlocal STATUS_INHIBIT = 1\nlocal BUS_CLASS = 0\nlocal lights, keys, lightByPath, channels, busState, busMailed, groupOf = {}, {}, {}, {}, {}, {}, {}\nlocal counts = { 0, 0, 0 }\nlocal onScreen, roundKeys, styleShown = {}, {}, {}\nlocal roundRunning = false\nlocal showAll = false\nlocal seedWanted = false\nlocal pending, alarmMailTimes, docsWanted = {}, {}, {}\nlocal live, flushTimer, sending, lastRefusal, heldShown, lastReportAt, firstStartAt, today, nextHeartbeatAt, heartbeatSentOn, summarySent = false, nil, false, nil, 0, nil, nil, nil, nil, nil, nil\nlocal seededMonth = nil\nlocal startedAt = os.time()\n\nlocal function setStatus(p, v) sys.set(ME .. p, v) end\n\nlocal function setRow(k, leaf, v)\n  if not onScreen[k] then return false end\n  sys.set(DASH .. \"lights/\" .. k .. \"/\" .. leaf, v)\n  return true\nend\n\nlocal function setStyle(leaf, v)\n  if styleShown[leaf] == v then return end\n  styleShown[leaf] = v\n  setStatus(leaf, v)\nend\nlocal function dateOf(t) return os.date(\"!%Y-%m-%d\", t) end\nlocal function csvQuote(s) if s:find('\"', 1, true) then s = s:gsub('\"', '\"\"') end return '\"' .. s .. '\"' end\n\nlocal BOM = \"\\239\\187\\191\"\nlocal function csvFile(header, rows)\n  return BOM .. header .. \"\\n\" .. table.concat(rows, \"\\n\") .. \"\\n\"\nend\n\nlocal function batteryPercent(l)\n  if l.battery == 255 then return nil end\n  return math.floor(l.battery * 100 / 254 + 0.5)\nend\n\nlocal function failureWords(f)\n  local r, b = {}, 1\n  for i = 1, 8 do if f & b ~= 0 then r[#r + 1] = FAILURE_WORDS[i] end b = b << 1 end\n  return r\nend\n\nlocal function stateWords(l)\n  if l.online == nil or (l.online and l.mode == nil) then return NOT_READ end\n  if not l.online then return \"offline\" end\n  local r, m = failureWords(l.failure), l.mode\n  if m & MODE_TESTING ~= 0 then r[#r + 1] = m & MODE_FT ~= 0 and \"function test running\" or \"duration test running\"\n  elseif m & MODE_EMERGENCY ~= 0 then r[#r + 1] = \"on battery\" end\n  if m & MODE_REST ~= 0 then r[#r + 1] = \"rest mode\" end\n  if m & MODE_HW_INHIBIT ~= 0 then r[#r + 1] = \"hardwired inhibit\" end\n  if l.status & STATUS_INHIBIT ~= 0 then r[#r + 1] = \"inhibit mode\" end\n  return #r == 0 and \"ok\" or table.concat(r, \", \")\nend\n\nlocal function isOk(l)\n  local m = l.mode\n  return l.online and m and l.failure == 0 and (m & MODE_TESTING ~= 0 or m & MODE_EMERGENCY == 0)\n     and m & (MODE_REST | MODE_HW_INHIBIT) == 0 and l.status & STATUS_INHIBIT == 0\nend\n\nlocal function stateStyleOf(l)\n  if l.state == \"ok\" then return \"ok\" end\n  if isOk(l) then return \"warn\" end\n  return \"alarm\"\nend\n\nlocal function recount()\n  local c = { 0, 0, 0 }\n  for i = 1, #keys do\n    local l = lights[keys[i]]\n    if isOk(l) then c[1] = c[1] + 1 else c[2] = c[2] + 1 end\n    if (l.mode or 0) & MODE_TESTING ~= 0 then c[3] = c[3] + 1 end\n  end\n  local moved = c[2] ~= counts[2]\n  counts = c\n  if live and moved then\n    setStatus(\"faults\", counts[2])\n    setStyle(\"faultStyle\", counts[2] > 0 and \"alarm\" or \"ok\")\n  end\nend\n\nlocal function refresh(l)\n  local shown = onScreen[l.key]\n  local w = stateWords(l)\n  local leftUnread = l.state == NOT_READ and w ~= NOT_READ\n  if w ~= l.state then\n    l.state = w\n    setRow(l.key, \"state\", w)\n  end\n  local st = stateStyleOf(l)\n  if st ~= l.stateStyle then\n    l.stateStyle = st\n    setRow(l.key, \"stateStyle\", st)\n  end\n\n  if live and not shown and st == \"alarm\" then seedWanted = true end\n\n  if live and leftUnread then seedWanted = true end\n  local pct = batteryPercent(l)\n  local bt = pct and (pct .. \" %\") or \"unknown\"\n  if bt ~= l.batteryText then l.batteryText = bt; setRow(l.key, \"battery\", bt) end\nend\n\nlocal function showHeld(n) if n ~= heldShown then heldShown = n; setStatus(\"held\", n) end end\n\nlocal function sendMail(id, subj, body, att, to)\n  local pk, ok, cause, why = pcall(sys.mail, { id = id, subject = subj, body = body, to = to or sMailTo, attachment = att })\n  if not pk then ok, cause, why = false, \"error\", \"mail call raised: \" .. tostring(ok) end\n  if ok then lastRefusal = nil; setStatus(\"mailError\", \"none\"); setStyle(\"mailStyle\", \"ok\") return true end\n  cause = cause or \"refused\"\n  why = why or (\"mail \" .. cause)\n  setStatus(\"mailError\", why)\n  setStyle(\"mailStyle\", \"alarm\")\n  if cause ~= \"busy\" and cause ~= \"rate_limited\" and lastRefusal ~= cause then lastRefusal = cause; rule.error(\"mail refused: \" .. why) end\n  return false, cause\nend\n\nlocal flushAlarms\n\nlocal function queueEvent(k, c, ev)\n  for i = #pending, 1, -1 do if pending[i].key == k and pending[i].class == c then table.remove(pending, i) end end\n  if heldShown > #pending then showHeld(#pending) end\n  if ev then\n    ev.key, ev.class, ev.at = k, c, os.time()\n    pending[#pending + 1] = ev\n    if not flushTimer then flushTimer = rule.setTimeout(flushAlarms, sBatchMs) end\n  end\nend\n\nlocal function alarm(l, mask, bits, text, rank)\n  l.intended = (l.intended & ~mask) | bits\n  queueEvent(l.key, mask, (l.intended & mask) ~= (l.mailed & mask)\n    and { problem = bits ~= 0, line = l.name .. \" (\" .. l.address .. \"): \" .. text, text = text, name = l.name, rank = rank } or nil)\nend\n\nlocal function checkFailure(l)\n  local f, R = l.failure, l.mailed & FAILURE_BITS\n  if f == l.intended & FAILURE_BITS then return end\n  if f & ~R ~= 0 then\n    alarm(l, FAILURE_BITS, f, table.concat(failureWords(f), \", \"), 3)\n  elseif f == 0 and R ~= 0 then\n    alarm(l, FAILURE_BITS, 0, \"failure cleared\", 0)\n  elseif R & ~f ~= 0 then\n    alarm(l, FAILURE_BITS, f, table.concat(failureWords(R & ~f), \", \") .. \" fixed, still \" .. table.concat(failureWords(f), \", \"), 3)\n  else\n    alarm(l, FAILURE_BITS, f, table.concat(failureWords(f), \", \"), 3)\n  end\nend\nlocal function checkBattery(l)\n  local onb = (l.mode or 0) & (MODE_EMERGENCY | MODE_TESTING) == MODE_EMERGENCY\n  if onb ~= (l.intended & BATTERY_BIT ~= 0) then alarm(l, BATTERY_BIT, onb and BATTERY_BIT or 0, onb and \"on battery, mains missing\" or \"mains back\", 2) end\nend\nlocal function checkOffline(l, now)\n  if l.online then if l.intended & OFFLINE_BIT ~= 0 then alarm(l, OFFLINE_BIT, 0, \"online again\", 0) end\n  elseif l.intended & OFFLINE_BIT == 0 and (busState[l.channel] or \"ok\") == \"ok\" and now - l.silentSince >= sOfflineSeconds then\n\n    alarm(l, OFFLINE_BIT, OFFLINE_BIT, l.online == nil and \"never answered\" or \"offline\", 1)\n  end\nend\n\nflushAlarms = function()\n  flushTimer = nil\n  local n = #pending\n  if n == 0 then return end\n  local now = os.time()\n  local j = 1\n  while j <= #alarmMailTimes do if now - alarmMailTimes[j] >= 3600 then table.remove(alarmMailTimes, j) else j = j + 1 end end\n  if #alarmMailTimes >= sMaxMailsPerHour then showHeld(n) return end\n  local P, O, pb, ob, drop, nProblems = {}, {}, 0, 0, 0, 0\n  local worst, sameAsWorst = nil, 0\n  for i = n, 1, -1 do\n    local e = pending[i]\n    local line = os.date(\"!%Y-%m-%d %H:%M\", e.at) .. \"  \" .. e.line\n    if e.problem then\n      nProblems = nProblems + 1\n      if pb < 30000 then P[#P + 1] = line; pb = pb + #line + 1 else drop = drop + 1 end\n      if not worst or e.rank > worst.rank then worst = e end\n    elseif ob < 30000 then O[#O + 1] = line; ob = ob + #line + 1 else drop = drop + 1 end\n  end\n  local subject = \"all clear\"\n  if worst then\n    for i = 1, n do if pending[i].problem and pending[i].text == worst.text then sameAsWorst = sameAsWorst + 1 end end\n\n    if sameAsWorst > 1 and sameAsWorst == nProblems then subject = worst.text .. \" — \" .. sameAsWorst .. \" luminaires\"\n    elseif nProblems > 1 then subject = nProblems .. \" problems, worst: \" .. worst.text\n    else subject = worst.text .. (worst.name and \", \" .. worst.name or \"\") end\n  end\n  local body = (#P > 0 and \"PROBLEMS\\n\" .. table.concat(P, \"\\n\") .. \"\\n\\n\" or \"\")\n    .. (#O > 0 and \"OK AGAIN\\n\" .. table.concat(O, \"\\n\") .. \"\\n\\n\" or \"\")\n    .. (drop > 0 and drop .. \" more events not shown; the status report carries the current state.\\n\\n\" or \"\")\n    .. #keys .. \" luminaires supervised by rule \" .. rule.id .. \" at \" .. siteName .. \"\\n\"\n  if not sendMail(\"watch-alarm\", siteName .. \": \" .. subject, body) then showHeld(n) return end\n  local changed = {}\n  for i = 1, n do\n    local e = pending[i]\n    if e.class == BUS_CLASS then\n      local ch = e.key:sub(5)\n      busMailed[ch] = busState[ch]\n      sys.set(MONITOR_PATH .. e.key, busMailed[ch])\n    else\n      local l = lights[e.key]\n      l.mailed = l.intended\n      changed[e.key] = l.mailed\n    end\n  end\n  if next(changed) then sys.set(\"/persist/dt1_monitor\", changed) end\n  pending = {}; alarmMailTimes[#alarmMailTimes + 1] = now; showHeld(0)\nend\n\nlocal function busWords()\n  local r = {}\n  for i = 1, #channels do r[#r + 1] = channels[i] .. \" \" .. (busState[channels[i]] or \"unknown\") end\n  return table.concat(r, \", \")\nend\n\nlocal function busStyleWord()\n  for i = 1, #channels do if (busState[channels[i]] or \"unknown\") ~= \"ok\" then return \"alarm\" end end\n  return \"ok\"\nend\n\nlocal function nextHeartbeat(now)\n  if sHeartbeatDays == \"off\" then return nil end\n  local d = os.date(\"!*t\", now)\n  local day0 = now - d.hour * 3600 - d.min * 60 - d.sec\n  for i = 0, 7 do\n    local t = day0 + i * 86400 + sHeartbeatHour * 3600\n    if (sHeartbeatDays == \"daily\" or sHeartbeatDays == DAY_NAMES[(d.wday - 1 + i) % 7 + 1]) and dateOf(t) ~= heartbeatSentOn then return t end\n  end\nend\n\nlocal function publishUi()\n  local periodsStore, opts = sys.get(TESTS_PATH .. \"periods\"), {}\n  if type(periodsStore) ~= \"table\" then periodsStore = {} end\n  for _, kind in ipairs{ \"ft\", \"dt\" } do\n    local t, pat, lo, hi = periodsStore[kind], kind == \"ft\" and \"^%d%d%d%d%-%d%d$\" or \"^%d%d%d%d$\", nil, nil\n    if type(t) == \"table\" then\n      for k in pairs(t) do if k:match(pat) then if not lo or k < lo then lo = k end if not hi or k > hi then hi = k end end end\n    end\n    if lo then\n      local y, m = num(hi:sub(1, 4)), num(hi:sub(6)) or 0\n      local k, n = hi, 0\n      while k >= lo and n < 120 do\n        n = n + 1\n        opts[#opts + 1] = { value = k, label = k .. \" (\" .. (num(t[k]) or 0) .. \")\" .. (kind == \"ft\" and \" function tests\" or \" duration tests\") }\n        if m > 0 then m = m - 1; if m == 0 then m, y = 12, y - 1 end; k = string.format(\"%04d-%02d\", y, m)\n        else y = y - 1; k = tostring(y) end\n      end\n    end\n  end\n  if #opts == 0 then opts[1] = { value = \"\", label = \"no tests recorded yet\" } end\n\n  sys.set(DASH .. \"ui/cards/watcher\", { type = \"group\", title = \"Supervision\", order = 1, span = 6, sections = {\n    { type = \"kv\", title = \"The bus\", rows = {\n      { name = \"Found on the bus\", path = \"watcher/supervised\", decimals = 0 },\n      { name = \"In fault\", path = \"watcher/faults\", decimals = 0, stylePath = \"watcher/faultStyle\" },\n      { name = \"DALI bus\", path = \"watcher/bus\", stylePath = \"watcher/busStyle\" } } },\n    { type = \"kv\", title = \"Email\", rows = {\n      { name = \"Mail problem\", path = \"watcher/mailError\", stylePath = \"watcher/mailStyle\" },\n\n      { name = \"Last report\", path = \"watcher/lastReport\", format = \"datetime\" },\n      { name = \"Next heartbeat\", path = \"watcher/nextHeartbeat\", format = \"datetime\" } } },\n    { type = \"table\", title = \"Send\", rows = \"watcher/bulk\", columns = {\n      { name = \"Send report now\", cmd = \"report\", confirm = \"Email the monthly report to the owner now?\" },\n      { name = \"Send heartbeat now\", cmd = \"heartbeat\" } } },\n\n    { type = \"kv\", title = \"Logbook\", rows = {\n      { name = \"Send logbook for\", type = \"select\", cmd = \"logbook\", path = \"watcher/lastLogbook\", options = opts,\n        confirm = \"Email this logbook to the owner?\" } } } } })\nend\n\nlocal groupOptions = {}\nfor g = 1, GROUPS do\n  groupOptions[g] = { value = g, label = tostring(g) }\nend\n\nlocal function publishLightsCard()\n  sys.set(DASH .. \"ui/cards/lights\", { type = \"group\", title = \"Luminaires\", order = 3, span = 12, sections = {\n    { type = \"kv\", title = \"Which lines are shown\", rows = {\n      { name = \"Showing\", path = \"shown\" } } },\n\n    { type = \"table\", rows = \"watcher/bulk\", columns = {\n      { name = \"Only what needs attention\", cmd = \"show\", value = \"attention\" },\n      { name = \"All \" .. #keys, cmd = \"show\", value = \"all\" } } },\n    { type = \"table\", title = \"The list\", rows = \"lights\", columns = {\n      { name = \"Name\", path = \"name\" },\n      { name = \"Address\", path = \"key\" },\n      { name = \"State\", path = \"state\", stylePath = \"stateStyle\" },\n      { name = \"Battery\", path = \"battery\" },\n      { name = \"Function test\", path = \"ft\", stylePath = \"ftStyle\" },\n      { name = \"Duration test\", path = \"dt\", stylePath = \"dtStyle\" },\n\n      { name = \"Group\", path = \"group\", type = \"select\", cmd = \"setGroup\",\n        value = { \"$row.key\", \"$in\" }, options = groupOptions },\n      { name = \"Test\", cmd = \"runFunctionTest\", value = \"$row.key\" },\n      { name = \"Duration test\", cmd = \"runDurationTest\", value = \"$row.key\", style = \"danger\",\n        confirm = \"Run this luminaire's battery flat? The test takes its rated duration and the battery needs up to 24 h to recharge.\" } } } } })\nend\n\nlocal function saneEntry(e)\n  if type(e) ~= \"table\" then return nil end\n  local v = {}\n  v.t = num(e.t)\n  v.m = num(e.m); if v.m then v.m = math.floor(v.m) end\n  v.r = num(e.r); if v.r then v.r = math.floor(v.r) end\n  if type(e.o) == \"string\" then v.o = e.o end\n  if type(e.f) == \"string\" then v.f = e.f end\n  if type(e.x) == \"string\" then v.x = e.x end\n  if e.s == true then v.s = true end\n  return v\nend\n\nlocal function testText(e) return e and ((e.t and dateOf(e.t) or \"?\") .. \" \" .. (e.o or \"?\")) or \"none\" end\n\nlocal function defectText(e)\n  if type(e) ~= \"table\" then return \"\" end\n  if type(e.f) ~= \"string\" then return \"\" end\n  return e.f\nend\n\nlocal function minutesText(e)\n  if type(e) ~= \"table\" or e.m == nil then return \"\" end\n  if e.s then return tostring(e.m) .. \"+\" end\n  return tostring(e.m)\nend\n\nlocal function entryWords(kind, e)\n  if not e then return \"not tested yet\" end\n  local w = e.o or \"?\"\n  if kind == \"dt\" and e.m then w = w .. \" \" .. minutesText(e) .. \" min\" end\n  w = w .. \" \" .. (e.t and dateOf(e.t) or \"?\")\n  if e.f then w = w .. \" \\u{2014} \" .. e.f end\n  return w\nend\n\nlocal function testCells(row, r, month, year)\n  local ft, dt = saneEntry(r.ft), saneEntry(r.dt)\n  local ftDone = ft ~= nil and ft.t ~= nil and os.date(\"!%Y-%m\", ft.t) == month\n  local dtDone = dt ~= nil and dt.t ~= nil and os.date(\"!%Y\", dt.t) == year\n  row.ft, row.ftStyle = entryWords(\"ft\", ft), ftDone and \"ok\" or \"warn\"\n  row.dt, row.dtStyle = entryWords(\"dt\", dt), dtDone and \"ok\" or \"warn\"\n  return ftDone, dtDone\nend\n\nlocal function groupFor(k)\n  local g = num(groupOf[k])\n  if g and g >= 1 and g <= GROUPS then return math.floor(g) end\n  return 1\nend\n\nlocal function groupCell(k)\n  if groupOf[k] == nil then return \"\" end\n  return groupFor(k)\nend\n\nlocal function shownWords(n)\n  if showAll then return \"all \" .. n end\n  if n == 0 then return \"nothing needs attention\" end\n  if n == 1 then return \"the one that needs attention\" end\n  return \"the \" .. n .. \" that need attention\"\nend\n\nlocal function seedLights()\n  local last = sys.get(TESTS_PATH .. \"last\"); if type(last) ~= \"table\" then last = {} end\n  groupOf = sys.get(TESTS_PATH .. \"g\"); if type(groupOf) ~= \"table\" then groupOf = {} end\n  local now = os.time()\n  local month, year = os.date(\"!%Y-%m\", now), os.date(\"!%Y\", now)\n  seededMonth = month\n  local rows, shown = {}, 0\n  local was = onScreen\n  onScreen = {}\n  seedWanted = false\n  for i = 1, #keys do\n    local l = lights[keys[i]]\n    local r = last[l.key]; if type(r) ~= \"table\" then r = {} end\n    local row = { name = l.name, key = l.address, state = l.state, stateStyle = l.stateStyle,\n      battery = l.batteryText,\n      group = groupFor(l.key),\n      lock = roundRunning }\n    local ftDone, dtDone = testCells(row, r, month, year)\n    if showAll or roundKeys[l.key] or not isOk(l) or not ftDone or not dtDone then\n      shown = shown + 1\n      onScreen[l.key] = true\n      l.ftWords, l.dtWords = row.ft, row.dt\n      rows[l.key] = row\n    end\n  end\n\n  local dropping = false\n  for k in pairs(was) do\n    if not rows[k] then\n      dropping = true\n      break\n    end\n  end\n  if dropping then sys.set(DASH .. \"lights\", nil) end\n  sys.set(DASH .. \"lights\", rows)\n  sys.set(DASH .. \"shown\", shownWords(shown))\nend\n\nlocal function repairTestColumns()\n  local last = sys.get(TESTS_PATH .. \"last\"); if type(last) ~= \"table\" then last = {} end\n  local now = os.time()\n  local month, year = os.date(\"!%Y-%m\", now), os.date(\"!%Y\", now)\n  local cell = {}\n  for i = 1, #keys do\n    local l = lights[keys[i]]\n    if onScreen[l.key] then\n      local r = last[l.key]; if type(r) ~= \"table\" then r = {} end\n      testCells(cell, r, month, year)\n      if cell.ft ~= l.ftWords and setRow(l.key, \"ft\", cell.ft) then\n        l.ftWords = cell.ft\n        setRow(l.key, \"ftStyle\", cell.ftStyle)\n      end\n      if cell.dt ~= l.dtWords and setRow(l.key, \"dt\", cell.dt) then\n        l.dtWords = cell.dt\n        setRow(l.key, \"dtStyle\", cell.dtStyle)\n      end\n    end\n  end\nend\n\nlocal function sendReport()\n  local last = sys.get(TESTS_PATH .. \"last\"); if type(last) ~= \"table\" then last = {} end\n  local now = os.time()\n  local B, A, U, csv = {}, {}, {}, {}\n  for i = 1, #keys do\n    local l = lights[keys[i]]\n    local r = last[l.key]; if type(r) ~= \"table\" then r = {} end\n    local ft, dt = saneEntry(r.ft), saneEntry(r.dt)\n    local a, ok = l.address, isOk(l)\n    if l.state == NOT_READ then U[#U + 1] = l.name .. \" (\" .. a .. \")\"\n    elseif not ok then\n      A[#A + 1] = l.name .. \" (\" .. a .. \"): \" .. l.state .. \"; battery \" .. l.batteryText .. \"; last function test \" .. testText(ft) .. \"; last duration test \" .. testText(dt)\n    end\n    csv[#csv + 1] = a .. \",\" .. csvQuote(l.name) .. \",\" .. tostring(l.online == true) .. \",\" .. tostring(ok == true) .. \",\"\n      .. (batteryPercent(l) or \"\") .. \",\" .. csvQuote(ok and \"\" or l.state) .. \",\"\n      .. (ft and ft.t and dateOf(ft.t) or \"\") .. \",\" .. (ft and ft.o or \"\") .. \",\" .. csvQuote(defectText(ft)) .. \",\"\n      .. (dt and dt.t and dateOf(dt.t) or \"\") .. \",\" .. (dt and dt.o or \"\") .. \",\" .. csvQuote(defectText(dt)) .. \",\"\n      .. minutesText(dt) .. \",\" .. (dt and dt.r or \"\") .. \",\" .. groupCell(l.key)\n  end\n  B[1] = siteName .. \" emergency lighting status \" .. dateOf(now) .. \"\\n\\nOK: \" .. counts[1] .. \" of \" .. #keys .. \" luminaires\"\n  if #A > 0 then B[#B + 1] = \"NEEDS ATTENTION\\n\" .. table.concat(A, \"\\n\") end\n  if #U > 0 then B[#B + 1] = \"NOT YET READ\\n\" .. table.concat(U, \"\\n\") end\n  B[#B + 1] = \"DALI bus: \" .. busWords() .. \"\\nDetails in the attached CSV. Rule \" .. rule.id .. \".\"\n    .. \"\\nThe tests are run automatically by the DT1 tester rule on this Wolf controller \\u{2014} a function test every month, a duration test every year, one group of luminaires at a time \\u{2014} and each result is read from that luminaire's own test flags over DALI, to IEC 62386-202.\"\n  local ok, cause = sendMail(\"watch-report\", siteName .. \": emergency lighting report \" .. dateOf(now), table.concat(B, \"\\n\\n\"),\n    { name = \"dt1-status-\" .. dateOf(now) .. \".csv\", contentType = \"text/csv\",\n      content = csvFile(\"address,name,online,ok,battery_pct,problem,last_function_test,function_result,function_defect,last_duration_test,duration_result,duration_defect,minutes,rated,group\",\n        csv) }, sReportTo)\n  if ok then lastReportAt = now; sys.set(MONITOR_PATH .. \"sent/report\", lastReportAt); setStatus(\"lastReport\", lastReportAt) end\n  if not ok and cause == \"error\" then\n    lastReportAt = now\n    return true\n  end\n  return ok\nend\n\nlocal function sendHeartbeat()\n  local now = os.time()\n  local ok, cause = sendMail(\"watch-hb\", siteName .. \": emergency lighting watcher alive\",\n    \"OK: \" .. counts[1] .. \" of \" .. #keys .. \" luminaires\\nIn fault: \" .. counts[2] .. \"\\nLast report: \" .. (lastReportAt and dateOf(lastReportAt) or \"never\")\n    .. \"\\nDALI bus: \" .. busWords() .. \"\\nRule \" .. rule.id .. \", \" .. os.date(\"!%Y-%m-%d %H:%M\", now) .. \" UTC\\n\")\n  if ok then\n    heartbeatSentOn = dateOf(now); sys.set(MONITOR_PATH .. \"sent/heartbeat\", heartbeatSentOn)\n    nextHeartbeatAt = nextHeartbeat(now); setStatus(\"nextHeartbeat\", nextHeartbeatAt)\n  end\n  if not ok and cause == \"error\" then\n    heartbeatSentOn = dateOf(now); nextHeartbeatAt = nextHeartbeat(now)\n    return true\n  end\n  return ok\nend\n\nlocal function sendLogbook(period)\n  if type(period) ~= \"string\" or not (period:match(\"^%d%d%d%d$\") or period:match(\"^%d%d%d%d%-%d%d$\")) then\n    return true\n  end\n  local isYear = #period == 4\n  local kind = isYear and \"duration\" or \"function\"\n  local tree = sys.get(TESTS_PATH .. (isYear and \"dt/\" or \"ft/\") .. period)\n  if type(tree) ~= \"table\" then tree = {} end\n\n  local rows = {}\n  local treeKeys = {}\n  for k in pairs(tree) do treeKeys[#treeKeys + 1] = k end\n  table.sort(treeKeys, function(x, y)\n    local xc, xa = tostring(x):match(\"^(%d+)_(%d+)$\")\n    local yc, ya = tostring(y):match(\"^(%d+)_(%d+)$\")\n    if xc and yc then\n      if xc ~= yc then return xc < yc end\n      return num(xa) < num(ya)\n    end\n    if xc ~= nil or yc ~= nil then return xc ~= nil end\n    return tostring(x) < tostring(y)\n  end)\n  for i = 1, #treeKeys do\n    local k = treeKeys[i]\n    local tests = tree[k]\n    if type(tests) == \"table\" then\n      local light = lights[tostring(k)]\n      local a = light and light.address or \"ch\" .. (tostring(k):gsub(\"_\", \"/\"))\n      local nameQ = csvQuote(light and light.name or \"(unknown)\")\n      local stamps = {}\n\n      for stamp in pairs(tests) do stamps[#stamps + 1] = { key = stamp, n = num(stamp) or math.huge } end\n      table.sort(stamps, function(x, y) return x.n < y.n end)\n      for j = 1, #stamps do\n        local entry = saneEntry(tests[stamps[j].key])\n        if entry then\n          local t = entry.t or (stamps[j].n ~= math.huge and stamps[j].n or nil)\n          rows[#rows + 1] = a .. \",\" .. nameQ .. \",\" .. (t and dateOf(t) or \"?\")\n            .. \",\" .. (entry.o or \"\") .. \",\" .. csvQuote(defectText(entry)) .. \",\" .. minutesText(entry)\n            .. \",\" .. (entry.r or \"\") .. \",\" .. (entry.x == \"m\" and \"manual\" or \"scheduled\")\n        end\n      end\n    end\n  end\n\n  local body\n  if #rows == 0 then\n    body = \"No \" .. kind .. \" tests were recorded in \" .. period .. \" — nothing was tested.\\n\"\n  else\n    body = #rows .. \" \" .. kind .. \" tests in \" .. period .. \", one row per test in the attached CSV.\\n\"\n  end\n  body = body .. #keys .. \" luminaires supervised. Rule \" .. rule.id .. \".\\n\"\n\n  local attachment\n  if #rows > 0 then\n    attachment = { name = \"dt1-logbook-\" .. period .. \".csv\", contentType = \"text/csv\",\n      content = csvFile(\"address,name,date,result,defect,minutes,rated,mode\", rows) }\n  end\n\n  local ok, cause = sendMail(\"watch-log\", siteName .. \": emergency lighting logbook \" .. period, body, attachment, sReportTo)\n  if ok then setStatus(\"lastLogbook\", period) end\n  if not ok and cause == \"error\" then return true end\n  return ok\nend\n\nlocal summaryQueue = {}\nlocal function sendSummary()\n  local line = summaryQueue[1]\n  if not line then return true end\n  local ok, cause = sendMail(\"watch-tests\", siteName .. \": emergency lighting test results\", line .. \"\\n\")\n  if ok then\n    table.remove(summaryQueue, 1)\n    summarySent = line; sys.set(MONITOR_PATH .. \"sent/summary\", line)\n    return #summaryQueue == 0\n  end\n  if cause == \"error\" then\n    table.remove(summaryQueue, 1)\n    return #summaryQueue == 0\n  end\n  return false\nend\n\nlocal SENDERS = { report = sendReport, heartbeat = sendHeartbeat, logbook = sendLogbook, summary = sendSummary }\n\nlocal function setBusy(b) sending = b; if not b then setStatus(\"running\", \"idle\") end end\nlocal function sendNext()\n  local k, v = next(docsWanted)\n  if not k then setBusy(false) return end\n  setStatus(\"running\", \"sending \" .. k)\n  local ok, res = pcall(SENDERS[k], v)\n  if not ok then\n    rule.error(\"send \" .. k .. \" failed\", tostring(res))\n    res = true\n\n    if k == \"report\" then lastReportAt = os.time() end\n    if k == \"heartbeat\" then heartbeatSentOn = dateOf(os.time()); nextHeartbeatAt = nextHeartbeat(os.time()) end\n  end\n  if res then docsWanted[k] = nil; rule.setTimeout(sendNext, 50) else setBusy(false) end\nend\nlocal function startSending()\n  if sending or not next(docsWanted) then return end\n  setBusy(true); rule.setTimeout(sendNext, 50)\nend\n\nlocal excluded = {}\nfor tok in tostring(settings.exclude or \"\"):gmatch(\"[^,;]+\") do\n  tok = tok:gsub(\"%s+\", \"\"):lower()\n  local c, a = tok:match(\"^ch([12])/(%d+)$\")\n  if c then excluded[c .. \"_\" .. num(a)] = tok elseif tok ~= \"\" then rule.error(\"exclude: cannot read '\" .. tok .. \"', expected ch1/15\") end\nend\nlocal monitorStore = sys.get(\"/persist/dt1_monitor\"); if type(monitorStore) ~= \"table\" then monitorStore = {} end\nlocal sentStore = monitorStore.sent; if type(sentStore) ~= \"table\" then sentStore = {} end\nlastReportAt, heartbeatSentOn, firstStartAt, summarySent = num(sentStore.report), sentStore.heartbeat, num(sentStore.since), sentStore.summary\nif not firstStartAt then firstStartAt = startedAt; sys.set(MONITOR_PATH .. \"sent/since\", firstStartAt) end\nfor c = 1, 2 do\n  local ch = \"ch\" .. c\n  local conf = sys.get(\"/dali/conf/\" .. ch)\n  if type(conf) == \"table\" and conf.enabled and type(conf.gear) == \"table\" then\n    busMailed[ch] = monitorStore[\"bus_\" .. ch]\n    local before = #keys\n    for a, g in pairs(conf.gear) do\n      local k = c .. \"_\" .. a\n      if type(g) == \"table\" and num(g.type) == 1 then\n        if excluded[k] then excluded[k] = nil\n        else\n          lights[k] = { key = k, name = (g.name and g.name ~= \"\") and g.name or ch .. \"/\" .. a, channel = ch,\n            address = \"ch\" .. c .. \"/\" .. a,\n            failure = 0, status = 0, battery = 255, mailed = math.floor(num(monitorStore[k]) or 0), state = NOT_READ, batteryText = \"unknown\",\n            stateStyle = \"alarm\",\n            silentSince = startedAt }\n          lights[k].intended = lights[k].mailed\n          keys[#keys + 1] = k\n        end\n      end\n    end\n    if #keys > before then channels[#channels + 1] = ch end\n  end\nend\nfor _, tok in pairs(excluded) do rule.error(\"exclude: no emergency luminaire at \" .. tok) end\ndo\n\n  local ranked = {}\n  for i = 1, #keys do local k = keys[i]; ranked[i] = { k = k, c = k:sub(1, 1), a = num(k:sub(3)) } end\n  table.sort(ranked, function(x, y) if x.c ~= y.c then return x.c < y.c end return x.a < y.a end)\n  for i = 1, #ranked do keys[i] = ranked[i].k end\nend\n\nlocal function subscribeLight(p, l, fn) lightByPath[p] = l; rule.subscribe(p, fn) end\n\nlocal function onFailure(p, v) local l = lightByPath[p]; if type(v) == \"number\" then l.failure = math.floor(v) & FAILURE_BITS; checkFailure(l); refresh(l); recount() end end\nlocal function onMode(p, v) local l = lightByPath[p]; if type(v) == \"number\" then l.mode = math.floor(v); checkBattery(l); refresh(l); recount() end end\nlocal function onStatus(p, v) local l = lightByPath[p]; if type(v) == \"number\" and math.floor(v) ~= l.status then l.status = math.floor(v); refresh(l); recount() end end\nlocal function onBattery(p, v) local l = lightByPath[p]; if type(v) == \"number\" and v ~= l.battery then l.battery = v; refresh(l) end end\nlocal function onOnline(p, v)\n  local l = lightByPath[p]\n  if type(v) ~= \"boolean\" or v == l.online then return end\n  l.online = v; l.silentSince = os.time()\n  checkOffline(l, l.silentSince); refresh(l); recount()\nend\nfor i = 1, #keys do\n  local l = lights[keys[i]]\n  local base = \"/dali/status/\" .. l.channel .. \"/gear/\" .. l.key:sub(3) .. \"/\"\n  subscribeLight(base .. \"online\", l, onOnline)\n  subscribeLight(base .. \"dt1FailureStatus\", l, onFailure)\n  subscribeLight(base .. \"dt1EmergencyMode\", l, onMode)\n  subscribeLight(base .. \"dt1EmergencyStatus\", l, onStatus)\n  subscribeLight(base .. \"dt1BatteryCharge\", l, onBattery)\nend\nfor _, ch in ipairs(channels) do\n  rule.subscribe(\"/dali/status/bus/\" .. ch .. \"/bus\", function(_, v)\n    if type(v) ~= \"string\" or v == busState[ch] then return end\n    busState[ch] = v\n    if live then setStatus(\"bus\", busWords()); setStyle(\"busStyle\", busStyleWord()) end\n    if v == \"ok\" then\n      local now = os.time()\n      for i = 1, #keys do local l = lights[keys[i]]; if l.channel == ch and not l.online then l.silentSince = now end end\n    end\n    if v == busMailed[ch] then queueEvent(\"bus_\" .. ch, BUS_CLASS, nil)\n    elseif v ~= \"ok\" then queueEvent(\"bus_\" .. ch, BUS_CLASS, { problem = true, line = \"DALI bus \" .. ch .. \": \" .. v, text = \"bus failure \" .. ch, rank = 4 })\n    elseif busMailed[ch] then queueEvent(\"bus_\" .. ch, BUS_CLASS, { problem = false, line = \"DALI bus \" .. ch .. \": ok again\", text = \"\", rank = 0 })\n    else queueEvent(\"bus_\" .. ch, BUS_CLASS, nil); busMailed[ch] = v end\n  end)\nend\nfor _, c in ipairs{ \"report\", \"heartbeat\", \"logbook\" } do\n  rule.subscribe(DASH .. \"cmd/\" .. c, function(p, v)\n    if v == nil then return end\n    sys.set(p, nil)\n    docsWanted[c] = v\n    startSending()\n  end)\nend\n\nrule.subscribe(DASH .. \"cmd/show\", function(p, v)\n  if v == nil then return end\n  sys.set(p, nil)\n  if v ~= \"attention\" and v ~= \"all\" then return end\n  if not live then return end\n  showAll = v == \"all\"\n  seedLights()\nend)\n\nrule.subscribe(DASH .. \"tester/round\", function(_, v)\n  roundKeys = {}\n  local wasRunning = roundRunning\n  roundRunning = type(v) == \"table\" and #v > 0\n  if type(v) == \"table\" then\n    for i = 1, #v do\n      local k = tostring(v[i])\n      roundKeys[k] = true\n\n      if not onScreen[k] then seedWanted = true end\n    end\n  end\n\n  if roundRunning ~= wasRunning then seedWanted = true end\nend)\nrule.subscribe(TESTS_PATH .. \"summary\", function(_, v)\n  if type(v) ~= \"string\" or v == \"\" or v == summarySent then return end\n  if summaryQueue[#summaryQueue] == v then return end\n\n  if live then repairTestColumns() end\n  if #summaryQueue >= 4 then table.remove(summaryQueue, 1) end\n  summaryQueue[#summaryQueue + 1] = v\n  docsWanted.summary = true\n  startSending()\nend)\n\nsys.set(DASH .. \"ui/ui\", 1)\nsys.set(DASH .. \"ui/title\", \"Emergency lighting\")\npublishUi()\npublishLightsCard()\nsetStatus(\"bulk\", { run = { n = 1 } })\ntoday, nextHeartbeatAt = dateOf(startedAt), nextHeartbeat(startedAt)\nrecount()\nsetStatus(\"supervised\", #keys)\nsetStatus(\"faults\", counts[2]); setStyle(\"faultStyle\", counts[2] > 0 and \"alarm\" or \"ok\")\nsetStatus(\"held\", 0); setStatus(\"mailError\", \"none\"); setStyle(\"mailStyle\", \"ok\")\nsetStatus(\"bus\", busWords()); setStyle(\"busStyle\", busStyleWord())\nsetStatus(\"lastReport\", lastReportAt); setStatus(\"nextHeartbeat\", nextHeartbeatAt)\nsetStatus(\"running\", #keys == 0 and \"no emergency luminaires found\" or \"idle\"); setStatus(\"lastLogbook\", nil)\nsys.set(DASH .. \"lock\", false)\nseedLights()\nlive = true\n\nrule.setInterval(function()\n  local now = os.time()\n  for i = 1, #keys do local l = lights[keys[i]]; if not l.online and l.intended & OFFLINE_BIT == 0 then checkOffline(l, now) end end\n  local day = dateOf(now)\n  if day ~= today then\n    today = day\n    publishUi()\n\n    if os.date(\"!%Y-%m\", now) ~= seededMonth then seedWanted = true end\n  end\n  if #keys > 0 then\n    if sMonthlyReport and os.date(\"!%Y-%m\", now) ~= os.date(\"!%Y-%m\", lastReportAt or firstStartAt) then docsWanted.report = true end\n    if nextHeartbeatAt and now >= nextHeartbeatAt then docsWanted.heartbeat = true end\n  end\n  startSending()\n  if #pending > 0 and not flushTimer then flushAlarms() end\n  if seedWanted then seedLights() end\nend, 60000)\n\nprint(\"[watcher] ready | \" .. #keys .. \" luminaires | init \" .. math.floor((os.clock() - T0) * 1000) .. \"ms\")\n",
      "slow": true,
      "type": "lua",
      "ui": {
        "description": "Monitors every DT1 luminaire on the bus. Sends alarm emails, a monthly status report with CSV, heartbeat emails, and logbook exports for a selected period. Does not send commands to the luminaires. Settings take effect after save and restart. Empty fields use the default value. The 'Emergency lighting' page appears in the menu after the restart; anyone logged in at that moment has to log out and back in once to see it.",
        "settings": [
          {
            "description": "Email addresses that receive alarms, the heartbeat and test result emails. Separate multiple addresses with commas. Leave empty to use the controller's default recipients.",
            "key": "mailTo",
            "name": "Alarm recipients",
            "placeholder": "system default list",
            "type": "text"
          },
          {
            "description": "Email addresses that receive the monthly status report and logbook exports. Separate multiple addresses with commas. Leave empty to send them to the alarm recipients above.",
            "key": "reportTo",
            "name": "Report recipients",
            "placeholder": "same as alarm recipients",
            "type": "text"
          },
          {
            "default": 15,
            "description": "How long a luminaire may stop responding before it is reported offline. No offline alarm is sent while the DALI channel is down.",
            "key": "offlineMinutes",
            "max": 1440,
            "min": 1,
            "name": "Offline after",
            "type": "number",
            "unit": "min"
          },
          {
            "default": 60,
            "description": "Events that occur within this time are combined into one email.",
            "key": "batchSeconds",
            "max": 3600,
            "min": 5,
            "name": "Collect events for",
            "type": "number",
            "unit": "s"
          },
          {
            "default": 6,
            "description": "Maximum number of alarm emails that can be sent per hour. Extra alarms wait and are skipped if they clear before they can be sent.",
            "key": "maxEmailsPerHour",
            "max": 60,
            "min": 1,
            "name": "Max alarm emails per hour",
            "type": "number"
          },
          {
            "description": "Addresses that are not monitored. Separate multiple addresses with commas or semicolons.",
            "key": "exclude",
            "name": "Exclude",
            "placeholder": "ch2/9, ch2/12",
            "type": "text"
          },
          {
            "default": 5,
            "description": "Hour (UTC) when the heartbeat email is sent.",
            "key": "heartbeatHour",
            "max": 23,
            "min": 0,
            "name": "Heartbeat hour",
            "type": "number",
            "unit": "UTC"
          },
          {
            "default": "daily",
            "description": "How often the heartbeat email is sent: every day, once a week on the selected weekday, or never. Its arrival proves the watcher itself is alive.",
            "key": "heartbeatDays",
            "name": "Heartbeat days",
            "options": [
              {
                "label": "Daily",
                "value": "daily"
              },
              {
                "label": "Monday",
                "value": "monday"
              },
              {
                "label": "Tuesday",
                "value": "tuesday"
              },
              {
                "label": "Wednesday",
                "value": "wednesday"
              },
              {
                "label": "Thursday",
                "value": "thursday"
              },
              {
                "label": "Friday",
                "value": "friday"
              },
              {
                "label": "Saturday",
                "value": "saturday"
              },
              {
                "label": "Sunday",
                "value": "sunday"
              },
              {
                "label": "Off",
                "value": "off"
              }
            ],
            "type": "select"
          },
          {
            "default": "on",
            "description": "Send a monthly status report with a CSV file. The 'Send report now' button works even when this is off.",
            "key": "monthlyReport",
            "name": "Monthly report",
            "options": [
              {
                "label": "On",
                "value": "on"
              },
              {
                "label": "Off",
                "value": "off"
              }
            ],
            "type": "select"
          }
        ],
        "title": "Emergency lighting watcher",
        "ui": 1
      }
    },
    "enabled": true,
    "id": 34,
    "name": "DT1 watcher v1.0.0"
  }
}