{
  "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 RULE_ID = rule.id\n\nlocal DASH_ID = \"emergency\"\nlocal DASH = \"/dashboards/status/\" .. DASH_ID .. \"/\"\nlocal ME = DASH .. \"tester/\"\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 TESTS_PATH = \"/persist/dt1_tests/\"\nlocal GROUPS = 4\nlocal BATCH = 4\n\nlocal BATCH_MS = 1000\nlocal HANDSHAKE_MS = 10000\nlocal HANDSHAKE_READS = 3\nlocal HANDSHAKE_SENDS = 2\nlocal FT_TIMEOUT_MIN = 15\nlocal POLL_MS = 60000\nlocal DT_POLL_MS = 300000\n\nlocal FT_KEEP_MONTHS = 24\nlocal DT_KEEP_YEARS = 10\nlocal NAMES_SHOWN = 20\nlocal NO_STATUS_ASKS = 10\nlocal CMD_RESET_FT = \"resetFunctionTestFlag\"\nlocal CMD_RESET_DT = \"resetDurationTestFlag\"\nlocal CMD_START_FT = \"startFunctionTest\"\nlocal CMD_START_DT = \"startDurationTest\"\nlocal CMD_STOP = \"stopTest\"\n\nlocal num = tonumber\nlocal settings = rule.settings\n\nlocal function settingNumber(name, lo, hi, default, whole)\n  local raw = settings[name]\n  if raw == nil or raw == \"\" then return default end\n  local n = num(raw)\n  if not n then\n    rule.error(\"settings: cannot read \" .. name .. \" '\" .. tostring(raw) .. \"', using \" .. default)\n    return default\n  end\n  if whole then n = math.floor(n) end\n  local clamped = math.max(lo, math.min(hi, n))\n  if clamped ~= n then\n    rule.error(\"settings: \" .. name .. \" \" .. n .. \" is outside \" .. lo .. \"..\" .. hi .. \", using \" .. clamped)\n  end\n  return clamped\nend\n\nlocal function settingSelect(name)\n  local raw = settings[name]\n  if raw == nil or raw == \"\" then return true end\n  if raw == \"on\" then return true end\n  if raw == \"off\" then return false end\n  rule.error(\"settings: cannot read \" .. name .. \" '\" .. tostring(raw) .. \"', treating it as off\")\n  return false\nend\n\nlocal sTestHour = settingNumber(\"testHour\", 0, 23, 1, true)\nlocal sFtDay = settingNumber(\"ftDay\", 1, 28, 1, true)\nlocal sDtMonth = settingNumber(\"dtMonth\", 1, 12, 1, true)\nlocal sFtGapMinutes = settingNumber(\"ftGapMinutes\", 0, 720, 10)\nlocal sDtTimeoutHours = settingNumber(\"dtTimeoutHours\", 1, 12, 4)\nlocal sDtGapHours = settingNumber(\"dtGapHours\", 0, 168, 20)\nlocal sPostponeHours = settingNumber(\"postponeHours\", 1, 72, 24)\nlocal sFunctionTest = settingSelect(\"functionTest\")\nlocal sDurationTest = settingSelect(\"durationTest\")\nlocal sExclude = tostring(settings.exclude or \"\")\n\nlocal lights = {}\nlocal keys = {}\nlocal allKeys = {}\nlocal excluded = {}\nfor tok in sExclude:gmatch(\"[^,;]+\") do\n  tok = tok:gsub(\"%s+\", \"\"):lower()\n  local c, a = tok:match(\"^ch([12])/(%d+)$\")\n  if c then\n    excluded[c .. \"_\" .. num(a)] = tok\n  elseif tok ~= \"\" then\n    rule.error(\"exclude: cannot read '\" .. tok .. \"', expected ch1/15\")\n  end\nend\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    for a, g in pairs(conf.gear) do\n      if type(g) == \"table\" and num(g.type) == 1 then\n        local k = c .. \"_\" .. a\n        allKeys[#allKeys + 1] = k\n        if excluded[k] then\n          excluded[k] = nil\n        else\n          lights[k] = { key = k, channel = ch, addr = a,\n            name = (g.name and g.name ~= \"\") and g.name or ch .. \"/\" .. a,\n            base = \"/dali/status/\" .. ch .. \"/gear/\" .. a }\n        end\n      end\n    end\n  end\nend\nfor _, tok in pairs(excluded) do rule.error(\"exclude: no emergency luminaire at \" .. tok) end\n\nlocal function byAddress(x, y)\n  if x:sub(1, 1) ~= y:sub(1, 1) then return x:sub(1, 1) < y:sub(1, 1) end\n  local nx = num(x:sub(3))\n  local ny = num(y:sub(3))\n  if nx and ny then return nx < ny end\n  if nx then return true end\n  if ny then return false end\n  return x < y\nend\ntable.sort(allKeys, byAddress)\nlocal order = {}\nfor i = 1, #allKeys do\n  local k = allKeys[i]\n  order[k] = i\n  if lights[k] then keys[#keys + 1] = k end\nend\n\nlocal function byOrder(x, y) return order[x] < order[y] end\n\nlocal function nameList(names)\n  if #names <= NAMES_SHOWN then return table.concat(names, \"; \") end\n  local shown = {}\n  for i = 1, NAMES_SHOWN do shown[i] = names[i] end\n  return table.concat(shown, \"; \") .. \"; and \" .. (#names - NAMES_SHOWN) .. \" more\"\nend\n\nlocal groupOf = sys.get(TESTS_PATH .. \"g\")\nif type(groupOf) ~= \"table\" then groupOf = {} end\nlocal newGroups = {}\nlocal badPins = {}\n\nlocal reportedPins = {}\n\nlocal function pinMark(g)\n  if type(g) == \"table\" then return \"table\" end\n  return g\nend\nfor i = 1, #allKeys do\n  local k = allKeys[i]\n  local g = groupOf[k]\n  if g == nil then\n    groupOf[k] = (i % GROUPS) + 1\n    newGroups[k] = groupOf[k]\n  else\n    local n = num(g)\n    if not n or n < 1 or n > GROUPS then\n      badPins[#badPins + 1] = \"g/\" .. k .. \" = \" .. tostring(g)\n      reportedPins[k] = pinMark(g)\n    end\n  end\nend\nif #badPins > 0 then\n\n  rule.error(\"group pins the round cannot use (tested with group 1): \" .. nameList(badPins))\nend\nif next(newGroups) then sys.set(TESTS_PATH .. \"g\", newGroups) end\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 startedAt = os.time()\nlocal ftPeriod = nil\nlocal dtPeriod = nil\nlocal ftCovSet = {}\nlocal ftCovCount = 0\nlocal dtCovSet = {}\nlocal dtCovCount = 0\nlocal ftLastT = nil\nlocal dtLastT = nil\nlocal dtLastGroup = 0\nlocal ftPeriodFloor = 0\nlocal dtPeriodFloor = 0\n\nlocal function scanPeriod(kind, period)\n  local set = {}\n  local count = 0\n  local lastT = nil\n  local lastKey = nil\n  local strays = {}\n  local tree = sys.get(TESTS_PATH .. kind .. \"/\" .. period)\n  if type(tree) == \"table\" then\n    for k, tests in pairs(tree) do\n      if type(tests) == \"table\" and lights[k] then\n        local any = false\n        for stamp, e in pairs(tests) do\n          if type(e) ~= \"table\" then\n\n            strays[#strays + 1] = k .. \"/\" .. tostring(stamp)\n          else\n            any = true\n            if e.x ~= \"m\" then\n              local t = num(stamp)\n              if t and (not lastT or t > lastT) then\n                lastT = t\n                lastKey = k\n              end\n            end\n          end\n        end\n        if any then\n          set[k] = true\n          count = count + 1\n        end\n      end\n    end\n  end\n  if #strays > 0 then\n\n    rule.error(\"archive: \" .. kind .. \"/\" .. period .. \": not entries, not counted: \" .. nameList(strays))\n  end\n  return set, count, lastT, lastKey\nend\n\nlocal function loadCovered(kind)\n  local period = kind == \"ft\" and ftPeriod or dtPeriod\n  local set, count, lastT, lastKey = scanPeriod(kind, period)\n  local floor = num(sys.get(TESTS_PATH .. \"periods/\" .. kind .. \"/\" .. period)) or 0\n  if kind == \"ft\" then\n    ftCovSet, ftCovCount = set, count\n    ftPeriodFloor = floor\n    if lastT then ftLastT = lastT end\n  else\n    dtCovSet, dtCovCount = set, count\n    dtPeriodFloor = floor\n    if lastT then\n      dtLastT = lastT\n      dtLastGroup = groupFor(lastKey)\n    end\n  end\nend\n\nlocal d0 = os.date(\"!*t\", startedAt)\nftPeriod = string.format(\"%04d-%02d\", d0.year, d0.month)\ndtPeriod = string.format(\"%04d\", d0.year)\nloadCovered(\"ft\")\nloadCovered(\"dt\")\n\nif not dtLastT and d0.month == 1 then\n  local _, _, lastT, lastKey = scanPeriod(\"dt\", string.format(\"%04d\", d0.year - 1))\n  if lastT then\n    dtLastT = lastT\n    dtLastGroup = groupFor(lastKey)\n  end\nend\n\nlocal function setStatus(p, v) sys.set(ME .. p, v) end\nlocal function dateOf(t) return os.date(\"!%Y-%m-%d\", t) end\nlocal function stampOf(t) return os.date(\"!%Y-%m-%d %H:%M\", t) end\nlocal function addressOf(k) return \"ch\" .. (k:gsub(\"_\", \"/\")) end\n\nlocal function utcTime(d) return os.time(d) end\n\nlocal function missingKeys(kind)\n  local set = kind == \"ft\" and ftCovSet or dtCovSet\n  local r = {}\n  for i = 1, #keys do\n    local k = keys[i]\n    if not set[k] then r[#r + 1] = k end\n  end\n  return r\nend\n\nlocal function coveredWords(count) return count .. \" of \" .. #keys end\nlocal function kindWords(kind) return kind == \"ft\" and \"function test\" or \"duration test\" end\nlocal function periodWords(kind) return kind == \"ft\" and \"this month\" or \"this year\" end\n\nlocal function saneEntry(e)\n  if type(e) ~= \"table\" then return nil end\n  local v = {}\n  v.t = num(e.t)\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  local m = num(e.m)\n  local r2 = num(e.r)\n  if m then v.m = math.floor(m) end\n  if r2 then v.r = math.floor(r2) end\n  if e.s == true then v.s = true end\n  return v\nend\n\nlocal function minutesWords(e)\n  if e.s then return e.m .. \"+\" end\n  return tostring(e.m)\nend\n\nlocal function entryWords(kind, e)\n  e = saneEntry(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 .. \" \" .. minutesWords(e) .. \" min\" end\n  w = w .. \" \" .. (e.t and dateOf(e.t) or \"?\")\n  if e.f then w = w .. \" — \" .. e.f end\n  return w\nend\n\nlocal function lastResultWords(kind, l, e)\n  e = saneEntry(e) or {}\n  local head = (e.o or \"?\") .. \", \" .. l.name .. \" (\" .. addressOf(l.key) .. \")\"\n  if kind == \"ft\" then\n    return head .. \", function test \" .. (e.m or \"?\") .. \" min\"\n  end\n  local w = head .. \", duration \" .. (e.m and minutesWords(e) or \"?\") .. \" min\"\n  if e.r then w = w .. \" of \" .. e.r end\n  return w\nend\n\nlocal onScreen = {}\nlocal lockPending = false\n\nlocal function readRows()\n  local rows = sys.get(DASH .. \"lights\")\n  if type(rows) ~= \"table\" then return nil end\n  local set = {}\n  for k in pairs(rows) do set[k] = true end\n  onScreen = set\n  return rows\nend\n\nlocal RESEED_MS = 300\nlocal RESEED_TRIES = 4\n\nlocal function withRows(fn, giveUp, tries)\n  local rows = readRows()\n  if rows then\n    fn(rows)\n    return\n  end\n  tries = (tries or RESEED_TRIES) - 1\n  if tries <= 0 then\n    if giveUp then giveUp() end\n    return\n  end\n  rule.setTimeout(function() withRows(fn, giveUp, tries) end, RESEED_MS)\nend\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 missedWords = { ft = {}, dt = {} }\nlocal rowStyleShown = {}\nlocal function setRowWords(kind, k, words)\n  if not setRow(k, kind, words) then\n    missedWords[kind][k] = words\n    return\n  end\n  missedWords[kind][k] = nil\n  local style = (kind == \"ft\" and ftCovSet or dtCovSet)[k] and \"ok\" or \"warn\"\n  if rowStyleShown[kind .. k] ~= style then\n    rowStyleShown[kind .. k] = style\n    setRow(k, kind .. \"Style\", style)\n  end\nend\n\nlocal function restyleRows(kinds)\n  withRows(function(rowsNow)\n    for k in pairs(rowsNow) do\n      if lights[k] then\n        for i = 1, #kinds do\n          local kind = kinds[i]\n          local style = (kind == \"ft\" and ftCovSet or dtCovSet)[k] and \"ok\" or \"warn\"\n          rowStyleShown[kind .. k] = style\n          setRow(k, kind .. \"Style\", style)\n        end\n      end\n    end\n  end)\nend\n\nlocal function greyRows(rowsNow)\n  for k in pairs(rowsNow) do\n    if lights[k] then setRow(k, \"lock\", true) end\n  end\nend\n\nlocal function ungreyRows(rowsNow)\n  for k in pairs(rowsNow) do\n    setRow(k, \"lock\", false)\n  end\nend\n\nlocal cardStyleShown = {}\nlocal function setCardStyle(leaf, style)\n  if cardStyleShown[leaf] == style then return end\n  cardStyleShown[leaf] = style\n  setStatus(leaf, style)\nend\n\nlocal function setCovered(kind, count)\n  setStatus(kind == \"ft\" and \"ftCovered\" or \"dtCovered\", coveredWords(count))\n  setCardStyle(kind .. \"Style\", count >= #keys and \"ok\" or \"warn\")\nend\n\nlocal LAST_STYLE = { pass = \"ok\", timeout = \"warn\", fail = \"alarm\" }\nlocal function setLastResult(kind, l, e)\n  setStatus(\"lastResult\", lastResultWords(kind, l, e))\n  local style = type(e) == \"table\" and LAST_STYLE[e.o] or nil\n  if style then setCardStyle(\"lastStyle\", style) end\nend\n\nlocal ratedOf = {}\nlocal ratedShown = nil\nlocal function noteRated(k, minutes)\n  local m = num(minutes)\n  if not m then return end\n  ratedOf[k] = math.floor(m)\nend\nlocal function setRated()\n  local agreed = nil\n  for _, m in pairs(ratedOf) do\n    if agreed and m ~= agreed then\n      agreed = nil\n      break\n    end\n    agreed = m\n  end\n  local secs = agreed and agreed * 60 or nil\n  if secs == ratedShown then return end\n  ratedShown = secs\n  setStatus(\"rated\", secs)\nend\n\nlocal KEY_ONLINE = \"online\"\nlocal KEY_EM = \"dt1EmergencyMode\"\nlocal KEY_ES = \"dt1EmergencyStatus\"\nlocal KEY_FS = \"dt1FailureStatus\"\n\nlocal EM_FT_RUNNING = 1 << 4\nlocal EM_DT_RUNNING = 1 << 5\nlocal ES_FT_DONE = 1 << 1\nlocal ES_DT_DONE = 1 << 2\nlocal ES_CHARGED = 1 << 3\nlocal ES_FT_PENDING = 1 << 4\nlocal ES_DT_PENDING = 1 << 5\nlocal FS_FT_FAILED = 1 << 6\nlocal FS_DT_FAILED = 1 << 7\n\nlocal FAULT_WORDS = { \"circuit failure\", \"battery duration failure\", \"battery failure\", \"lamp failure\" }\n\nlocal function faultWords(mask)\n  local m = num(mask)\n  if not m or m == 0 then return nil end\n  m = math.floor(m)\n  local r = {}\n  for i = 1, #FAULT_WORDS do\n    if m & (1 << (i - 1)) ~= 0 then r[#r + 1] = FAULT_WORDS[i] end\n  end\n  if #r == 0 then return nil end\n  return table.concat(r, \", \")\nend\n\nlocal function maskBit(mask, bit)\n  if mask == nil then return nil end\n  return (mask & bit) ~= 0\nend\n\nlocal function gearView(st, kind)\n  if type(st) ~= \"table\" then return {} end\n  local em = num(st[KEY_EM])\n  local es = num(st[KEY_ES])\n  local fs = num(st[KEY_FS])\n  if em then em = math.floor(em) end\n  if es then es = math.floor(es) end\n  if fs then fs = math.floor(fs) end\n\n  if st[KEY_ONLINE] == nil and not em and not es then return {} end\n  local v = {}\n  v.online = st[KEY_ONLINE]\n  if em then v.anyTestRunning = (em & (EM_FT_RUNNING | EM_DT_RUNNING)) ~= 0 end\n  v.charged = maskBit(es, ES_CHARGED)\n  v.fault = faultWords(fs)\n  if kind == \"ft\" then\n    v.running = maskBit(em, EM_FT_RUNNING)\n    v.pending = maskBit(es, ES_FT_PENDING)\n    v.done = maskBit(es, ES_FT_DONE)\n    v.failed = maskBit(fs, FS_FT_FAILED)\n  else\n    v.running = maskBit(em, EM_DT_RUNNING)\n    v.pending = maskBit(es, ES_DT_PENDING)\n    v.done = maskBit(es, ES_DT_DONE)\n    v.failed = maskBit(fs, FS_DT_FAILED)\n\n    local steps = st.dt1DurationTestResult\n    if type(steps) == \"number\" then\n      steps = math.floor(steps)\n      if steps >= 255 then\n        v.durationMin = 510\n        v.durationSat = true\n      else\n        v.durationMin = steps * 2\n      end\n    end\n\n    if type(st.gearConfig) == \"table\" then\n      local rated = nil\n      for _, item in ipairs(st.gearConfig) do\n        if type(item) == \"table\" and item.key == \"dt1RatedDuration\" and type(item.value) == \"number\" then rated = item.value end\n      end\n      if rated and rated > 0 then v.ratedMin = math.floor(rated) * 2 end\n    end\n    local charge = st.dt1BatteryCharge\n    if type(charge) == \"number\" and charge ~= 255 then\n      v.batteryPct = math.floor(charge * 100 / 254 + 0.5)\n    end\n  end\n  v.resetProven = (v.done == false)\n  v.startProven = (v.running or v.done or v.pending) and true or false\n  v.saysPostponed = (v.running == false and v.pending) and true or false\n\n  v.clearProven = (v.pending == false)\n  return v\nend\n\nlocal function readFlags(base)\n  return {\n    [KEY_ONLINE] = sys.get(base .. \"/\" .. KEY_ONLINE),\n    [KEY_EM] = sys.get(base .. \"/\" .. KEY_EM),\n    [KEY_ES] = sys.get(base .. \"/\" .. KEY_ES),\n  }\nend\n\nlocal function readVerdict(base, kind, flags)\n  local st = flags or readFlags(base)\n  st[KEY_FS] = sys.get(base .. \"/\" .. KEY_FS)\n  if kind == \"dt\" then\n    st.dt1DurationTestResult = sys.get(base .. \"/dt1DurationTestResult\")\n    st.dt1BatteryCharge = sys.get(base .. \"/dt1BatteryCharge\")\n    st.gearConfig = sys.get(base .. \"/gearConfig\")\n  end\n  return st\nend\n\nlocal round = nil\n\nlocal askedOn = {}\naskedOn.ft = sys.get(TESTS_PATH .. \"askedOn/ft\")\naskedOn.dt = sys.get(TESTS_PATH .. \"askedOn/dt\")\nlocal nextShown = nil\n\nlocal function ifSameRound(r, fn)\n  return function() if round == r then fn() end end\nend\n\nlocal startNext\nlocal continueGroup\nlocal tryAdvanceQueue\nlocal record\nlocal pollWalk\nlocal updateNext\n\nlocal function nextDtGroup(groups)\n  for i = 1, #groups do\n    if groups[i] > dtLastGroup then return groups[i] end\n  end\n  return groups[1]\nend\n\nlocal function runningWords()\n  if not round then return \"idle\" end\n  local w = kindWords(round.kind)\n  if round.queue[1] then w = w .. \", group \" .. round.queue[1].group end\n  local inFlight = round.testingCount + round.starting\n  if inFlight > 0 then w = w .. \": \" .. inFlight .. \" under test\" end\n  return w\nend\n\nlocal function concludeRound(r, now, stopped)\n  if round ~= r then return end\n  if r.pollTimer then rule.clearTimer(r.pollTimer) end\n  if r.gapTimer then rule.clearTimer(r.gapTimer) end\n  local stats = r.stats\n  if stats.total > 0 then\n\n    local head = (r.manual and \"manual \" or \"\") .. kindWords(r.kind)\n    if #stats.groups == 1 then\n      head = head .. \", group \" .. stats.groups[1]\n    else\n      head = head .. \", \" .. #stats.groups .. \" groups\"\n    end\n    local parts = {}\n    for _, o in ipairs{ \"pass\", \"fail\", \"timeout\" } do\n      if stats[o] > 0 then\n        local p = stats[o] .. \" \" .. o\n        if stats.names[o] then p = p .. \" (\" .. nameList(stats.names[o]) .. \")\" end\n        parts[#parts + 1] = p\n      end\n    end\n    sys.set(TESTS_PATH .. \"summary\", stampOf(now) .. \" UTC — \" .. head .. \": \" .. table.concat(parts, \", \"))\n  end\n  if #r.skips > 0 then\n    rule.error(\"not tested: \" .. nameList(r.skips))\n  end\n\n  if r.kind == \"dt\" and not r.manual and not stopped and stats.total == 0 and r.walkedGroup then\n    dtLastGroup = r.walkedGroup\n  end\n  round = nil\n  setStatus(\"running\", \"idle\")\n  updateNext(now, os.date(\"!*t\", now))\n\n  for _, k in ipairs(r.skippedRows) do\n    local e = sys.get(TESTS_PATH .. \"last/\" .. k .. \"/\" .. r.kind)\n    if type(e) == \"table\" then\n      setRowWords(r.kind, k, entryWords(r.kind, e))\n    end\n  end\n\n  sys.set(DASH .. \"tester/round\", nil)\n\n  withRows(function(rowsNow)\n    if round then return end\n    lockPending = false\n    ungreyRows(rowsNow)\n\n    local owed = missedWords[r.kind]\n    for k, words in pairs(owed) do\n      if onScreen[k] then\n        owed[k] = nil\n        local e = sys.get(TESTS_PATH .. \"last/\" .. k .. \"/\" .. r.kind)\n        if type(e) == \"table\" then words = entryWords(r.kind, e) end\n        setRowWords(r.kind, k, words)\n      end\n    end\n  end, function()\n    lockPending = true\n  end)\nend\n\ntryAdvanceQueue = function(r)\n  if round ~= r then return end\n  if r.starting > 0 or r.testingCount > 0 then return end\n  local grp = r.queue[1]\n  if grp and not grp.dispatched then return end\n  if grp then table.remove(r.queue, 1) end\n  if r.queue[1] then\n    local gapMs = r.kind == \"ft\" and math.max(50, sFtGapMinutes * 60000) or 50\n    setStatus(\"running\", kindWords(r.kind) .. \": waiting before group \" .. r.queue[1].group)\n    r.gapTimer = rule.setTimeout(ifSameRound(r, function() startNext(r) end), gapMs)\n  else\n    concludeRound(r, os.time())\n  end\nend\n\nlocal CARD_WHY = {\n  [\"no status\"] = \"no data\",\n  [\"battery not full\"] = \"battery charging\",\n  [\"postponed by the gear\"] = \"battery not ready\",\n  [\"reset did not take\"] = \"no response\",\n  [\"clear did not take\"] = \"no response\",\n  [\"start did not take\"] = \"no response\",\n}\n\nlocal function skip(r, l, why)\n  r.skips[#r.skips + 1] = l.name .. \" (\" .. addressOf(l.key) .. \"): \" .. why\n  r.skippedRows[#r.skippedRows + 1] = l.key\n  r.inHand[l.key] = nil\n  setRowWords(r.kind, l.key, \"skipped (\" .. (CARD_WHY[why] or why) .. \")\")\nend\n\nlocal function gateReason(r, v, phase)\n\n  if v.online == false then return \"offline\" end\n  if phase == \"reset\" then\n\n    if v.anyTestRunning == nil then return \"no status\" end\n\n    if v.anyTestRunning then return \"busy\" end\n\n    if r.kind == \"dt\" and v.charged == false then return \"battery not full\" end\n  end\n  return nil\nend\n\nlocal function handshakeResolved(r)\n  r.starting = r.starting - 1\n  if r.starting == 0 then continueGroup(r) end\nend\n\nlocal function letGo(r, pend, why)\n  skip(r, pend.l, why)\n  handshakeResolved(r)\nend\n\nlocal function commandTaken(r, portion, spec)\n  local waiting = portion\n  local survivors = {}\n  local sendsLeft = HANDSHAKE_SENDS\n  local readsLeft = 0\n  local sendAndWait\n  local askAndCheck\n\n  askAndCheck = function()\n    if readsLeft == 0 then\n      if sendsLeft > 0 then\n        sendAndWait()\n        return\n      end\n      for i = 1, #waiting do\n        local pend = waiting[i]\n        if spec.giveUp then spec.giveUp(pend) end\n        letGo(r, pend, spec.why)\n      end\n      waiting = {}\n      if spec.done then spec.done(survivors) end\n      return\n    end\n    readsLeft = readsLeft - 1\n    for i = 1, #waiting do\n      sys.set(waiting[i].l.base .. \"/readDtStatus\", true, true)\n    end\n    rule.setTimeout(ifSameRound(r, function()\n      local left = {}\n      for i = 1, #waiting do\n        local pend = waiting[i]\n        local v = gearView(readFlags(pend.l.base), r.kind)\n        local why = gateReason(r, v, spec.phase)\n        if why then\n\n          if spec.giveUp then spec.giveUp(pend) end\n          letGo(r, pend, why)\n        elseif spec.proof(v) then\n          survivors[#survivors + 1] = pend\n          if spec.onProven then spec.onProven(pend, v) end\n        else\n          left[#left + 1] = pend\n        end\n      end\n      waiting = left\n      if #waiting == 0 then\n        if spec.done then spec.done(survivors) end\n        return\n      end\n      askAndCheck()\n    end), HANDSHAKE_MS)\n  end\n\n  sendAndWait = function()\n    sendsLeft = sendsLeft - 1\n    readsLeft = HANDSHAKE_READS\n    for i = 1, #waiting do\n      sys.set(waiting[i].l.base .. \"/dtCommand\", spec.cmd, true)\n    end\n    rule.setTimeout(ifSameRound(r, askAndCheck), HANDSHAKE_MS)\n  end\n\n  sendAndWait()\nend\n\nlocal function startHalf(r, portion)\n  local cmd = r.kind == \"ft\" and CMD_START_FT or CMD_START_DT\n  local timeoutSec = r.kind == \"ft\" and FT_TIMEOUT_MIN * 60 or sDtTimeoutHours * 3600\n  local now = os.time()\n  for i = 1, #portion do\n    portion[i].startedAt = now\n    portion[i].deadline = now + timeoutSec\n  end\n  commandTaken(r, portion, {\n    phase = \"start\",\n    cmd = cmd,\n    proof = function(v) return v.startProven end,\n    why = \"start did not take\",\n    giveUp = function(pend)\n\n      sys.set(pend.l.base .. \"/dtCommand\", CMD_STOP, true)\n    end,\n    onProven = function(pend, v)\n\n      r.testing[pend.l.key] = { l = pend.l, startedAt = pend.startedAt, deadline = pend.deadline,\n                                everRan = (v.running or v.done) and true or nil }\n      r.testingCount = r.testingCount + 1\n      setRowWords(r.kind, pend.l.key, \"testing\")\n      handshakeResolved(r)\n    end,\n    done = function(survivors)\n      if #survivors > 0 then setStatus(\"running\", runningWords()) end\n    end,\n  })\nend\n\nlocal function clearHalf(r, portion)\n  commandTaken(r, portion, {\n    phase = \"reset\",\n    cmd = CMD_STOP,\n    proof = function(v) return v.clearProven end,\n    why = \"clear did not take\",\n    done = function(survivors)\n      if #survivors == 0 then return end\n      startHalf(r, survivors)\n    end,\n  })\nend\n\nlocal function handshake(r, portion)\n  commandTaken(r, portion, {\n    phase = \"reset\",\n    cmd = r.kind == \"ft\" and CMD_RESET_FT or CMD_RESET_DT,\n    proof = function(v) return v.resetProven end,\n    why = \"reset did not take\",\n    done = function(survivors)\n      if #survivors == 0 then return end\n      clearHalf(r, survivors)\n    end,\n  })\nend\n\ncontinueGroup = function(r)\n  local grp = r.queue[1]\n  if not grp then\n    tryAdvanceQueue(r)\n    return\n  end\n  if #grp.keys > 0 then\n    rule.setTimeout(ifSameRound(r, function() startNext(r) end), BATCH_MS)\n  elseif #grp.waiting > 0 then\n    grp.keys, grp.waiting = grp.waiting, {}\n    rule.setTimeout(ifSameRound(r, function() startNext(r) end), 60000)\n  else\n    grp.dispatched = true\n    tryAdvanceQueue(r)\n  end\nend\n\nstartNext = function(r)\n  if round ~= r then return end\n  local grp = r.queue[1]\n  if not grp then\n    tryAdvanceQueue(r)\n    return\n  end\n  r.walkedGroup = grp.group\n  local armed = {}\n  local handled = 0\n  while handled < BATCH and #grp.keys > 0 do\n    local k = table.remove(grp.keys, 1)\n    handled = handled + 1\n    local l = lights[k]\n    local v = gearView(readFlags(l.base), r.kind)\n    local why = gateReason(r, v, \"reset\")\n    if why == \"no status\" then\n\n      r.asks[k] = (r.asks[k] or 0) + 1\n      if r.asks[k] <= NO_STATUS_ASKS then\n        grp.waiting[#grp.waiting + 1] = k\n      else\n        skip(r, l, \"no status\")\n      end\n    elseif why then\n      skip(r, l, why)\n    else\n\n      if r.kind == \"dt\" then sys.set(l.base .. \"/readGearConfig\", true, true) end\n      armed[#armed + 1] = { l = l }\n      r.inHand[k] = true\n      setRowWords(r.kind, k, \"starting\")\n    end\n  end\n  if #armed > 0 then\n    r.starting = r.starting + #armed\n    if not r.stats.seen[grp.group] then\n      r.stats.seen[grp.group] = true\n      r.stats.groups[#r.stats.groups + 1] = grp.group\n    end\n    setStatus(\"running\", runningWords())\n    handshake(r, armed)\n  else\n    continueGroup(r)\n  end\nend\n\nlocal function judgeOne(r, rec, v, now)\n\n  if v.running or v.done then rec.everRan = true end\n  if v.done then\n\n    if v.failed == true then return \"fail\" end\n    if v.failed == false then return \"pass\" end\n  end\n  if r.kind == \"dt\" then\n    if v.saysPostponed then\n\n      rec.deadline = rec.startedAt + sPostponeHours * 3600 + sDtTimeoutHours * 3600\n      if rec.shown ~= \"postponed\" then\n        rec.shown = \"postponed\"\n        setRowWords(\"dt\", rec.l.key, \"postponed, waiting for the battery\")\n      end\n    elseif rec.shown == \"postponed\" and v.running then\n\n      rec.deadline = math.min(rec.deadline, now + sDtTimeoutHours * 3600)\n      rec.shown = \"testing\"\n      setRowWords(\"dt\", rec.l.key, \"testing\")\n    end\n  end\n  if now >= rec.deadline then\n    sys.set(rec.l.base .. \"/dtCommand\", CMD_STOP, true)\n    if not rec.everRan then\n\n      return \"notrun\"\n    end\n    return \"timeout\"\n  end\n  return nil\nend\n\nlocal function poll(r)\n  if round ~= r or r.polling or r.testingCount == 0 then return end\n  local list = {}\n  for k in pairs(r.testing) do list[#list + 1] = k end\n  table.sort(list, byOrder)\n  r.polling = true\n  pollWalk(r, list, 1)\nend\n\npollWalk = function(r, list, i)\n  if round ~= r then\n    r.polling = false\n    return\n  end\n  local now = os.time()\n  local concluded = {}\n  local letGoCount = 0\n  local n = 0\n  while n < BATCH and i <= #list do\n    local k = list[i]\n    i = i + 1\n    n = n + 1\n    local rec = r.testing[k]\n    if rec then\n\n      local flags = readFlags(rec.l.base)\n      local v = gearView(flags, r.kind)\n      local justAsked = false\n      if v.done and now < rec.deadline and not rec.freshAsked then\n\n        rec.freshAsked = true\n        justAsked = true\n        sys.set(rec.l.base .. \"/readDtStatus\", true, true)\n      elseif v.done or now >= rec.deadline then\n        v = gearView(readVerdict(rec.l.base, r.kind, flags), r.kind)\n      end\n      local o = judgeOne(r, rec, v, now)\n      if o == \"notrun\" then\n        r.testing[k] = nil\n        r.testingCount = r.testingCount - 1\n\n        skip(r, rec.l, r.kind == \"dt\" and \"postponed by the gear\" or \"did not run\")\n        letGoCount = letGoCount + 1\n      elseif o then\n        concluded[#concluded + 1] = { rec = rec, view = v, o = o }\n      elseif r.kind == \"ft\" and not justAsked then\n\n        sys.set(rec.l.base .. \"/readDtStatus\", true, true)\n      end\n    end\n  end\n\n  if #concluded > 0 then record(r, concluded) end\n  if letGoCount > 0 and #concluded == 0 then tryAdvanceQueue(r) end\n  if i <= #list then\n    rule.setTimeout(ifSameRound(r, function() pollWalk(r, list, i) end), BATCH_MS)\n  else\n    r.polling = false\n  end\nend\n\nrecord = function(r, concluded)\n  local now = os.time()\n  local kind = r.kind\n  local period = kind == \"ft\" and ftPeriod or dtPeriod\n  local covSet = kind == \"ft\" and ftCovSet or dtCovSet\n  local lastE = nil\n  local lastL = nil\n  for i = 1, #concluded do\n    local c = concluded[i]\n    local rec = c.rec\n    local v = c.view\n    local l = c.rec.l\n    r.testing[l.key] = nil\n    r.testingCount = r.testingCount - 1\n    local e = { t = now, o = c.o }\n    if kind == \"ft\" then\n      e.m = math.max(1, math.floor((now - rec.startedAt) / 60 + 0.5))\n    else\n      e.r = v.ratedMin\n\n      if v.done then\n        e.m = v.durationMin\n        if v.durationSat then e.s = true end\n      end\n      e.b = v.batteryPct\n      if not r.manual then\n        dtLastT = e.t\n        dtLastGroup = groupFor(l.key)\n      end\n    end\n    e.f = v.fault\n    if r.manual then e.x = \"m\" end\n    if kind == \"ft\" and not r.manual then ftLastT = e.t end\n\n    sys.set(TESTS_PATH .. kind .. \"/\" .. period .. \"/\" .. l.key .. \"/\" .. e.t, e)\n    sys.set(TESTS_PATH .. \"last/\" .. l.key .. \"/\" .. kind, nil)\n    sys.set(TESTS_PATH .. \"last/\" .. l.key .. \"/\" .. kind, e)\n    if not covSet[l.key] then\n      covSet[l.key] = true\n      if kind == \"ft\" then ftCovCount = ftCovCount + 1 else dtCovCount = dtCovCount + 1 end\n    end\n\n    r.inHand[l.key] = nil\n    setRowWords(kind, l.key, entryWords(kind, e))\n    lastE = e\n    lastL = l\n    noteRated(l.key, e.r)\n\n    r.stats.total = r.stats.total + 1\n    r.stats[e.o] = r.stats[e.o] + 1\n    if e.o ~= \"pass\" then\n      if not r.stats.names[e.o] then r.stats.names[e.o] = {} end\n      table.insert(r.stats.names[e.o], l.name .. \", \" .. addressOf(l.key))\n    end\n  end\n  setStatus(\"running\", runningWords())\n\n  local covered = kind == \"ft\" and ftCovCount or dtCovCount\n  local floor = math.max(covered, kind == \"ft\" and ftPeriodFloor or dtPeriodFloor)\n  if kind == \"ft\" then ftPeriodFloor = floor else dtPeriodFloor = floor end\n  sys.set(TESTS_PATH .. \"periods/\" .. kind .. \"/\" .. period, floor)\n  setCovered(kind, covered)\n  if lastE then setLastResult(kind, lastL, lastE) end\n  setRated()\n  tryAdvanceQueue(r)\nend\n\nlocal function refreshGroups()\n  local stored = sys.get(TESTS_PATH .. \"g\")\n\n  if type(stored) ~= \"table\" then return {} end\n  local wentBad = {}\n  local moved = {}\n  for i = 1, #allKeys do\n    local k = allKeys[i]\n    local g = stored[k]\n    if g ~= nil then\n      local was = groupFor(k)\n      groupOf[k] = g\n      if lights[k] and groupFor(k) ~= was then\n        moved[#moved + 1] = k\n      end\n    end\n    local n = num(groupOf[k])\n    local mark = pinMark(groupOf[k])\n    if n and n >= 1 and n <= GROUPS then\n      reportedPins[k] = nil\n    elseif reportedPins[k] ~= mark then\n      wentBad[#wentBad + 1] = \"g/\" .. k .. \" = \" .. tostring(groupOf[k])\n      reportedPins[k] = mark\n    end\n  end\n  if #wentBad > 0 then\n\n    rule.error(\"group pins the round cannot use (tested with group 1): \" .. nameList(wentBad))\n  end\n  return moved\nend\n\nlocal function beginRound(kind, list, manual)\n  if round then return false end\n  local moved = refreshGroups()\n  local perGroup = {}\n  for i = 1, #list do\n    local g = groupFor(list[i])\n    if not perGroup[g] then perGroup[g] = {} end\n    table.insert(perGroup[g], list[i])\n  end\n  local queue = {}\n  for g = 1, GROUPS do\n    if perGroup[g] then queue[#queue + 1] = { group = g, keys = perGroup[g], waiting = {}, dispatched = false } end\n  end\n  if #queue == 0 then return false end\n  if kind == \"dt\" and not manual and sDtGapHours > 0 then\n\n    local groups = {}\n    for i = 1, #queue do groups[#groups + 1] = queue[i].group end\n    local pick = nextDtGroup(groups)\n    for i = 1, #queue do\n      if queue[i].group == pick then queue = { queue[i] } break end\n    end\n  end\n\n  local locked = {}\n  local roundKeys = {}\n  for i = 1, #queue do\n    local grp = queue[i]\n    for j = 1, #grp.keys do\n      local k = grp.keys[j]\n      locked[k] = true\n      roundKeys[#roundKeys + 1] = k\n    end\n  end\n\n  sys.set(DASH .. \"tester/round\", roundKeys)\n  local r = {\n    kind = kind,\n    manual = manual or false,\n    queue = queue,\n    testing = {},\n    testingCount = 0,\n    starting = 0,\n    locked = locked,\n    inHand = {},\n    polling = false,\n    asks = {},\n    skips = {},\n    skippedRows = {},\n    stats = { total = 0, pass = 0, fail = 0, timeout = 0, names = {}, groups = {}, seen = {} },\n  }\n  round = r\n\n  withRows(function(rowsNow)\n    if round ~= r then return end\n    lockPending = false\n    greyRows(rowsNow)\n\n    for i = 1, #moved do setRow(moved[i], \"group\", groupFor(moved[i])) end\n  end, function()\n\n    if round == r then lockPending = true end\n  end)\n\n  setStatus(\"running\", runningWords())\n\n  r.pollTimer = rule.setInterval(function() poll(r) end, kind == \"dt\" and DT_POLL_MS or POLL_MS)\n  rule.setTimeout(ifSameRound(r, function() startNext(r) end), 50)\n  updateNext(os.time(), os.date(\"!*t\"))\n  return true\nend\n\nlocal stopWalk\nstopWalk = function(work, i, kind)\n  local n = 0\n  while n < BATCH and i <= #work do\n    local l = work[i]\n    i = i + 1\n    n = n + 1\n    if not (round and round.inHand[l.key]) then\n      sys.set(l.base .. \"/dtCommand\", CMD_STOP, true)\n    end\n    if not (round and round.locked[l.key]) then\n\n      local e = sys.get(TESTS_PATH .. \"last/\" .. l.key .. \"/\" .. kind)\n      if type(e) == \"table\" then\n        setRowWords(kind, l.key, entryWords(kind, e))\n      else\n        setRowWords(kind, l.key, \"cancelled\")\n      end\n    end\n  end\n  if i <= #work then rule.setTimeout(function() stopWalk(work, i, kind) end, BATCH_MS) end\nend\n\nlocal function stopAll()\n  local r = round\n  if not r then return 0 end\n  local n = r.testingCount + r.starting\n  local kind = r.kind\n  local work = {}\n  for k in pairs(r.inHand) do work[#work + 1] = lights[k] end\n\n  local today = dateOf(os.time())\n  askedOn.ft = today\n  askedOn.dt = today\n  sys.set(TESTS_PATH .. \"askedOn/ft\", today)\n  sys.set(TESTS_PATH .. \"askedOn/dt\", today)\n  concludeRound(r, os.time(), true)\n  if #work > 0 then stopWalk(work, 1, kind) end\n  return n\nend\n\nlocal function whenWords(t, now)\n  local day = dateOf(t)\n  local clock = os.date(\"!%H:%M\", t) .. \" UTC\"\n  if day == dateOf(now) then return \"today \" .. clock end\n  if day == dateOf(now + 86400) then return \"tomorrow \" .. clock end\n  return day .. \" \" .. clock\nend\n\nlocal function nextStartFor(kind, now, d)\n  local dayStart = now - d.hour * 3600 - d.min * 60 - d.sec\n  local today = dateOf(now)\n\n  local t0 = now\n\n  if askedOn[kind] == today or (kind == \"dt\" and ftLastT and dateOf(ftLastT) == today) then\n    t0 = dayStart + 86400\n  end\n\n  if kind == \"dt\" and dtLastT and t0 < dtLastT + sDtGapHours * 3600 then\n    t0 = dtLastT + sDtGapHours * 3600\n  end\n\n  local td = os.date(\"!*t\", t0)\n  local at = t0\n  if td.hour ~= sTestHour then\n    at = t0 - td.hour * 3600 - td.min * 60 - td.sec + sTestHour * 3600\n    if at < t0 then at = at + 86400 end\n  end\n\n  if kind == \"ft\" then\n    td = os.date(\"!*t\", at)\n    if td.day < sFtDay then at = at + (sFtDay - td.day) * 86400 end\n  end\n\n  if kind == \"dt\" then\n    td = os.date(\"!*t\", at)\n    if td.month < sDtMonth then\n      at = utcTime{ year = td.year, month = sDtMonth, day = 1, hour = sTestHour, min = 0, sec = 0 }\n    end\n  end\n\n  return at\nend\n\nupdateNext = function(now, d)\n  local at = nil\n  local kind = nil\n  local missing = nil\n  if sFunctionTest then\n    local miss = missingKeys(\"ft\")\n    if #miss > 0 then\n      at = nextStartFor(\"ft\", now, d)\n      kind = \"ft\"\n      missing = #miss\n    end\n  end\n  if sDurationTest then\n    local miss = missingKeys(\"dt\")\n    if #miss > 0 then\n      local dtAt = nextStartFor(\"dt\", now, d)\n      if not at or dtAt < at then\n        at = dtAt\n        kind = \"dt\"\n        missing = #miss\n      end\n    end\n  end\n  local words = \"nothing due\"\n  if at then\n\n    local whenW = (round and at <= now) and \"after the current round\" or whenWords(at, now)\n    words = kindWords(kind) .. \" \" .. whenW .. \", \" .. missing .. \" missing\"\n  end\n  if words ~= nextShown then\n    nextShown = words\n    setStatus(\"next\", words)\n  end\nend\n\nsys.set(DASH .. \"ui/ui\", 1)\nsys.set(DASH .. \"ui/title\", \"Emergency lighting\")\n\nlocal function publishCard()\n  sys.set(DASH .. \"ui/cards/tester\", { type = \"group\", title = \"Testing\", order = 2, span = 6, sections = {\n    { type = \"kv\", title = \"Done in this period\", rows = {\n      { name = \"Function test, \" .. ftPeriod, path = \"tester/ftCovered\", stylePath = \"tester/ftStyle\" },\n      { name = \"Duration test, \" .. dtPeriod, path = \"tester/dtCovered\", stylePath = \"tester/dtStyle\" } } },\n\n    { type = \"kv\", title = \"Now\", rows = {\n      { name = \"Testing\", path = \"tester/running\" },\n      { name = \"Next\", path = \"tester/next\" },\n      { name = \"Last result\", path = \"tester/lastResult\", stylePath = \"tester/lastStyle\" },\n      { name = \"Last action\", path = \"tester/lastPress\", stylePath = \"tester/lastPressStyle\" } } },\n\n    { type = \"kv\", title = \"How it runs\", rows = {\n      { name = \"Groups\", path = \"tester/groups\" },\n      { name = \"Rated duration\", path = \"tester/rated\", format = \"duration\" } } },\n\n    { type = \"table\", title = \"Run\", rows = \"tester/bulk\", columns = {\n\n      { name = \"Function test the ones that are missing\", cmd = \"runFunctionTest\", value = \"missing\",\n        confirm = \"Function test every luminaire with no result this month? Early in the month that is all \"\n          .. #keys .. \" of them. About 90 s each, in \" .. GROUPS .. \" groups, one group at a time with a pause between groups.\" },\n      { name = \"Function test all\", cmd = \"runFunctionTest\", value = \"all\",\n        confirm = \"Function test all \" .. #keys .. \" luminaires? About 90 s each, in \" .. GROUPS .. \" groups, one group at a time with a pause between groups.\" },\n      { name = \"Stop test\", cmd = \"stopTest\", style = \"danger\" } } } } })\nend\npublishCard()\nsetCovered(\"ft\", ftCovCount)\nsetCovered(\"dt\", dtCovCount)\nsetStatus(\"groups\", GROUPS .. \", one at a time\")\nsetStatus(\"running\", #keys == 0 and \"no emergency luminaires found\" or \"idle\")\nsetStatus(\"bulk\", { run = { n = 1 } })\n\nsys.set(DASH .. \"lock\", false)\nsetStatus(\"lastPress\", \"none yet\")\n\nlocal lastStore = sys.get(TESTS_PATH .. \"last\")\nif type(lastStore) ~= \"table\" then lastStore = {} end\nlocal newest = nil\nfor i = 1, #keys do\n  local k = keys[i]\n  local last = lastStore[k]\n  if type(last) ~= \"table\" then last = {} end\n  for _, kind in ipairs{ \"ft\", \"dt\" } do\n    local e = last[kind]\n\n    local t = type(e) == \"table\" and num(e.t) or nil\n    if t then\n      if not newest or t > newest.t then\n        newest = { kind = kind, l = lights[k], e = e, t = t }\n      end\n\n      if kind == \"dt\" then noteRated(k, e.r) end\n    end\n  end\nend\nif newest then\n  setLastResult(newest.kind, newest.l, newest.e)\nelse\n  setStatus(\"lastResult\", \"none yet\")\nend\nsetRated()\nupdateNext(startedAt, d0)\n\nlocal function press(words, style)\n  setStatus(\"lastPress\", stampOf(os.time()) .. \" UTC — \" .. words)\n  setCardStyle(\"lastPressStyle\", style)\nend\n\nlocal function keyFrom(v)\n  local c, a = v:match(\"^ch(%d+)/(%d+)$\")\n  if not c then c, a = v:match(\"^(%d+)_(%d+)$\") end\n  if c then\n    local k = c .. \"_\" .. num(a)\n    if lights[k] then return k end\n  end\n  return nil\nend\n\nlocal function runButton(kind, allowBulk)\n  local words = kindWords(kind)\n  return function(p, v)\n    if v == nil then return end\n    sys.set(p, nil)\n    if type(v) ~= \"string\" then\n      press(\"that press could not be read, so nothing was started\", \"warn\")\n      return\n    end\n    if round then\n      press(\"a \" .. kindWords(round.kind) .. \" is already running — nothing new was started\", \"warn\")\n      return\n    end\n    if allowBulk and v == \"all\" then\n      if beginRound(kind, keys, true) then\n        press(words .. \" running on all \" .. #keys .. \" luminaires, one group at a time\", \"ok\")\n      else\n        press(\"nothing to do: no emergency luminaires were found\", \"warn\")\n      end\n      return\n    end\n    if allowBulk and v == \"missing\" then\n\n      local miss = missingKeys(kind)\n      if #miss == 0 then\n        press(\"nothing to do: every luminaire has a result \" .. periodWords(kind), \"warn\")\n      elseif beginRound(kind, miss, true) then\n        press(words .. \" running on the \" .. #miss .. \" luminaires with no result \" .. periodWords(kind), \"ok\")\n      else\n        press(\"nothing to do: no emergency luminaires were found\", \"warn\")\n      end\n      return\n    end\n    local k = keyFrom(v)\n    if not k then\n      press(\"no luminaire has the address '\" .. v .. \"', so nothing was started\", \"warn\")\n      return\n    end\n    if beginRound(kind, { k }, true) then\n      press(words .. \" running on \" .. lights[k].name .. \" (\" .. addressOf(k) .. \")\", \"ok\")\n    else\n      press(\"nothing to do: no emergency luminaires were found\", \"warn\")\n    end\n  end\nend\n\nrule.subscribe(DASH .. \"cmd/runFunctionTest\", runButton(\"ft\", true))\nrule.subscribe(DASH .. \"cmd/runDurationTest\", runButton(\"dt\", false))\n\nlocal function valueWords(x)\n  if type(x) == \"number\" or type(x) == \"string\" then return tostring(x) end\n  return \"?\"\nend\n\nrule.subscribe(DASH .. \"cmd/setGroup\", function(p, v)\n  if v == nil then return end\n  sys.set(p, nil)\n  if type(v) ~= \"table\" then\n    press(\"that press could not be read, so no group was changed\", \"warn\")\n    return\n  end\n\n  if round then\n    press(\"a \" .. kindWords(round.kind) .. \" is running, so no group was changed\", \"warn\")\n    return\n  end\n  local k = nil\n  if type(v[1]) == \"string\" then k = keyFrom(v[1]) end\n  if not k then\n    press(\"no luminaire has the address '\" .. valueWords(v[1]) .. \"', so no group was changed\", \"warn\")\n    return\n  end\n  local g = num(v[2])\n  if not g or g < 1 or g > GROUPS or g ~= math.floor(g) then\n    press(\"'\" .. valueWords(v[2]) .. \"' is not a group between 1 and \" .. GROUPS .. \", so nothing was changed\", \"warn\")\n    return\n  end\n  groupOf[k] = g\n  sys.set(TESTS_PATH .. \"g\", { [k] = g })\n\n  withRows(function()\n    setRow(k, \"group\", groupFor(k))\n  end)\n  press(\"group of \" .. lights[k].name .. \" (\" .. addressOf(k) .. \") set to \" .. g, \"ok\")\nend)\n\nrule.subscribe(DASH .. \"cmd/stopTest\", function(p, v)\n  if v == nil then return end\n  sys.set(p, nil)\n  if not round then\n    press(\"nothing was running, so there was nothing to stop\", \"warn\")\n    return\n  end\n  local n = stopAll()\n\n  press(\"stopped, \" .. n .. \" tests cancelled — no more scheduled tests today\", \"ok\")\nend)\n\nlocal function pruneArchive(d)\n  local periods = sys.get(TESTS_PATH .. \"periods\")\n  if type(periods) ~= \"table\" then return end\n  local curMonths = d.year * 12 + d.month\n  local drop = {}\n  if type(periods.ft) == \"table\" then\n    for p in pairs(periods.ft) do\n      local y, m = tostring(p):match(\"^(%d+)%-(%d+)$\")\n      if y and curMonths - (num(y) * 12 + num(m)) >= FT_KEEP_MONTHS then drop[#drop + 1] = \"ft/\" .. p end\n    end\n  end\n  if type(periods.dt) == \"table\" then\n    for p in pairs(periods.dt) do\n      local y = tostring(p):match(\"^(%d+)$\")\n      if y and d.year - num(y) >= DT_KEEP_YEARS then drop[#drop + 1] = \"dt/\" .. p end\n    end\n  end\n  for i = 1, #drop do\n    local sub = drop[i]\n    rule.setTimeout(function()\n      sys.set(TESTS_PATH .. sub, nil)\n      sys.set(TESTS_PATH .. \"periods/\" .. sub, nil)\n    end, i * 1000)\n  end\nend\n\npruneArchive(d0)\n\nrule.setInterval(function()\n  local now = os.time()\n  local d = os.date(\"!*t\", now)\n  local rolled = {}\n  local pf = string.format(\"%04d-%02d\", d.year, d.month)\n  if pf ~= ftPeriod then\n    ftPeriod = pf\n    loadCovered(\"ft\")\n    setCovered(\"ft\", ftCovCount)\n    rolled[#rolled + 1] = \"ft\"\n    pruneArchive(d)\n  end\n  local pd = string.format(\"%04d\", d.year)\n  if pd ~= dtPeriod then\n    dtPeriod = pd\n    loadCovered(\"dt\")\n    setCovered(\"dt\", dtCovCount)\n    rolled[#rolled + 1] = \"dt\"\n  end\n\n  if #rolled > 0 then\n    restyleRows(rolled)\n    publishCard()\n  end\n\n  if lockPending then\n    local r = round\n    withRows(function(rowsNow)\n      if round ~= r then return end\n      lockPending = false\n      if r then greyRows(rowsNow) else ungreyRows(rowsNow) end\n    end)\n  end\n  if not round and #keys > 0 then\n    local today = dateOf(now)\n    if sFunctionTest and nextStartFor(\"ft\", now, d) <= now then\n      local miss = missingKeys(\"ft\")\n      if #miss > 0 and beginRound(\"ft\", miss, false) then\n        askedOn.ft = today\n        sys.set(TESTS_PATH .. \"askedOn/ft\", today)\n      end\n    end\n    if not round and sDurationTest and nextStartFor(\"dt\", now, d) <= now then\n      local miss = missingKeys(\"dt\")\n      if #miss > 0 and beginRound(\"dt\", miss, false) then\n        askedOn.dt = today\n        sys.set(TESTS_PATH .. \"askedOn/dt\", today)\n      end\n    end\n  end\n  updateNext(now, d)\nend, 60000)\n\nprint(\"[tester] ready | \" .. #keys .. \" luminaires | init \" .. math.floor((os.clock() - T0) * 1000) .. \"ms\")\n",
      "slow": true,
      "type": "lua",
      "ui": {
        "description": "Runs the monthly function test and the yearly duration test on every DT1 luminaire, one group at a time, and writes every result to the test archive. Sends no email: the watcher reports what this rule records. 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": [
          {
            "default": 1,
            "description": "Hour (UTC) when both test rounds run. The standard's first choice is an empty building.",
            "key": "testHour",
            "max": 23,
            "min": 0,
            "name": "Test hour",
            "type": "number",
            "unit": "UTC"
          },
          {
            "default": 1,
            "description": "Day of the month the function test round starts. From this day the round runs once a day until every luminaire has been tested that month.",
            "key": "ftDay",
            "max": 28,
            "min": 1,
            "name": "Function test from day",
            "type": "number"
          },
          {
            "default": 1,
            "description": "Month of the year the duration test campaign starts, 1 = January. From this month the round runs every day until every luminaire has been tested that year.",
            "key": "dtMonth",
            "max": 12,
            "min": 1,
            "name": "Duration test from month",
            "type": "number"
          },
          {
            "default": 10,
            "description": "Wait between one group finishing its function test and the next group starting, so the tested batteries top up while their neighbours are tested.",
            "key": "ftGapMinutes",
            "max": 720,
            "min": 0,
            "name": "Pause between function test groups",
            "type": "number",
            "unit": "min"
          },
          {
            "default": 4,
            "description": "How long a running duration test may take, counted from its own start. Set it above the luminaires' rated duration (usually 1 h or 3 h) with margin to spare: a test that has not concluded within this is stopped and recorded as a timeout, even when the luminaire was healthy.",
            "key": "dtTimeoutHours",
            "max": 12,
            "min": 1,
            "name": "Duration test timeout",
            "type": "number",
            "unit": "h"
          },
          {
            "default": 20,
            "description": "Wait between one duration test group concluding and the next group starting. 20 gives about one group a day; 0 chains the groups back to back in one round.",
            "key": "dtGapHours",
            "max": 168,
            "min": 0,
            "name": "Pause between duration test groups",
            "type": "number",
            "unit": "h"
          },
          {
            "default": 24,
            "description": "How long to keep watching a duration test the luminaire postponed because its battery was not full yet, on top of the test's own timeout. A postponed test that started and never concluded is recorded as timeout; one the luminaire never began is stopped without a record and retried the next day.",
            "key": "postponeHours",
            "max": 72,
            "min": 1,
            "name": "Watch a postponed test for",
            "type": "number",
            "unit": "h"
          },
          {
            "default": "on",
            "description": "The scheduled monthly function test round. The buttons on the card work even when this is off.",
            "key": "functionTest",
            "name": "Function test round",
            "options": [
              {
                "label": "On",
                "value": "on"
              },
              {
                "label": "Off",
                "value": "off"
              }
            ],
            "type": "select"
          },
          {
            "default": "on",
            "description": "The scheduled yearly duration test round. The buttons on the card work even when this is off.",
            "key": "durationTest",
            "name": "Duration test round",
            "options": [
              {
                "label": "On",
                "value": "on"
              },
              {
                "label": "Off",
                "value": "off"
              }
            ],
            "type": "select"
          },
          {
            "description": "Addresses that are not tested. Separate multiple addresses with commas or semicolons. An excluded luminaire keeps its archive entries and its test group.",
            "key": "exclude",
            "name": "Exclude",
            "placeholder": "ch2/9, ch2/12",
            "type": "text"
          }
        ],
        "title": "Emergency lighting tester",
        "ui": 1
      }
    },
    "enabled": true,
    "id": 35,
    "name": "DT1 tester v1.0.0"
  }
}