{
 "apiVersion": "1.3",
 "files": [
  {
   "content": "-- ==== src/version.lua ====\n-- Copyright (C) 2026 ArtiLED B.V. All Rights Reserved.\n-- ArtiLED for Fibaro Home Center 3: control ArtiLED Hub rooms, zones, lights, scenes and modes.\n-- Proprietary software of ArtiLED B.V. Do not copy, modify or distribute without written permission.\n\nQA_VERSION = '0.2.1'\n\n-- tools/pack.py --dry-run turns this on: every Hub set request is logged and nothing is sent.\nDRY_RUN = false\n\n-- The marker other software tests for (the ArtiLED Hub's Fibaro light picker, #637, must never offer\n-- these devices: that would make the Hub control itself through Fibaro). Set on the parent and on\n-- every child as properties.manufacturer and properties.model. Never change these strings.\nARTILED_MANUFACTURER = 'ArtiLED B.V.'\nARTILED_MODEL = 'ArtiLED Hub Quick App'\n\n-- tools/pack.py --name-prefix: prepended to the names of new children (dev fixtures only).\nNAME_PREFIX = ''\n\n-- ==== src/running.lua ====\n-- Copyright (C) 2026 ArtiLED B.V. All Rights Reserved.\n--\n-- Is a scene or mode running HERE. A line-for-line Lua port of the Hub's own rule,\n-- ArtiLED_Hub_API js/running-match.js (card #429). tests/test_running_parity.lua runs the same cases\n-- through that JavaScript file and through this port and requires identical answers, so the two can\n-- never drift into two different rules. Change the Hub file first, then this port.\n--\n-- The `running` flag on /scenes and /modes is house-wide by design (is_running_anywhere()), so it can\n-- never answer this. /api/running can: `fires` are the starts the Hub made, with their exact scope;\n-- `running` is what every reporting node is doing, with every light, room and zone its lights are in.\n--\n-- Three answers, never collapsed into a boolean:\n--   'on'       a Hub fire of this item in exactly this scope; or a node here that no fire speaks for\n--              reports this item\n--   'off'      nodes here report, but not this item (idle, something else, or a node a fire already\n--              speaks for); also a scene that is not Advanced: it never reports\n--   'unknown'  no node here reports at all (Hue and slave lights never do): change nothing\n--\n--   ArtiRunning.state(data, item, scope)\n--     data   {fires = {...}, running = {...}} from /api/running\n--     item   {type = 'scene'|'mode', id = Hub id, name = Hub name, reports = false for a non-Advanced scene}\n--     scope  {kind = 'house'|'room'|'zone'|'light', id = number}\n\nArtiRunning = {}\n\n-- parseInt(x, 10) as the JavaScript uses it: a number, or nil where JavaScript gives NaN.\nlocal function int(x)\n  if type(x) == 'number' then\n    return x >= 0 and math.floor(x) or -math.floor(-x)\n  end\n  if type(x) == 'string' then\n    local m = x:match('^%s*([+-]?%d+)')\n    if m then return math.tointeger(tonumber(m)) end\n  end\n  return nil\nend\nArtiRunning.int = int\n\nlocal function list(v)\n  return type(v) == 'table' and v or {}\nend\n\n-- A node a fire speaks for - a fire in ANY scope - is never matched by name as well.\nlocal function claimed(fires)\n  local out = {}\n  for _, f in ipairs(fires) do\n    for _, ident in ipairs(list(f.confirming_nodes)) do\n      out[ident] = true\n    end\n  end\n  return out\nend\n\nlocal function fire_here(f, scope)\n  local t = f.scope_type\n  if type(t) ~= 'string' or t == '' then t = 'house' end\n  if scope.kind == 'house' then\n    return t == 'house'\n  end\n  return t == scope.kind and int(f.scope_id) ~= nil and int(f.scope_id) == scope.id\nend\n\n-- Where a node reports: every light, room and zone its lights belong to (membership lists, Hub 1.79+).\nlocal function report_here(r, scope)\n  if scope.kind == 'house' then\n    return true\n  end\n  local l\n  if scope.kind == 'room' then l = r.room_ids\n  elseif scope.kind == 'zone' then l = r.zone_ids\n  else l = r.light_ids end\n  for _, id in ipairs(list(l)) do\n    if int(id) == scope.id then return true end\n  end\n  return false\nend\n\nlocal function lower(s)\n  return string.lower(tostring(s))\nend\n\nfunction ArtiRunning.state(data, item, scope)\n  data = type(data) == 'table' and data or {}\n  local fires = list(data.fires)\n  local running = list(data.running)\n  local here = { kind = scope.kind, id = int(scope.id) or 0 }\n  local id = int(item.id)\n\n  for _, f in ipairs(fires) do\n    if f.type == item.type and id ~= nil and int(f.hub_id) == id and fire_here(f, here) then\n      return 'on'\n    end\n  end\n  if item.reports == false then\n    return 'off'\n  end\n\n  local spoken_for = claimed(fires)\n  local name = lower(item.name or '')\n  local measured = false\n  for _, r in ipairs(running) do\n    if report_here(r, here) then\n      measured = true\n      local rname = r.name\n      if not spoken_for[r.ident] and r.type == item.type and type(rname) == 'string' and rname ~= ''\n          and lower(rname) == name then\n        return 'on'\n      end\n    end\n  end\n  return measured and 'off' or 'unknown'\nend\n\nfunction ArtiRunning.shown(answer, shown_before)\n  if answer == 'on' then return true end\n  if answer == 'off' then return false end\n  return not not shown_before\nend\n\n-- ==== src/rules.lua ====\n-- Copyright (C) 2026 ArtiLED B.V. All Rights Reserved.\n--\n-- Every decision of the Quick App, as pure functions: what an HC3 action sends to the Hub, what a Hub\n-- item shows on an HC3 device, which HC3 type a Hub entity gets, and the identity of a child.\n-- No HC3 call and no network here, so tests/test_rules.lua can hold all of it.\n--\n-- !! READ BEFORE CHANGING THE COMMAND RULES. They come from the Home Assistant integration and the\n-- Homey app (ArtiLED_Hub_HA, ArtiLED_Hub_Homey) and from ArtiAPI.txt, and each one was learned on real\n-- lights:\n--   - `state` NEVER travels with brightness or colour. state=1 is RESTORE: it reloads the controller's\n--     last saved frame, visibly killing a light that is lit.\n--   - colour and brightness are sent alone; a colour wakes a light by itself; brightness alone never\n--     switches a light on (Hub rule A).\n--   - a scene or mode ALWAYS carries its room or zone: a bare id runs house-wide.\n--   - /light always carries hue=1, and so does every colour command (else Hue lights ignore it).\n\nArtiRules = {}\n\nArtiRules.LIGHT_KINDS = { room = true, zone = true, light = true }\n\nlocal function num(v)\n  return tonumber(v)\nend\n\nlocal function clamp(v, lo, hi)\n  if v < lo then return lo end\n  if v > hi then return hi end\n  return v\nend\n\nlocal function round(v)\n  return math.floor(v + 0.5)\nend\n\n-- ---------------------------------------------------------------------------------------------------\n-- HC3 type, chosen ONCE when the child is created (a type change would be a new device).\n-- No capabilities (a Hub before 1.81): a colour light, as the Homey app assumes.\nfunction ArtiRules.childType(kind, caps)\n  if kind == 'scene' or kind == 'mode' then\n    return 'com.fibaro.binarySwitch'\n  end\n  if type(caps) ~= 'table' then\n    return 'com.fibaro.colorController'\n  end\n  if num(caps.rgb) == 1 or num(caps.rgbw) == 1 or num(caps.hsv) == 1 then\n    return 'com.fibaro.colorController'\n  end\n  if num(caps.brightness) == 1 then\n    return 'com.fibaro.multilevelSwitch'\n  end\n  return 'com.fibaro.binarySwitch'\nend\n\n-- ---------------------------------------------------------------------------------------------------\n-- Identity: the Hub's ident + kind + id (+ scope for scenes and modes). Never the name, never the\n-- numeric id alone - two Hubs both have a room 1.\nfunction ArtiRules.key(spec)\n  local base = string.format('%s:%s:%s', tostring(spec.ident), spec.kind, tostring(spec.id))\n  if spec.kind == 'scene' or spec.kind == 'mode' then\n    base = base .. string.format('@%s:%s', tostring(spec.scopeKind), tostring(spec.scopeId))\n  end\n  return base\nend\n\n-- A spec the Quick App can act on: known kind, numeric id, and a room/zone scope for scenes and modes.\nfunction ArtiRules.validSpec(spec)\n  if type(spec) ~= 'table' or type(spec.ident) ~= 'string' or spec.ident == '' then return false end\n  if not num(spec.id) then return false end\n  if ArtiRules.LIGHT_KINDS[spec.kind] then return true end\n  if spec.kind == 'scene' or spec.kind == 'mode' then\n    return (spec.scopeKind == 'room' or spec.scopeKind == 'zone') and num(spec.scopeId) ~= nil\n  end\n  return false\nend\n\n-- ---------------------------------------------------------------------------------------------------\n-- Hub item -> HC3 properties.\n\n-- First colour stop as r, g, b. The Hub reports an ORDERED LIST (a light can carry a gradient); HC3\n-- has one colour, so the first stop is shown. Averaging would invent a colour the light is not showing.\nfunction ArtiRules.firstRgb(colors)\n  if type(colors) ~= 'table' or type(colors[1]) ~= 'string' then return nil end\n  local r, g, b = colors[1]:match('rgba?%(%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)')\n  if not r then return nil end\n  return clamp(tonumber(r), 0, 255), clamp(tonumber(g), 0, 255), clamp(tonumber(b), 0, 255)\nend\n\n-- What a room/zone/light child shows. `item` nil = \"not in the Hub's list\" (deleted, or the Hub no\n-- longer lists it) - never \"Hub unreachable\": the caller does not call this when the poll failed.\n-- Returns {dead = true, reason = ...} or {dead = false, state = bool, value = ..., color = ...}.\n-- The Hub's state evidence, three-way. ONLY an explicit 1 / 0 (number or string) is ON / OFF.\n-- false, null, missing or anything else is UNKNOWN: the Hub sends state:false for a light with no\n-- current state evidence (#637), and turning that into OFF would make Q1 restore a light that may\n-- be lit (Mycroft 6416 QA-S4).\nfunction ArtiRules.stateEvidence(v)\n  if v == 1 or v == '1' then return true end\n  if v == 0 or v == '0' then return false end\n  return nil\nend\n\nfunction ArtiRules.lightProps(item, hc3type)\n  if type(item) ~= 'table' then\n    return { dead = true, reason = 'Removed from the ArtiLED Hub' }\n  end\n  if num(item.disabled) == 1 then\n    return { dead = true, reason = 'Disabled on the ArtiLED Hub' }\n  end\n  if num(item.down) == 1 then\n    return { dead = true, reason = 'Not reachable from the ArtiLED Hub' }\n  end\n  local on = ArtiRules.stateEvidence(item.state)\n  local out = { dead = false, state = on }\n  if on == nil then\n    -- Reachable, state unknown: available, nothing claimed (state, value, colour stay as shown).\n    out.unknown = true\n    return out\n  end\n  if hc3type == 'com.fibaro.binarySwitch' then\n    out.value = on\n    return out\n  end\n  local bri = clamp(num(item.brightness) or 0, 0, 255)\n  out.value = on and clamp(round(bri * 99 / 255), 1, 99) or 0\n  if hc3type == 'com.fibaro.colorController' then\n    local r, g, b = ArtiRules.firstRgb(item.colors)\n    if r then\n      -- White is write-only (the Hub reports RGB whatever emitter made it), so w is never claimed.\n      out.color = string.format('%d,%d,%d,0', r, g, b)\n      out.colorComponents = { red = r, green = g, blue = b, white = 0 }\n    end\n  end\n  return out\nend\n\n-- ---------------------------------------------------------------------------------------------------\n-- HC3 action -> Hub request.\n--\n-- spec    the child's binding {kind, id, scopeKind, scopeId}\n-- action  the HC3 action name\n-- args    its arguments (a list)\n-- ctx     {knownState = true|false|nil}: this child's own last successful command when newer than\n--          the last poll, else the last poll (ArtiRules.knownState). Used by toggle only.\n-- Returns {endpoint = '...', data = {...}} or nil, reason.\n\nlocal function scoped(spec, data)\n  if spec.kind == 'room' then data.room = num(spec.id)\n  elseif spec.kind == 'zone' then data.zone = num(spec.id)\n  elseif spec.kind == 'light' then data.light = num(spec.id); data.hue = 1 end\n  return { endpoint = spec.kind, data = data }\nend\n\nlocal function byte(v)\n  return clamp(round(num(v) or 0), 0, 255)\nend\n\nlocal function colourFields(r, g, b, w)\n  return { color_rgbw = { red = byte(r), green = byte(g), blue = byte(b), white = byte(w) }, hue = 1 }\nend\n\nlocal function lightCommand(spec, action, args, ctx)\n  if action == 'turnOff' then\n    return scoped(spec, { state = 0 })\n  end\n  if action == 'turnOn' then\n    -- Michel 2026-10-04 (Q3 = B): a room/zone/light that is ALREADY on is left alone; state 1 is the\n    -- Hub's RESTORE and would reload the saved frame over what it shows (\"red, then turn on\" keeps\n    -- red). Off or unknown -> sent. ctx is read at DISPATCH (src/parent.lua), after every earlier\n    -- command has completed, so a queued Off before this On is already counted (Mycroft 6377 QA-D1).\n    if ctx.knownState == true then\n      return nil, 'already on: nothing sent (a restore would reload the saved frame over it)', 'skip'\n    end\n    return scoped(spec, { state = 1 })\n  end\n  if action == 'toggle' then\n    return lightCommand(spec, ctx.knownState == true and 'turnOff' or 'turnOn', args, ctx)\n  end\n  if action == 'setValue' then\n    local v = num(args[1])\n    if not v then return nil, 'setValue without a number' end\n    if v <= 0 then\n      return scoped(spec, { state = 0 })\n    end\n    local bri = scoped(spec, { brightness = clamp(round(clamp(v, 0, 99) * 255 / 99), 1, 255) })\n    if ctx.knownState == false then\n      -- Michel 2026-10-04 (Q1 = B): brightness on an OFF light also switches it on. Never in one\n      -- request (state never travels with brightness): the wake FIRST, then the level as a follow-up\n      -- the queue sends right after it, with nothing in between.\n      local wake = scoped(spec, { state = 1 })\n      wake.followUp = bri\n      return wake\n    end\n    -- On: brightness alone. Unknown (no poll yet, or the Hub has no state evidence): brightness alone\n    -- too, and said so; a restore on a light that is in fact lit would kill it.\n    if ctx.knownState == nil then\n      return bri, 'state unknown: brightness sent alone, no wake', 'note'\n    end\n    return bri\n  end\n  if action == 'setColor' then\n    return scoped(spec, colourFields(args[1], args[2], args[3], args[4]))\n  end\n  if action == 'setColorComponents' or action == 'setColorComponentsAndBrightness' then\n    local c = args[1]\n    if type(c) ~= 'table' then return nil, action .. ' without colour components' end\n    local fields = colourFields(c.red, c.green, c.blue, c.white)\n    if action == 'setColorComponentsAndBrightness' then\n      local level = num(args[2])\n      if level and level > 0 then\n        fields.brightness = clamp(round(clamp(level, 0, 99) * 255 / 99), 1, 255)\n      end\n    end\n    return scoped(spec, fields)\n  end\n  return nil, 'not supported: ' .. tostring(action)\nend\n\nlocal function scopeArg(spec, data)\n  if spec.scopeKind == 'room' then data.room = num(spec.scopeId)\n  elseif spec.scopeKind == 'zone' then data.zone = num(spec.scopeId) end\n  return data\nend\n\nfunction ArtiRules.command(spec, action, args, ctx)\n  args = args or {}\n  ctx = ctx or {}\n  if not ArtiRules.validSpec(spec) then\n    return nil, 'this device is not bound to an ArtiLED Hub entity'\n  end\n  local req, why, kind\n  if ArtiRules.LIGHT_KINDS[spec.kind] then\n    req, why, kind = lightCommand(spec, action, args, ctx)\n  elseif spec.kind == 'scene' then\n    if action == 'turnOn' or action == 'toggle' then\n      req = { endpoint = 'scene', data = scopeArg(spec, { scene = num(spec.id), running = 1 }) }\n    else\n      return nil, 'a scene runs once; there is nothing to switch off'\n    end\n  elseif spec.kind == 'mode' then\n    local start\n    if action == 'turnOn' then start = true\n    elseif action == 'turnOff' then start = false\n    elseif action == 'toggle' then start = ctx.knownState ~= true\n    else return nil, 'not supported: ' .. tostring(action) end\n    req = { endpoint = 'mode', data = scopeArg(spec, { mode = num(spec.id), running = start and 1 or 0 }) }\n  end\n  if not req then return nil, why, kind end\n  assert(ArtiRules.safe(req))\n  if req.followUp then assert(ArtiRules.safe(req.followUp)) end\n  return req, why, kind\nend\n\n-- The invariants every request must hold. Checked on every request built above, and by the tests.\nfunction ArtiRules.safe(req)\n  local d = req and req.data\n  if type(d) ~= 'table' then return false end\n  if d.state ~= nil and (d.brightness ~= nil or d.color_rgbw ~= nil or d.color_hsv ~= nil\n      or d.color_w ~= nil or d.color_rgb ~= nil) then\n    return false\n  end\n  if req.endpoint == 'scene' or req.endpoint == 'mode' then\n    if d.room == nil and d.zone == nil then return false end\n  end\n  if req.endpoint == 'light' and d.hue ~= 1 then return false end\n  if (d.color_rgbw or d.color_hsv or d.color_w) and d.hue ~= 1 then return false end\n  return true\nend\n\n-- ---------------------------------------------------------------------------------------------------\n-- A mode child: on / off / unknown from /api/running through the #429 rule.\nfunction ArtiRules.modeAnswer(runningData, spec, name)\n  return ArtiRunning.state(runningData,\n    { type = 'mode', id = num(spec.id), name = name or '', reports = true },\n    { kind = spec.scopeKind, id = num(spec.scopeId) })\nend\n\n-- Is `item` from a /scenes or /modes list (asked WITH this child's scope) this child's entity?\nfunction ArtiRules.findById(list, id)\n  if type(list) ~= 'table' then return nil end\n  for _, it in ipairs(list) do\n    if type(it) == 'table' and num(it.id) == num(id) then return it end\n  end\n  return nil\nend\n\n-- The state a toggle acts on: our own last successful command when it is newer than the last poll,\n-- else the last poll. nil = unknown (a toggle then switches on).\n-- polledSeq / commandSeq are the Hub client's DISPATCH numbers (ArtiHub, one request at a time), so\n-- \"newer\" is the order the Hub saw them in. Never seconds: a command and a poll in the same second\n-- would tie (Mycroft 6387 QA-S3).\nfunction ArtiRules.knownState(polledState, polledSeq, commandState, commandSeq)\n  if commandState ~= nil and commandSeq ~= nil and (polledSeq == nil or commandSeq > polledSeq) then\n    return commandState\n  end\n  return polledState\nend\n\n-- ==== src/hub.lua ====\n-- Copyright (C) 2026 ArtiLED B.V. All Rights Reserved.\n--\n-- The ArtiLED Hub's ArtiAPI, as the Home Assistant integration and the Homey app speak it:\n-- HTTP POST to http://<hub>/api/<endpoint>, form-encoded, with `token` and `data` (JSON).\n-- A reply is {success: 1, data: {...}}; error 2 means the token was rejected.\n--\n-- !! THE QUEUE CONTRACT (plan rev 5a, Mycroft 6377 QA-D2). Keep it this small:\n--   - ONE request in flight. The Hub processes sets synchronously and everything else waits behind\n--     them, so we never stack requests on it.\n--   - Two FIFO lanes. When the wire is free the oldest COMMAND goes first, then the oldest READ.\n--     Commands keep their order among themselves (Off then On goes Off, On) and only overtake reads\n--     that are WAITING, never a request already in flight.\n--   - No retry, no replay. A failure is reported to the caller and forgotten.\n--   - At most MAX_COMMANDS waiting commands; one more is refused at once, never dropped silently.\n--   - Every item carries the Hub GENERATION it was queued under (and its child id). A transport\n--     failure, an auth failure, or the parent finding a different Hub ident bumps the generation:\n--     every WAITING item is then dropped and its caller told \"not sent\". A request already sent cannot\n--     be taken back; a reply that comes back under an older generation, or for a removed child, is\n--     IGNORED and logged as a late reply. It is never called cancelled.\n--   - SET GATE: no command is admitted until the parent has verified the Hub's identity in this\n--     generation (verify()). Before that a command is refused at once, never parked for later.\n--   - DRY_RUN (src/version.lua) is checked at the WIRE, right before the HTTP request, for every\n--     endpoint that changes something on the Hub. No route around it exists.\n\nArtiHub = {}\nArtiHub.__index = ArtiHub\n\nArtiHub.TIMEOUT_MS = 20000\nArtiHub.MAX_COMMANDS = 20\n\n-- Endpoints that change something on the Hub.\nArtiHub.SET_ENDPOINTS = { room = true, zone = true, light = true, house = true, scene = true, mode = true }\n\nlocal function urlencode(s)\n  return (tostring(s):gsub('[^%w%-%._~]', function(c)\n    return string.format('%%%02X', string.byte(c))\n  end))\nend\nArtiHub.urlencode = urlencode\n\n-- JSON for the `data` field. An empty Lua table must go as {} (an object), never [].\nlocal function encodeData(d)\n  if type(d) ~= 'table' or next(d) == nil then return '{}' end\n  return json.encode(d)\nend\nArtiHub.encodeData = encodeData\n\nlocal function body(token, data)\n  return 'token=' .. urlencode(token) .. '&data=' .. urlencode(encodeData(data))\nend\n\nlocal function trim(s)\n  return (tostring(s or ''):gsub('^%s+', ''):gsub('%s+$', ''))\nend\n\n-- log(text): where the client reports dry-run requests, drops and late replies.\nfunction ArtiHub.new(ip, token, log)\n  local self = setmetatable({}, ArtiHub)\n  self.ip = trim(ip)\n  self.token = token or ''\n  self.log = log or function() end\n  self.commands = {}\n  self.reads = {}\n  self.inflight = nil\n  self.gen = 1\n  self.verified = false\n  self.removed = {}\n  self.http = net.HTTPClient()\n  return self\nend\n\nfunction ArtiHub:url(endpoint)\n  return 'http://' .. self.ip .. '/api/' .. endpoint\nend\n\nlocal function safecall(self, cb, ...)\n  local ok, err = pcall(cb, ...)\n  if not ok then self.log('ArtiLED: callback error: ' .. tostring(err)) end\nend\n\n-- The Hub's identity was lost: drop every WAITING item, close the SET gate.\nfunction ArtiHub:bump(reason)\n  self.gen = self.gen + 1\n  self.verified = false\n  local waiting = {}\n  for _, j in ipairs(self.commands) do waiting[#waiting + 1] = j end\n  for _, j in ipairs(self.reads) do waiting[#waiting + 1] = j end\n  self.commands, self.reads = {}, {}\n  for _, j in ipairs(waiting) do\n    if j.command then\n      self.log('ArtiLED: not sent (' .. reason .. '): /api/' .. j.endpoint .. ' ' .. encodeData(j.data))\n    end\n    safecall(self, j.cb, false, 'not sent: ' .. reason, 'dropped')\n  end\nend\n\n-- The parent verified the Hub's ident in this generation: commands may be admitted.\nfunction ArtiHub:verify()\n  self.verified = true\nend\n\n-- A child was removed: its waiting commands are dropped; a reply already on its way is ignored.\nfunction ArtiHub:dropChild(childId)\n  self.removed[childId] = true\n  local keep = {}\n  for _, j in ipairs(self.commands) do\n    if j.child == childId then\n      self.log('ArtiLED: not sent (device removed): /api/' .. j.endpoint .. ' ' .. encodeData(j.data))\n      safecall(self, j.cb, false, 'not sent: device removed', 'dropped')\n    else\n      keep[#keep + 1] = j\n    end\n  end\n  self.commands = keep\nend\n\n-- A failed request takes its follow-up (still waiting at the head of the command lane) with it.\nfunction ArtiHub:_dropFollowUp(job, reason)\n  local f = job.followJob\n  if not f then return end\n  for i, j in ipairs(self.commands) do\n    if j == f then\n      table.remove(self.commands, i)\n      self.log('ArtiLED: not sent (' .. reason .. '): /api/' .. f.endpoint .. ' ' .. encodeData(f.data))\n      safecall(self, f.cb, false, 'not sent: ' .. reason, 'dropped', nil, f.req)\n      return\n    end\n  end\nend\n\nfunction ArtiHub:_finish(job, ok, data, code)\n  self.inflight = nil\n  if job.gen ~= self.gen or (job.child ~= nil and self.removed[job.child]) then\n    self.log('ArtiLED: late reply ignored for /api/' .. job.endpoint)\n  else\n    if not ok then self:_dropFollowUp(job, 'the request before it failed') end\n    safecall(self, job.cb, ok, data, code, job.seq, job.req)\n    if not ok and (code == 'unreachable' or code == 'http' or code == 'auth') and job.gen == self.gen then\n      self:bump(code == 'auth' and 'Hub login no longer valid' or 'Hub not reachable')\n    end\n  end\n  self:_dispatch()\nend\n\nfunction ArtiHub:_dispatch()\n  if self.inflight then return end\n  local job = table.remove(self.commands, 1) or table.remove(self.reads, 1)\n  if not job then return end\n\n  -- A command decided at DISPATCH (plan rev 5c): build() reads the child's known state now, after\n  -- every earlier command has completed. Nothing -> skipped: reported, never sent, never retried.\n  if job.build then\n    local req, why = job.build()\n    if not req then\n      safecall(self, job.cb, false, why or 'skipped', 'skipped', nil, nil)\n      return self:_dispatch()\n    end\n    job.req, job.endpoint, job.data = req, req.endpoint, req.data\n    job.body = body(self.token, req.data)\n    if req.followUp then\n      -- At the HEAD of the command lane: nothing can come between the wake and its brightness.\n      local f = { endpoint = req.followUp.endpoint, data = req.followUp.data, req = req.followUp,\n        method = 'POST', command = true, body = body(self.token, req.followUp.data), cb = job.cb,\n        gen = job.gen, child = job.child }\n      job.followJob = f\n      table.insert(self.commands, 1, f)\n    end\n  end\n\n  -- !! THE DRY-RUN BOUNDARY. Nothing that changes the Hub passes this line under DRY_RUN.\n  if DRY_RUN and ArtiHub.SET_ENDPOINTS[job.endpoint] then\n    self.log('ArtiLED DRY-RUN, not sent: /api/' .. job.endpoint .. ' ' .. encodeData(job.data))\n    safecall(self, job.cb, false, 'dry-run: nothing was sent', 'dry-run', nil, job.req)\n    return self:_dispatch()\n  end\n\n  self.inflight = job\n  -- The Hub handles one request at a time, in the order we send them, so the dispatch number is\n  -- the causal order of what we learned and what we changed (Mycroft 6387 QA-S3: never wall time).\n  self.seq = (self.seq or 0) + 1\n  job.seq = self.seq\n  local finished = false\n  local function done(ok, data, code)\n    if finished then return end\n    finished = true\n    self:_finish(job, ok, data, code)\n  end\n  self.http:request(self:url(job.endpoint), {\n    options = {\n      method = job.method,\n      headers = { ['Content-Type'] = 'application/x-www-form-urlencoded', ['Accept'] = 'application/json' },\n      data = job.body,\n      timeout = ArtiHub.TIMEOUT_MS,\n    },\n    success = function(resp)\n      if not resp or tonumber(resp.status) ~= 200 then\n        return done(false, 'HTTP ' .. tostring(resp and resp.status), 'http')\n      end\n      local okj, parsed = pcall(json.decode, resp.data or '')\n      if not okj or type(parsed) ~= 'table' then\n        return done(false, 'not a Hub reply', 'http')\n      end\n      if tonumber(parsed.success) ~= 1 then\n        local code = tonumber(parsed.error) or 0\n        return done(false, tostring(parsed.error_text or 'Hub error'),\n          (code == 2 and job.endpoint ~= 'login') and 'auth' or 'hub')\n      end\n      local data = parsed.data\n      if type(data) ~= 'table' then data = {} end\n      return done(true, data)\n    end,\n    error = function(err)\n      return done(false, 'not reachable: ' .. tostring(err), 'unreachable')\n    end,\n  })\nend\n\n\n-- A read. cb(ok, dataOrError, code, seq): seq = the request's dispatch number (nil if never sent).\nfunction ArtiHub:read(endpoint, data, cb)\n  self.reads[#self.reads + 1] = { endpoint = endpoint, data = data, method = 'POST',\n    body = body(self.token, data), cb = cb or function() end, gen = self.gen }\n  self:_dispatch()\nend\n\n-- A command for one child. Refused at once (cb(false, ..., 'refused')) when the SET gate is closed\n-- or the command lane is full.\n-- A command decided at dispatch: build() -> request (optionally with .followUp) or nil, reason.\n-- cb(ok, dataOrError, code, seq, req) runs once per request sent (twice with a follow-up).\nfunction ArtiHub:commandBuild(childId, label, build, cb)\n  cb = cb or function() end\n  if not self.verified then\n    self.log('ArtiLED: not sent (Hub not verified yet): ' .. label)\n    return cb(false, 'not sent: Hub not verified yet', 'refused')\n  end\n  if #self.commands >= ArtiHub.MAX_COMMANDS then\n    self.log('ArtiLED: not sent (too many commands waiting): ' .. label)\n    return cb(false, 'not sent: too many commands waiting', 'refused')\n  end\n  self.commands[#self.commands + 1] = { endpoint = label, data = {}, build = build, method = 'POST',\n    command = true, cb = cb, gen = self.gen, child = childId }\n  self:_dispatch()\nend\n\nfunction ArtiHub:command(childId, endpoint, data, cb)\n  cb = cb or function() end\n  if not self.verified then\n    self.log('ArtiLED: not sent (Hub not verified yet): /api/' .. endpoint .. ' ' .. encodeData(data))\n    return cb(false, 'not sent: Hub not verified yet', 'refused')\n  end\n  if #self.commands >= ArtiHub.MAX_COMMANDS then\n    self.log('ArtiLED: not sent (too many commands waiting): /api/' .. endpoint)\n    return cb(false, 'not sent: too many commands waiting', 'refused')\n  end\n  self.commands[#self.commands + 1] = { endpoint = endpoint, data = data, method = 'POST', command = true,\n    body = body(self.token, data), cb = cb, gen = self.gen, child = childId }\n  self:_dispatch()\nend\n\n-- Unauthenticated: {name, ident}. A read, at the head of the read lane.\nfunction ArtiHub:alive(cb)\n  table.insert(self.reads, 1, { endpoint = 'alive', method = 'GET', cb = cb, gen = self.gen })\n  self:_dispatch()\nend\n\n-- Username + password -> the Hub's stable token (the Homey app's route). No token is sent.\nfunction ArtiHub:login(user, pass, cb)\n  local b = 'data=' .. urlencode(json.encode({ username = user, password = pass }))\n  table.insert(self.reads, 1, { endpoint = 'login', method = 'POST', body = b, gen = self.gen,\n    cb = function(ok, data, code)\n      if ok and type(data.token) == 'string' and data.token ~= '' then return cb(true, data.token) end\n      if ok then return cb(false, 'the Hub accepted the login but returned no token', 'hub') end\n      return cb(false, data, code)\n    end })\n  self:_dispatch()\nend\n\n-- ==== src/child.lua ====\n-- Copyright (C) 2026 ArtiLED B.V. All Rights Reserved.\n--\n-- One ArtiLED room, zone, light, scene or mode as an HC3 device. The child is deliberately thin: it\n-- shows what the parent tells it and hands every HC3 action to the parent, which owns the Hub\n-- connection, the queue and the binding. What the child IS comes from its binding (spec), kept by the\n-- parent - never from its name or its HC3 type.\n\nclass 'ArtiChild'(QuickAppChild)\n\n-- A mode switch trusts its own start/stop this long: a node needs a few seconds to pick a mode up and\n-- report it, and the tile must not spring back the moment it is pressed (as the Homey app).\nlocal OPTIMISTIC_S = 20\n\nfunction ArtiChild:__init(device)\n  QuickAppChild.__init(self, device)\n  self.shown = {}\nend\n\n-- Set a property only when it changed: HC3 records every update in the device history.\nfunction ArtiChild:show(name, value)\n  if value == nil then return end\n  local before = self.shown[name]\n  if type(value) ~= 'table' and before == value then return end\n  if type(value) == 'table' and before ~= nil and json.encode(before) == json.encode(value) then return end\n  self.shown[name] = value\n  self:updateProperty(name, value)\nend\n\n-- Unavailable: dead + reason. The values stay as they were - an unreachable light is never \"off\".\nfunction ArtiChild:showDead(reason)\n  self:show('dead', true)\n  self:show('deadReason', reason)\n  self:show('log', reason)\nend\n\nfunction ArtiChild:showAlive()\n  self:show('dead', false)\n  self:show('deadReason', '')\nend\n\nfunction ArtiChild:note(text)\n  self:show('log', text)\nend\n\n-- A room / zone / light: props from ArtiRules.lightProps.\nfunction ArtiChild:applyLight(props, seq)\n  if props.dead then return self:showDead(props.reason) end\n  self:showAlive()\n  -- An unknown poll is still the newest evidence: it retires an older command's state (knownState\n  -- then answers unknown), but it claims nothing on the device.\n  self.polledState, self.polledSeq = props.state, seq\n  if props.unknown then\n    if self.renamed then self:note(self.renamed) else self:note('State not reported by the ArtiLED Hub') end\n    return\n  end\n  self:show('value', props.value)\n  self:show('state', props.state)\n  if props.color then\n    self:show('color', props.color)\n    self:show('colorComponents', props.colorComponents)\n  end\n  if self.renamed then self:note(self.renamed) else self:note('') end\nend\n\n-- A mode: answer 'on' | 'off' | 'unknown' from the #429 rule.\nfunction ArtiChild:applyMode(answer, now, seq)\n  if self.goneReason then return self:showDead(self.goneReason) end\n  self:showAlive()\n  if answer == 'unknown' then return end                 -- change nothing\n  local on = answer == 'on'\n  -- Inside the optimistic window only a POSITIVE report is accepted: a negative one is most likely the\n  -- node not having started yet.\n  if self.optimisticUntil and now < self.optimisticUntil and on ~= self.cmdState then return end\n  self.optimisticUntil = nil\n  self.polledState, self.polledSeq = on, seq\n  self:show('value', on)\n  self:show('state', on)\nend\n\n-- A scene: no state, only availability (from the inventory round).\nfunction ArtiChild:applyScene()\n  if self.goneReason then return self:showDead(self.goneReason) end\n  self:showAlive()\n  self:show('value', false)\n  self:show('state', false)\nend\n\n-- After the Hub accepted one of our commands.\n-- seq = the command's dispatch number (causal order against polls); now = seconds, for the mode's\n-- optimistic window only.\nfunction ArtiChild:afterCommand(req, now, seq)\n  local d = req.data\n  if req.endpoint == 'scene' then\n    -- Momentary: on for a moment, then off again. A scene has no state to show.\n    self:updateProperty('value', true)\n    self:updateProperty('state', true)\n    self.shown.value, self.shown.state = nil, nil\n    setTimeout(function()\n      self:show('value', false)\n      self:show('state', false)\n    end, 1000)\n    return\n  end\n  local on\n  if req.endpoint == 'mode' then\n    on = d.running == 1\n    self.optimisticUntil = now + OPTIMISTIC_S\n  elseif d.state ~= nil then\n    on = d.state == 1\n  elseif d.color_rgbw or d.color_hsv or d.color_w then\n    on = true                                           -- a colour wakes the light by itself\n  elseif d.brightness ~= nil then\n    on = nil                                            -- brightness alone never switches it on\n  end\n  if on ~= nil then\n    self.cmdState, self.cmdSeq = on, seq\n    if self.type == 'com.fibaro.binarySwitch' then\n      self:show('value', on)\n    elseif not on then\n      self:show('value', 0)\n    end\n    self:show('state', on)\n  end\nend\n\n-- HC3 actions. Each one goes to the parent, which decides and queues (src/parent.lua).\nfunction ArtiChild:turnOn() self.parent:childAction(self, 'turnOn', {}) end\nfunction ArtiChild:turnOff() self.parent:childAction(self, 'turnOff', {}) end\nfunction ArtiChild:toggle() self.parent:childAction(self, 'toggle', {}) end\nfunction ArtiChild:setValue(v) self.parent:childAction(self, 'setValue', { v }) end\nfunction ArtiChild:setColor(r, g, b, w) self.parent:childAction(self, 'setColor', { r, g, b, w }) end\nfunction ArtiChild:setColorComponents(c) self.parent:childAction(self, 'setColorComponents', { c }) end\nfunction ArtiChild:setColorComponentsAndBrightness(c, level)\n  self.parent:childAction(self, 'setColorComponentsAndBrightness', { c, level })\nend\nfunction ArtiChild:startColorFade() self.parent:childAction(self, 'startColorFade', {}) end\nfunction ArtiChild:startColorEnhancement() self.parent:childAction(self, 'startColorEnhancement', {}) end\nfunction ArtiChild:stopColorChange() self.parent:childAction(self, 'stopColorChange', {}) end\n\n-- ==== src/parent.lua ====\n-- Copyright (C) 2026 ArtiLED B.V. All Rights Reserved.\n--\n-- The parent: one per ArtiLED Hub. It holds the Hub connection, the bindings of its children, the\n-- poll loop and the selection view. Plan: /opt/workflow/artiled/plans/artiled-fibaro-hc3-quick-app.md\n--\n-- !! RULES THAT MUST SURVIVE EVERY CHANGE\n--   - onInit only READS. Starting, restarting or updating the Quick App never actuates a light.\n--   - Children are re-bound with initChildDevices and NEVER deleted or recreated here: their HC3 ids\n--     are what the customer's Fibaro scenes point at (proved on HC3 5.210, #430 comment 6371).\n--   - A child is bound by Hub ident + kind + id (+ scope) in the parent's internal storage. A child\n--     without a binding is shown dead, never guessed. A different Hub at HubIP is never adopted.\n--   - Hub unreachable -> children dead, values untouched, nothing replayed.\n\nQuickApp.POLL_S = 10\nQuickApp.INVENTORY_EVERY = 6           -- ticks: identity, scene/mode inventory and child removal\nQuickApp.EARLY_POLL_MS = 2000\nQuickApp.RETRY_S = 30\n\nQuickApp.CHILD_CLASSES = {\n  ['com.fibaro.colorController'] = ArtiChild,\n  ['com.fibaro.multilevelSwitch'] = ArtiChild,\n  ['com.fibaro.binarySwitch'] = ArtiChild,\n}\n\nlocal function now()\n  return os.time()\nend\n\nlocal function trim(s)\n  return (tostring(s or ''):gsub('^%s+', ''):gsub('%s+$', ''))\nend\n\n-- ---------------------------------------------------------------------------------------------------\n-- Start\n\nfunction QuickApp:onInit()\n  self:debug('ArtiLED Quick App ' .. QA_VERSION .. (DRY_RUN and ' (DRY-RUN: no command is sent)' or ''))\n  self.tick = 0\n  self.ui = {}\n  self.lists = {}\n\n  self:updateProperty('manufacturer', ARTILED_MANUFACTURER)\n  self:updateProperty('model', ARTILED_MODEL)\n  self.bindings = self:loadBindings()\n  self:initChildDevices(QuickApp.CHILD_CLASSES)\n  local live = {}\n  for id, child in pairs(self.childDevices or {}) do\n    live[tostring(id)] = true\n    self:bindChild(child)\n  end\n  -- Bindings of children that no longer exist (deleted by hand while the Quick App was stopped).\n  local pruned = false\n  for id in pairs(self.bindings) do\n    if not live[id] then self.bindings[id] = nil; pruned = true end\n  end\n  if pruned then self:saveBindings() end\n\n  self:status('Starting...')\n  self:initView()\n  self:connect()\nend\n\nfunction QuickApp:bindChild(child)\n  child.parent = self\n  child:show('manufacturer', ARTILED_MANUFACTURER)\n  child:show('model', ARTILED_MODEL)\n  local b = self.bindings[tostring(child.id)]\n  if b and ArtiRules.validSpec(b) then\n    child.spec = b\n    child.hubName = b.name\n  else\n    child.spec = nil\n    child:showDead('Not linked to an ArtiLED Hub entity')\n  end\nend\n\nfunction QuickApp:loadBindings()\n  local raw = self:internalStorageGet('bindings')\n  if type(raw) == 'table' then return raw end\n  if type(raw) == 'string' and raw ~= '' then\n    local ok, t = pcall(json.decode, raw)\n    if ok and type(t) == 'table' then return t end\n  end\n  return {}\nend\n\nfunction QuickApp:saveBindings()\n  self:internalStorageSet('bindings', json.encode(self.bindings))\nend\n\nfunction QuickApp:var(name)\n  return trim(self:getVariable(name))\nend\n\nfunction QuickApp:status(text)\n  self.statusText = text\n  local line = 'ArtiLED ' .. QA_VERSION .. (DRY_RUN and ' DRY-RUN' or '') .. ' - ' .. text\n  pcall(function() self:updateView('lblStatus', 'text', line) end)\n  self:updateProperty('log', text)\nend\n\nfunction QuickApp:eachChild(fn)\n  for id, child in pairs(self.childDevices or {}) do fn(child, id) end\nend\n\nfunction QuickApp:allDead(reason)\n  self:eachChild(function(child)\n    if child.spec then child:showDead(reason) end\n  end)\nend\n\n-- ---------------------------------------------------------------------------------------------------\n-- Connect: identity first, then credentials. Nothing is sent to a Hub whose ident does not match.\n\nfunction QuickApp:connect()\n  local ip = self:var('HubIP')\n  if ip == '' then\n    return self:status('Fill in HubIP, and HubUser + HubPassword (or HubToken), under Variables')\n  end\n  self.hub = self.hub or ArtiHub.new(ip, self:internalStorageGet('hubToken') or '', function(t) self:debug(t) end)\n  self:checkIdentity(function(ok)\n    if not ok then return self:retryLater() end\n    self:credentials()\n  end)\nend\n\nfunction QuickApp:retryLater()\n  if self.retryPending then return end\n  self.retryPending = true\n  setTimeout(function()\n    self.retryPending = false\n    self:connect()\n  end, QuickApp.RETRY_S * 1000)\nend\n\n-- cb(true) when /alive answers with the stored ident (or, before the first login, with any ident).\nfunction QuickApp:checkIdentity(cb)\n  self.hub:alive(function(ok, data)\n    if not ok or type(data) ~= 'table' or type(data.ident) ~= 'string' or data.ident == '' then\n      self:status('ArtiLED Hub not reachable at ' .. self.hub.ip)\n      self:allDead('ArtiLED Hub not reachable')\n      return cb(false)\n    end\n    local stored = self:internalStorageGet('hubIdent')\n    if type(stored) == 'string' and stored ~= '' and stored ~= data.ident then\n      -- !! Never adopt another Hub: same numeric ids would silently retarget every child.\n      self:foreignHub()\n      return cb(false)\n    end\n    self.foreign = false\n    self.seenIdent = data.ident\n    self.hubName = tostring(data.name or 'ArtiLED Hub')\n    return cb(true)\n  end)\nend\n\nfunction QuickApp:credentials()\n  local token = self:var('HubToken')\n  local user, pass = self:var('HubUser'), self:var('HubPassword')\n  if token ~= '' then\n    return self:tryToken(token, 'HubToken')\n  end\n  if user ~= '' and pass ~= '' then\n    return self.hub:login(user, pass, function(ok, t)\n      if not ok then\n        self:status('The ArtiLED Hub refused this login: check HubUser and HubPassword')\n        return self:allDead('Hub login refused')\n      end\n      self:tryToken(t, 'HubPassword')\n    end)\n  end\n  local stored = self:internalStorageGet('hubToken')\n  if type(stored) == 'string' and stored ~= '' then\n    return self:tryToken(stored, nil)\n  end\n  self:status('Fill in HubUser + HubPassword (or HubToken) under Variables')\nend\n\n-- A token is kept only once it WORKS (/version answers with it).\nfunction QuickApp:tryToken(token, clearVar)\n  self.hub.token = token\n  self.hub:read('version', {}, function(ok, data, code)\n    if not ok then\n      if code == 'auth' then\n        self.authFailed = true\n        self:status('Hub login no longer valid: fill in HubUser and HubPassword again')\n        return self:allDead('Hub login no longer valid')\n      end\n      self:status('ArtiLED Hub not reachable at ' .. self.hub.ip)\n      self:allDead('ArtiLED Hub not reachable')\n      return self:retryLater()\n    end\n    -- Hidden from the REST API (third argument). The password itself is never stored.\n    self:internalStorageSet('hubToken', token, true)\n    if not self:internalStorageGet('hubIdent') then\n      self:internalStorageSet('hubIdent', self.seenIdent, true)\n    end\n    if clearVar then self:setVariable(clearVar, '') end\n    self.hubVersion = tostring(data.version or '?')\n    self.hub:verify()\n    self:status(self.hubName .. ' - Hub ' .. self.hubVersion .. ' - connected')\n    self:startPolling()\n  end)\nend\n\n-- ---------------------------------------------------------------------------------------------------\n-- Poll\n\nfunction QuickApp:startPolling()\n  if self.polling then return end\n  self.polling = true\n  local function loop()\n    self:pollTick()\n    setTimeout(loop, QuickApp.POLL_S * 1000)\n  end\n  loop()\nend\n\nfunction QuickApp:needs()\n  local need = { room = false, zone = false, light = false, running = false, scopes = {} }\n  self:eachChild(function(child)\n    local s = child.spec\n    if not s then return end\n    if s.kind == 'room' or s.kind == 'zone' or s.kind == 'light' then need[s.kind] = true end\n    if s.kind == 'mode' then need.running = true end\n    if s.kind == 'scene' or s.kind == 'mode' then\n      need.scopes[s.kind .. '@' .. s.scopeKind .. ':' .. tostring(s.scopeId)] =\n        { list = s.kind == 'scene' and 'scenes' or 'modes', scopeKind = s.scopeKind, scopeId = tonumber(s.scopeId) }\n    end\n  end)\n  return need\nend\n\n-- Children the customer deleted by hand: drop them, their bindings and their waiting commands.\n-- !! Checked against the BINDINGS, not only childDevices: when a child is deleted in the HC3 UI the\n-- HC3 sends the parent a removeChildDevice action and the SDK already takes it out of childDevices\n-- (measured on the #430 dev install), so a loop over childDevices alone would never see it.\nfunction QuickApp:pruneRemovedChildren()\n  local ok, devices = pcall(api.get, '/devices?parentId=' .. tostring(self.id))\n  if not ok or type(devices) ~= 'table' then return end\n  local present = {}\n  for _, d in ipairs(devices) do present[tostring(d.id)] = true end\n  local gone = {}\n  for id in pairs(self.bindings) do\n    if not present[id] then gone[#gone + 1] = id end\n  end\n  for id in pairs(self.childDevices or {}) do\n    if not present[tostring(id)] and not self.bindings[tostring(id)] then gone[#gone + 1] = tostring(id) end\n  end\n  for _, id in ipairs(gone) do\n    self:debug('ArtiLED: child ' .. id .. ' was removed in the HC3; forgetting it')\n    if self.hub then self.hub:dropChild(tonumber(id)) end\n    if self.childDevices then self.childDevices[tonumber(id)] = nil end\n    self.bindings[id] = nil\n  end\n  if #gone > 0 then self:saveBindings() end\nend\n\nfunction QuickApp:pollTick(force)\n  -- A refused token or a different Hub stops polling until the installer fixes the Variables (a\n  -- variable change restarts the Quick App): no point asking the Hub every 10 s with a dead login.\n  if self.tickBusy or not self.hub or self.foreign or self.authFailed then return end\n  if not force then self.tick = self.tick + 1 end\n  local inventory = (self.tick % QuickApp.INVENTORY_EVERY) == 1\n\n  -- After a lost identity (generation bump) commands stay refused until /alive matches again.\n  if not self.hub.verified then\n    self.tickBusy = true\n    return self:checkIdentity(function(ok)\n      self.tickBusy = false\n      if ok and self:internalStorageGet('hubToken') then\n        self.hub:verify()\n        self:status(self.hubName .. ' - Hub ' .. tostring(self.hubVersion or '?') .. ' - connected')\n        self:pollTick(true)\n      end\n    end)\n  end\n\n  if inventory then self:pruneRemovedChildren() end\n  local need = self:needs()\n  local results, seqs, pending, failed = {}, {}, 0, nil\n  local function want(key, issue)\n    pending = pending + 1\n    issue(function(ok, d, code, seq)\n      if ok then results[key], seqs[key] = d, seq else failed = failed or code end\n      pending = pending - 1\n      if pending == 0 then\n        self.tickBusy = false\n        self:applyTick(results, failed, inventory, seqs)\n      end\n    end)\n  end\n  local function read(endpoint, data)\n    return function(cb) self.hub:read(endpoint, data, cb) end\n  end\n  self.tickBusy = true\n  if need.room then want('room', read('rooms', {})) end\n  if need.zone then want('zone', read('zones', {})) end\n  if need.light then want('light', read('lights', {})) end\n  if need.running then want('running', read('running', {})) end\n  if inventory then\n    -- !! The identity is judged the MOMENT /alive answers, not when the whole tick is in: commands\n    -- overtake waiting reads, so a later check would let one reach the other Hub (Mycroft 6387 QA-S1).\n    want('alive', function(cb)\n      self.hub:alive(function(ok, d, code, seq)\n        if ok and type(d) == 'table' and d.ident ~= self:internalStorageGet('hubIdent') then\n          self:foreignHub()\n        end\n        cb(ok, d, code, seq)\n      end)\n    end)\n    for key, s in pairs(need.scopes) do\n      local scope = {}\n      scope[s.scopeKind] = s.scopeId\n      want('inv:' .. key, read(s.list, scope))\n    end\n  end\n  if pending == 0 then self.tickBusy = false end\nend\n\nlocal function index(list)\n  local out = {}\n  for _, it in ipairs(type(list) == 'table' and list or {}) do\n    if type(it) == 'table' and tonumber(it.id) then out[tonumber(it.id)] = it end\n  end\n  return out\nend\n\n-- A different Hub answers at HubIP: drop all waiting work, close the SET gate, stop.\nfunction QuickApp:foreignHub()\n  if self.foreign then return end\n  self.foreign = true\n  self.hub:bump('a different ArtiLED Hub answers at ' .. self.hub.ip)\n  self:status('A different ArtiLED Hub answers at ' .. self.hub.ip .. ': nothing is sent to it')\n  self:allDead('A different ArtiLED Hub answers at this address')\nend\n\nfunction QuickApp:applyTick(results, failed, inventory, seqs)\n  seqs = seqs or {}\n  if self.foreign then return end\n  if failed then\n    if failed == 'auth' then\n      self.authFailed = true\n      self:status('Hub login no longer valid: fill in HubUser and HubPassword again')\n      return self:allDead('Hub login no longer valid')\n    end\n    self:status('ArtiLED Hub not reachable at ' .. self.hub.ip)\n    return self:allDead('ArtiLED Hub not reachable')\n  end\n  self:status(self.hubName .. ' - Hub ' .. tostring(self.hubVersion or '?') .. ' - connected')\n\n  local t = now()\n  local idx = {\n    room = results.room and index(results.room.rooms),\n    zone = results.zone and index(results.zone.zones),\n    light = results.light and index(results.light.lights),\n  }\n  self:eachChild(function(child)\n    local s = child.spec\n    if not s then return end\n    if s.kind == 'room' or s.kind == 'zone' or s.kind == 'light' then\n      local list = idx[s.kind]\n      if not list then return end\n      local item = list[tonumber(s.id)]\n      if item and item.name and tostring(item.name) ~= tostring(s.name) then\n        child.renamed = 'Hub name: ' .. tostring(item.name)\n      else\n        child.renamed = nil\n      end\n      child:applyLight(ArtiRules.lightProps(item, child.type), seqs[s.kind])\n      return\n    end\n    local key = s.kind .. '@' .. s.scopeKind .. ':' .. tostring(s.scopeId)\n    local inv = results['inv:' .. key]\n    if inv then\n      local item = ArtiRules.findById(inv[s.kind == 'scene' and 'scenes' or 'modes'], s.id)\n      if not item then\n        -- Deleted, or taken out of this room/zone: either way it cannot run HERE any more.\n        child.goneReason = 'No longer offered in this ' .. s.scopeKind .. ' on the ArtiLED Hub'\n      else\n        child.goneReason = nil\n        local hubName = tostring(item.name or '')\n        if hubName ~= '' and hubName ~= child.hubName then\n          -- !! /running matches modes by NAME: keep the Hub's current name, or a renamed mode could\n          -- never be recognised as running. The HC3 device name is the customer's and stays.\n          child.hubName = hubName\n          child:note('Hub name: ' .. hubName)\n        end\n      end\n    end\n    if s.kind == 'mode' then\n      if results.running then\n        child:applyMode(ArtiRules.modeAnswer(results.running, s, child.hubName), t, seqs.running)\n      end\n    else\n      child:applyScene()\n    end\n  end)\nend\n\n-- ---------------------------------------------------------------------------------------------------\n-- Commands from children\n\nfunction QuickApp:childAction(child, action, args)\n  if not child.spec then\n    child:note('Not linked to an ArtiLED Hub entity: nothing sent')\n    return\n  end\n  local function known()\n    return { knownState = ArtiRules.knownState(child.polledState, child.polledSeq, child.cmdState, child.cmdSeq) }\n  end\n  -- Checked now for what cannot change (unbound, unsupported, bad arguments)...\n  local req, why, kind = ArtiRules.command(child.spec, action, args, known())\n  if not req and kind ~= 'skip' then\n    child:note(why)\n    self:debug('ArtiLED: ' .. action .. ' on ' .. tostring(child.id) .. ': ' .. tostring(why))\n    return\n  end\n  if not self.hub then\n    child:note('Not sent: the ArtiLED Hub is not set up')\n    return\n  end\n  -- ...and DECIDED at dispatch, with the known state as it is then (plan rev 5c).\n  local label = action .. ' on device ' .. tostring(child.id)\n  self.hub:commandBuild(child.id, label, function()\n    local r, w, k = ArtiRules.command(child.spec, action, args, known())\n    if r and k == 'note' then self:debug('ArtiLED: ' .. label .. ': ' .. tostring(w)) end\n    return r, w, k\n  end, function(ok, err, code, seq, sent)\n    if ok then\n      child:afterCommand(sent, now(), seq)\n      setTimeout(function() self:pollTick(true) end, QuickApp.EARLY_POLL_MS)\n    elseif code == 'skipped' then\n      child:note(tostring(err))\n      self:debug('ArtiLED: ' .. label .. ': ' .. tostring(err))\n    elseif code ~= 'dry-run' then\n      child:note(tostring(err))\n    end\n  end)\nend\n\n-- ---------------------------------------------------------------------------------------------------\n-- Adding a child: the installer's deliberate choice, from the view (Add) or as an action:\n--   fibaro.call(<parent id>, 'addEntity', 'room', 15)\n--   fibaro.call(<parent id>, 'addEntity', 'scene', 12, 'room', 15)\n\nlocal LIST_OF = { room = 'rooms', zone = 'zones', light = 'lights', scene = 'scenes', mode = 'modes' }\n\nfunction QuickApp:addEntity(kind, id, scopeKind, scopeId, cb)\n  cb = cb or function() end\n  local function fail(text)\n    self:updateInfo(text)\n    self:debug('ArtiLED addEntity: ' .. text)\n    cb(false, text)\n  end\n  if not self.hub or not self.hub.verified then return fail('Not connected to the ArtiLED Hub') end\n  local ident = self:internalStorageGet('hubIdent')\n  local spec = { ident = ident, kind = kind, id = tonumber(id) }\n  if kind == 'scene' or kind == 'mode' then\n    spec.scopeKind, spec.scopeId = scopeKind, tonumber(scopeId)\n  end\n  if not ArtiRules.validSpec(spec) then return fail('Choose what to add (and a room or zone for a scene or mode)') end\n  local key = ArtiRules.key(spec)\n  for cid, b in pairs(self.bindings) do\n    if ArtiRules.validSpec(b) and ArtiRules.key(b) == key then\n      return fail('Already added (device ' .. tostring(cid) .. ')')\n    end\n  end\n\n  local filter = {}\n  if kind == 'scene' or kind == 'mode' then filter[spec.scopeKind] = spec.scopeId else filter[kind] = spec.id end\n  self.hub:read(LIST_OF[kind], filter, function(ok, data)\n    if not ok then return fail('The ArtiLED Hub did not answer') end\n    local item = ArtiRules.findById(data[LIST_OF[kind]], spec.id)\n    if not item then return fail('Not found on the ArtiLED Hub' .. (spec.scopeKind and ' in that ' .. spec.scopeKind or '')) end\n    local function create(scopeName)\n      -- !! Re-checked HERE, after the asynchronous lookups: two Adds of the same entity can both pass\n      -- the first check before either creates a device (Mycroft 6387 QA-S2).\n      for cid, b in pairs(self.bindings) do\n        if ArtiRules.validSpec(b) and ArtiRules.key(b) == key then\n          return fail('Already added (device ' .. tostring(cid) .. ')')\n        end\n      end\n      local hubName = tostring(item.name or spec.id)\n      spec.name = hubName\n      local name = NAME_PREFIX .. hubName .. (scopeName and (' - ' .. scopeName) or '')\n      local hc3type = ArtiRules.childType(kind, item.capabilities)\n      local child = self:createChildDevice({\n        name = name,\n        type = hc3type,\n        initialProperties = {},\n        initialInterfaces = ArtiRules.LIGHT_KINDS[kind] and { 'light' } or {},\n      }, ArtiChild)\n      if not child then return fail('The HC3 did not create the device') end\n      if not ArtiRules.LIGHT_KINDS[kind] then\n        -- A scene or mode is not a lamp: the HC3 makes every binarySwitch a Light by default, and then\n        -- a Fibaro \"all lights off\" would stop modes. Set ONCE at creation; later the customer decides.\n        pcall(api.put, '/devices/' .. tostring(child.id),\n          { properties = { deviceRole = 'Other', categories = { 'other' } } })\n      end\n      self.bindings[tostring(child.id)] = spec\n      self:saveBindings()\n      self:bindChild(child)\n      self:updateInfo('Added ' .. name .. ' (device ' .. tostring(child.id) .. ')')\n      self:debug('ArtiLED: added ' .. key .. ' as ' .. hc3type .. ' ' .. tostring(child.id))\n      setTimeout(function() self:pollTick(true) end, 500)\n      cb(true, child.id)\n    end\n    if not spec.scopeKind then return create(nil) end\n    local f = {}\n    f[spec.scopeKind] = spec.scopeId\n    self.hub:read(LIST_OF[spec.scopeKind], f, function(ok2, sd)\n      local s = ok2 and ArtiRules.findById(sd[LIST_OF[spec.scopeKind]], spec.scopeId)\n      create(s and tostring(s.name) or (spec.scopeKind .. ' ' .. tostring(spec.scopeId)))\n    end)\n  end)\nend\n\n-- ==== src/view.lua ====\n-- Copyright (C) 2026 ArtiLED B.V. All Rights Reserved.\n--\n-- The parent's view: the installer picks what to add, one entity at a time - never a bulk import.\n--   lblStatus   Hub name - version - connected / what is wrong\n--   selKind     Room / Zone / Light / Scene / Mode\n--   selScope    the room or zone a scene or mode runs in (scenes and modes are never house-wide here)\n--   selItem     the entity, listed by the Hub for that kind (and scope)\n--   btnAdd      add it as a child device\n--   btnRefresh  read the lists again\n--   lblInfo     the result of the last action\n-- The layout itself is in view/view.json (packed into the .fqa); it is meant to stay fixed across\n-- versions, because an in-place code update does not change it.\n\nlocal KINDS = {\n  { text = 'Room', type = 'option', value = 'room' },\n  { text = 'Zone', type = 'option', value = 'zone' },\n  { text = 'Light', type = 'option', value = 'light' },\n  { text = 'Scene', type = 'option', value = 'scene' },\n  { text = 'Mode', type = 'option', value = 'mode' },\n}\n\nlocal function selected(event)\n  local v = event and event.values and event.values[1]\n  if type(v) == 'table' then v = v[1] end\n  return v\nend\n\nfunction QuickApp:updateInfo(text)\n  pcall(function() self:updateView('lblInfo', 'text', text) end)\nend\n\nfunction QuickApp:setOptions(name, options)\n  pcall(function() self:updateView(name, 'options', options) end)\nend\n\nfunction QuickApp:initView()\n  self:setOptions('selKind', KINDS)\n  self:setOptions('selScope', {})\n  self:setOptions('selItem', {})\n  local n = 0\n  for _ in pairs(self.childDevices or {}) do n = n + 1 end\n  self:updateInfo(n .. ' ArtiLED device(s). Choose a kind, then what to add.')\nend\n\nlocal function options(list, prefix)\n  local out = {}\n  for _, it in ipairs(type(list) == 'table' and list or {}) do\n    if type(it) == 'table' and tonumber(it.id) and tonumber(it.disabled or 0) ~= 1 then\n      out[#out + 1] = { text = (prefix or '') .. tostring(it.name or it.id), type = 'option',\n        value = (prefix and prefix:lower():gsub('[^%a]', '') .. ':' or '') .. tostring(it.id) }\n    end\n  end\n  table.sort(out, function(a, b) return a.text:lower() < b.text:lower() end)\n  return out\nend\n\n-- Fill the scope list (rooms then zones) and, for room/zone/light, the item list.\nfunction QuickApp:onKind(event)\n  self.ui.kind = selected(event)\n  self.ui.item, self.ui.scope = nil, nil\n  self:setOptions('selItem', {})\n  if not self.hub or not self.hub.verified then return self:updateInfo('Not connected to the ArtiLED Hub') end\n  local kind = self.ui.kind\n  if kind == 'room' or kind == 'zone' or kind == 'light' then\n    self:setOptions('selScope', {})\n    self.hub:read(kind .. 's', {}, function(ok, data)\n      if not ok then return self:updateInfo('The ArtiLED Hub did not answer') end\n      self:setOptions('selItem', options(data[kind .. 's']))\n      self:updateInfo('Choose the ' .. kind .. ' to add')\n    end)\n  elseif kind == 'scene' or kind == 'mode' then\n    self.hub:read('rooms', {}, function(ok, rooms)\n      if not ok then return self:updateInfo('The ArtiLED Hub did not answer') end\n      self.hub:read('zones', {}, function(ok2, zones)\n        if not ok2 then return self:updateInfo('The ArtiLED Hub did not answer') end\n        local list = options(rooms.rooms, 'Room ')\n        for _, o in ipairs(options(zones.zones, 'Zone ')) do list[#list + 1] = o end\n        self:setOptions('selScope', list)\n        self:updateInfo('Choose the room or zone the ' .. kind .. ' runs in')\n      end)\n    end)\n  end\nend\n\n-- For scenes and modes: list what the Hub OFFERS in that scope (its own scoping does the filtering).\nfunction QuickApp:onScope(event)\n  self.ui.scope = selected(event)\n  self.ui.item = nil\n  local kind = self.ui.kind\n  if kind ~= 'scene' and kind ~= 'mode' then return end\n  local sk, sid = tostring(self.ui.scope or ''):match('^(%a+):(%d+)$')\n  if not sk then return end\n  local f = {}\n  f[sk] = tonumber(sid)\n  self.hub:read(kind .. 's', f, function(ok, data)\n    if not ok then return self:updateInfo('The ArtiLED Hub did not answer') end\n    self:setOptions('selItem', options(data[kind .. 's']))\n    self:updateInfo('Choose the ' .. kind .. ' to add')\n  end)\nend\n\nfunction QuickApp:onItem(event)\n  self.ui.item = selected(event)\nend\n\nfunction QuickApp:onAdd()\n  local kind, item = self.ui.kind, self.ui.item\n  if not kind or not item then return self:updateInfo('Choose a kind and what to add first') end\n  if kind == 'scene' or kind == 'mode' then\n    local sk, sid = tostring(self.ui.scope or ''):match('^(%a+):(%d+)$')\n    if not sk then return self:updateInfo('Choose the room or zone first') end\n    return self:addEntity(kind, item, sk, sid)\n  end\n  return self:addEntity(kind, item)\nend\n\nfunction QuickApp:onRefresh()\n  if self.ui.kind then return self:onKind({ values = { self.ui.kind } }) end\n  self:initView()\nend\n",
   "isMain": true,
   "isOpen": true,
   "name": "main",
   "type": "lua"
  }
 ],
 "initialInterfaces": [
  "quickAppChild"
 ],
 "initialProperties": {
  "apiVersion": "1.3",
  "quickAppVariables": [
   {
    "name": "HubIP",
    "value": ""
   },
   {
    "name": "HubUser",
    "value": ""
   },
   {
    "name": "HubPassword",
    "value": ""
   },
   {
    "name": "HubToken",
    "value": ""
   }
  ],
  "typeTemplateInitialized": true,
  "uiCallbacks": [
   {
    "callback": "onKind",
    "eventType": "onToggled",
    "name": "selKind"
   },
   {
    "callback": "onScope",
    "eventType": "onToggled",
    "name": "selScope"
   },
   {
    "callback": "onItem",
    "eventType": "onToggled",
    "name": "selItem"
   },
   {
    "callback": "onAdd",
    "eventType": "onReleased",
    "name": "btnAdd"
   },
   {
    "callback": "",
    "eventType": "onLongPressDown",
    "name": "btnAdd"
   },
   {
    "callback": "",
    "eventType": "onLongPressReleased",
    "name": "btnAdd"
   },
   {
    "callback": "onRefresh",
    "eventType": "onReleased",
    "name": "btnRefresh"
   },
   {
    "callback": "",
    "eventType": "onLongPressDown",
    "name": "btnRefresh"
   },
   {
    "callback": "",
    "eventType": "onLongPressReleased",
    "name": "btnRefresh"
   }
  ],
  "uiView": [
   {
    "components": [
     {
      "name": "lblStatus",
      "style": {
       "weight": "1.00"
      },
      "text": "ArtiLED",
      "type": "label",
      "visible": true
     }
    ],
    "style": {
     "weight": "1.0"
    },
    "type": "horizontal"
   },
   {
    "components": [
     {
      "eventBinding": {
       "onToggled": [
        {
         "params": {
          "actionName": "UIAction",
          "args": [
           "onToggled",
           "selKind",
           "$event.value"
          ]
         },
         "type": "deviceAction"
        }
       ]
      },
      "name": "selKind",
      "options": [],
      "selectionType": "single",
      "style": {
       "weight": "1.00"
      },
      "text": "Kind",
      "type": "select",
      "values": [],
      "visible": true
     }
    ],
    "style": {
     "weight": "1.0"
    },
    "type": "horizontal"
   },
   {
    "components": [
     {
      "eventBinding": {
       "onToggled": [
        {
         "params": {
          "actionName": "UIAction",
          "args": [
           "onToggled",
           "selScope",
           "$event.value"
          ]
         },
         "type": "deviceAction"
        }
       ]
      },
      "name": "selScope",
      "options": [],
      "selectionType": "single",
      "style": {
       "weight": "1.00"
      },
      "text": "Room or zone (scenes and modes)",
      "type": "select",
      "values": [],
      "visible": true
     }
    ],
    "style": {
     "weight": "1.0"
    },
    "type": "horizontal"
   },
   {
    "components": [
     {
      "eventBinding": {
       "onToggled": [
        {
         "params": {
          "actionName": "UIAction",
          "args": [
           "onToggled",
           "selItem",
           "$event.value"
          ]
         },
         "type": "deviceAction"
        }
       ]
      },
      "name": "selItem",
      "options": [],
      "selectionType": "single",
      "style": {
       "weight": "1.00"
      },
      "text": "Add",
      "type": "select",
      "values": [],
      "visible": true
     }
    ],
    "style": {
     "weight": "1.0"
    },
    "type": "horizontal"
   },
   {
    "components": [
     {
      "eventBinding": {
       "onLongPressDown": [
        {
         "params": {
          "actionName": "UIAction",
          "args": [
           "onLongPressDown",
           "btnAdd"
          ]
         },
         "type": "deviceAction"
        }
       ],
       "onLongPressReleased": [
        {
         "params": {
          "actionName": "UIAction",
          "args": [
           "onLongPressReleased",
           "btnAdd"
          ]
         },
         "type": "deviceAction"
        }
       ],
       "onReleased": [
        {
         "params": {
          "actionName": "UIAction",
          "args": [
           "onReleased",
           "btnAdd"
          ]
         },
         "type": "deviceAction"
        }
       ]
      },
      "name": "btnAdd",
      "style": {
       "weight": "0.50"
      },
      "text": "Add",
      "type": "button",
      "visible": true
     },
     {
      "eventBinding": {
       "onLongPressDown": [
        {
         "params": {
          "actionName": "UIAction",
          "args": [
           "onLongPressDown",
           "btnRefresh"
          ]
         },
         "type": "deviceAction"
        }
       ],
       "onLongPressReleased": [
        {
         "params": {
          "actionName": "UIAction",
          "args": [
           "onLongPressReleased",
           "btnRefresh"
          ]
         },
         "type": "deviceAction"
        }
       ],
       "onReleased": [
        {
         "params": {
          "actionName": "UIAction",
          "args": [
           "onReleased",
           "btnRefresh"
          ]
         },
         "type": "deviceAction"
        }
       ]
      },
      "name": "btnRefresh",
      "style": {
       "weight": "0.50"
      },
      "text": "Refresh lists",
      "type": "button",
      "visible": true
     }
    ],
    "style": {
     "weight": "1.0"
    },
    "type": "horizontal"
   },
   {
    "components": [
     {
      "name": "lblInfo",
      "style": {
       "weight": "1.00"
      },
      "text": "",
      "type": "label",
      "visible": true
     }
    ],
    "style": {
     "weight": "1.0"
    },
    "type": "horizontal"
   }
  ],
  "useUiView": true,
  "userDescription": "ArtiLED: High End Intelligent Lighting",
  "viewLayout": {
   "$jason": {
    "body": {
     "header": {
      "style": {
       "height": "0"
      },
      "title": "quickApp_device"
     },
     "sections": {
      "items": [
       {
        "components": [
         {
          "name": "lblStatus",
          "style": {
           "weight": "1.00"
          },
          "text": "ArtiLED",
          "type": "label",
          "visible": true
         },
         {
          "style": {
           "weight": "0.5"
          },
          "type": "space"
         }
        ],
        "style": {
         "weight": "1.2"
        },
        "type": "vertical"
       },
       {
        "components": [
         {
          "name": "selKind",
          "options": [],
          "selectionType": "single",
          "style": {
           "weight": "1.00"
          },
          "text": "Kind",
          "type": "select",
          "values": [],
          "visible": true
         },
         {
          "style": {
           "weight": "0.5"
          },
          "type": "space"
         }
        ],
        "style": {
         "weight": "1.2"
        },
        "type": "vertical"
       },
       {
        "components": [
         {
          "name": "selScope",
          "options": [],
          "selectionType": "single",
          "style": {
           "weight": "1.00"
          },
          "text": "Room or zone (scenes and modes)",
          "type": "select",
          "values": [],
          "visible": true
         },
         {
          "style": {
           "weight": "0.5"
          },
          "type": "space"
         }
        ],
        "style": {
         "weight": "1.2"
        },
        "type": "vertical"
       },
       {
        "components": [
         {
          "name": "selItem",
          "options": [],
          "selectionType": "single",
          "style": {
           "weight": "1.00"
          },
          "text": "Add",
          "type": "select",
          "values": [],
          "visible": true
         },
         {
          "style": {
           "weight": "0.5"
          },
          "type": "space"
         }
        ],
        "style": {
         "weight": "1.2"
        },
        "type": "vertical"
       },
       {
        "components": [
         {
          "components": [
           {
            "name": "btnAdd",
            "style": {
             "weight": "0.50"
            },
            "text": "Add",
            "type": "button",
            "visible": true
           },
           {
            "name": "btnRefresh",
            "style": {
             "weight": "0.50"
            },
            "text": "Refresh lists",
            "type": "button",
            "visible": true
           }
          ],
          "style": {
           "weight": "1.2"
          },
          "type": "horizontal"
         },
         {
          "style": {
           "weight": "0.5"
          },
          "type": "space"
         }
        ],
        "style": {
         "weight": "1.2"
        },
        "type": "vertical"
       },
       {
        "components": [
         {
          "name": "lblInfo",
          "style": {
           "weight": "1.00"
          },
          "text": "",
          "type": "label",
          "visible": true
         },
         {
          "style": {
           "weight": "0.5"
          },
          "type": "space"
         }
        ],
        "style": {
         "weight": "1.2"
        },
        "type": "vertical"
       }
      ]
     }
    },
    "head": {
     "title": "quickApp_device"
    }
   }
  }
 },
 "name": "ArtiLED",
 "type": "com.fibaro.deviceController"
}
