--[[ Fixture Label Export (XML) - v2.0.1 grandMA2 3.x (onPC und Konsole) Exportiert den Patch als XML für die "Fixture-Etiketten" WebApp: Fix ID, Channel ID, Name, Fixture-Typ, Layer, Patch (Universe.Adresse), Footprint. Basiert auf "Patch Export v1.0.2" von Jason Giaffo (GiaffoDesigns.com), neu geschrieben von Heiko Steiger / hesteig.de. Ablauf: 1. Der Patch wird aus dem Objektbaum gelesen (Setup > Layers > Layer > Fixture). Dabei kommen Layer, Typ und Patch direkt aus der Show. 2. Fehlen Patch-Adressen (oder findet das Plugin den Layer-Baum nicht), wird wie im Original-Plugin "List DmxUniverse ... /f=" ausgewertet. 3. Die XML-Datei landet im Ordner "reports" der Show (onPC: C:\ProgramData\MA Lighting Technologies\grandma\gma2_V_3.x.x\reports). 4. Es entsteht nur diese eine Datei. Das Debug-Log steht als Kommentar am Ende der XML; nur wenn der Export scheitert, wird _debug.txt geschrieben. Falls etwas fehlt, die Datei mitschicken. --]] ---- CONFIG ---- local CONFIG = { output_dir = '', -- leer = /reports/ ; sonst absoluter Pfad mit / am Ende file_prefix = 'fixture_labels', debug_log = true, -- Log als Kommentar in die XML (bei Fehler: _debug.txt) dmx_fallback = true, -- fehlende Adressen über "List DmxUniverse" ermitteln uni_start = 0, -- Universen für den Fallback; 0 = beim Bedarf fragen uni_end = 0, open_folder_onpc = true, -- auf onPC den Ordner im Explorer öffnen } ---- INTERN ---- local VERSION = '2.0.1' local G = gma.show.getobj local P = gma.show.property local CAND = { fid = {'fixid', 'fixtureid', 'fid', 'fix'}, cid = {'chaid', 'chid', 'channelid', 'channel', 'cha'}, name = {'name', 'fixturename'}, ftype = {'fixturetype', 'type', 'fixturetypename', 'ftype'}, patch = {'patch', 'dmx', 'dmxaddress', 'address', 'patchaddress'}, } local logLines = {} local function L(...) local n = select('#', ...) local t = {} for i = 1, n do t[#t + 1] = tostring((select(i, ...))) end logLines[#logLines + 1] = table.concat(t, ' ') end local function safe(f, ...) if type(f) ~= 'function' then return nil end local ok, r = pcall(f, ...) if ok then return r end return nil end local function trim(s) if s == nil then return '' end return (tostring(s):gsub('^%s+', ''):gsub('%s+$', '')) end local function norm(s) return (tostring(s or ''):lower():gsub('[^%w]', '')) end local function clean(s) s = trim(s) if s == '' or s == '-' or s == '---' or s == 'None' then return nil end return s end local function esc(s) s = tostring(s or '') s = s:gsub('&', '&'):gsub('<', '<'):gsub('>', '>'):gsub('"', '"') s = s:gsub('[\1-\8\11\12\14-\31]', '') return s end local function valid(h) if h == nil or h == false then return false end return safe(G.name, h) ~= nil end local function objName(h) local l = safe(G.label, h) if l and l ~= '' then return l end return safe(G.name, h) or '' end local function children(h) local t = {} local n = safe(G.amount, h) or 0 for i = 0, n - 1 do local c = safe(G.child, h, i) if c then t[#t + 1] = c end end return t end local function getProps(h) local res, seen = {}, {} local n = safe(P.amount, h) or 0 for i = 0, n do -- 0..n deckt 0- und 1-basierte Indizes ab local nm = safe(P.name, h, i) if nm and nm ~= '' and not seen[nm] then seen[nm] = true res[#res + 1] = {name = nm, key = norm(nm), value = tostring(safe(P.get, h, i) or '')} end end return res end local function pick(props, cands) for _, c in ipairs(cands) do for _, p in ipairs(props) do if p.key == c then return p.value end end end return nil end local function propDump(props) local t = {} for _, p in ipairs(props) do t[#t + 1] = p.name .. '=' .. p.value end return table.concat(t, ' | ') end -- "1.001", "1:1", "1 / 001", " 2: 17" -> 1,1 ; reine Zahl = absolute Adresse local function parsePatch(s) if s == nil then return nil end s = tostring(s) local u, a = s:match('(%d+)%s*[%.:/]%s*(%d+)') if u then u, a = tonumber(u), tonumber(a) if u and a and u > 0 and a > 0 and a <= 512 then return u, a end end local abs = tonumber(s:match('^%s*(%d+)%s*$') or '') if abs and abs > 0 then return math.floor((abs - 1) / 512) + 1, (abs - 1) % 512 + 1 end return nil end local function cleanType(s) s = clean(s) if not s then return nil end s = s:gsub('^"(.*)"$', '%1') s = s:gsub('^%d+%s+', '') -- führende Pool-Nummer entfernen ("3 Mac Aura" -> "Mac Aura") return trim(s) end local function splitCSV(str) local t, start = {}, 1 while true do local i = str:find(',', start, true) if not i then t[#t + 1] = str:sub(start); break end t[#t + 1] = str:sub(start, i - 1) start = i + 1 end return t end local function msgbox(title, text) local v = gma.show.getvar('version') or '' if v:find('3.1.2') == 1 or not gma.gui.msgbox then gma.textinput(title, text) else gma.gui.msgbox(title, text) end end ---- LAYER-BAUM FINDEN ---- local function isFixtureLike(h) local pr = getProps(h) return (pick(pr, CAND.fid) or pick(pr, CAND.cid) or pick(pr, CAND.patch)) ~= nil end local function looksLikeLayers(h) local layers = children(h) if #layers == 0 then return false end for i = 1, math.min(#layers, 8) do local fx = children(layers[i]) if #fx > 0 and isFixtureLike(fx[1]) then return true end end return false end local function findLayers() local cands = {} local function consider(h, how) if not valid(h) then return end local nm, cl = norm(safe(G.name, h)), tostring(safe(G.class, h) or ''):upper() if nm == 'layers' or nm == 'fixturelayers' or cl:find('LAYERS') or cl:find('LAYER_COLLECT') then local parentName = norm(objName(safe(G.parent, h) or -1)) L('Kandidat:', how, 'name=' .. tostring(safe(G.name, h)), 'class=' .. cl, 'parent=' .. parentName) cands[#cands + 1] = {h = h, parent = parentName} end end -- 1) bekannte Adressen (laut MA-Forum: "Export Root 6.4" = Setup > Layers) for _, ref in ipairs({'Root 6.4', 'Layers', 'LiveSetup.Layers'}) do consider(safe(G.handle, ref), ref) end -- 2) Kinder von Root i durchsuchen (Handles wie "Root 10.2" funktionieren nicht überall) for i = 1, 20 do local h = safe(G.handle, 'Root ' .. i) if valid(h) then L('Root ' .. i .. ':', safe(G.name, h), safe(G.class, h)) for j, a in ipairs(children(h)) do if i == 10 or i == 11 then L(' ' .. i .. '.' .. j .. ':', safe(G.name, a), safe(G.class, a)) end consider(a, 'Root ' .. i .. '.' .. j) for _, b in ipairs(children(a)) do consider(b, 'Root ' .. i .. '.' .. j .. '.*') end end end end -- 3) Baum ab "Root" bis Tiefe 3 durchsuchen local root = safe(G.handle, 'Root') if valid(root) then for _, a in ipairs(children(root)) do consider(a, 'tree') for _, b in ipairs(children(a)) do consider(b, 'tree') for _, c in ipairs(children(b)) do consider(c, 'tree') end end end end -- Live-Setup bevorzugen, EditSetup nur als Notlösung local best for _, c in ipairs(cands) do if looksLikeLayers(c.h) then if not c.parent:find('edit') then return c.h end best = best or c.h end end return best end ---- PATCH AUS DEM BAUM LESEN ---- local function readTree(layersH) local list = {} local dumped = 0 for li, lh in ipairs(children(layersH)) do local lname = objName(lh) L('Layer', li, '"' .. lname .. '"', 'class=' .. tostring(safe(G.class, lh)), 'fixtures=' .. tostring(safe(G.amount, lh))) for _, fh in ipairs(children(lh)) do local pr = getProps(fh) local fx = { fid = clean(pick(pr, CAND.fid)), cid = clean(pick(pr, CAND.cid)), name = clean(pick(pr, CAND.name)) or clean(objName(fh)) or '', ftype = cleanType(pick(pr, CAND.ftype)) or '', layer = lname, lindex = li, subs = safe(G.amount, fh) or 0, } fx.uni, fx.addr = parsePatch(pick(pr, CAND.patch)) if not fx.uni then for _, sh in ipairs(children(fh)) do local spr = getProps(sh) fx.uni, fx.addr = parsePatch(pick(spr, CAND.patch)) if dumped < 3 then L(' Sub-Props:', propDump(spr)) end if fx.uni then break end end end if dumped < 3 then L(' Fixture-Props (class=' .. tostring(safe(G.class, fh)) .. '):', propDump(pr)) dumped = dumped + 1 end if fx.fid or fx.cid then list[#list + 1] = fx end end end return list end ---- FALLBACK: DMX-LISTE (wie Original-Plugin) ---- local function askUniverses() local s, e = CONFIG.uni_start, CONFIG.uni_end if s == 0 or e == 0 then local inp = gma.textinput('Universen für Adress-Abgleich (z.B. 1-8)', '1-8') if not inp then return nil end local a, b = inp:match('(%d+)%s*%-%s*(%d+)') if not a then a = inp:match('(%d+)'); b = a end s, e = tonumber(a), tonumber(b) end if not s or not e or e < s then return nil end return s, e end local function readDmxList(dirReports, uStart, uEnd) local tmp = 'fxl_tempreport.csv' local range = 'Dmxuniverse ' .. uStart .. '.*' for i = uStart + 1, uEnd do range = range .. ' + ' .. i .. '.*' end gma.cmd('SelectDrive 1') gma.cmd('List ' .. range .. ' /f="' .. tmp .. '"') local path = dirReports .. tmp local f = io.open(path, 'r') if not f then L('DMX-Liste nicht lesbar:', path); return nil end -- Spalten: [1]DMX Label, [2]Adresse (u:aaa), [3]Fixture Name, [4]FixID, [5]ChID, [6]Attrib, [7]Type, [8]DMX Profile local map, order, first, rows = {}, {}, true, 0 for line in f:lines() do if first then first = false L('DMX-Liste Kopfzeile:', line) else rows = rows + 1 local c = splitCSV(line) local u, a = parsePatch(c[2]) local fid = clean(c[4]); if fid then fid = fid:match('^(%d+)') or fid end local cid = clean(c[5]); if cid then cid = cid:match('^(%d+)') or cid end if u and (fid or cid) then local key = fid and ('F' .. fid) or ('C' .. cid) local abs = (u - 1) * 512 + a local e = map[key] if not e then e = {fid = fid, cid = cid, name = trim(c[3]), abs = abs, count = 0} map[key] = e order[#order + 1] = key end if abs < e.abs then e.abs = abs end e.count = e.count + 1 end end end f:close() os.remove(path) L('DMX-Liste: Zeilen=' .. rows, 'Fixtures=' .. #order) return map, order end ---- XML SCHREIBEN ---- local function writeXML(path, list, meta) local out = {} out[#out + 1] = '' out[#out + 1] = string.format('', VERSION, esc(meta.maver), esc(meta.show), esc(meta.date), esc(meta.source)) -- nach Layer gruppieren, Reihenfolge beibehalten local layers, lorder = {}, {} for _, fx in ipairs(list) do local k = fx.layer or '' if not layers[k] then layers[k] = {index = fx.lindex or (#lorder + 1), items = {}}; lorder[#lorder + 1] = k end table.insert(layers[k].items, fx) end for _, k in ipairs(lorder) do local ly = layers[k] out[#out + 1] = string.format(' ', esc(ly.index), esc(k)) for _, fx in ipairs(ly.items) do local patch = '' if fx.uni then patch = string.format('%d.%03d', fx.uni, fx.addr) end out[#out + 1] = string.format(' ', esc(fx.fid), esc(fx.cid), esc(fx.name), esc(fx.ftype), patch, fx.uni and tostring(fx.uni) or '', fx.addr and tostring(fx.addr) or '', fx.footprint and tostring(fx.footprint) or '', tostring(fx.subs or 0)) end out[#out + 1] = ' ' end out[#out + 1] = '' if CONFIG.debug_log then -- "--" ist in XML-Kommentaren verboten out[#out + 1] = '' end local f = io.open(path, 'w') if not f then return false end f:write(table.concat(out, '\n'), '\n') f:close() return true end ---- MAIN ---- return function() logLines = {} local dirMain = (gma.show.getvar('PATH') or '.') .. '/' local dirOut = CONFIG.output_dir ~= '' and CONFIG.output_dir or (dirMain .. 'reports/') local dirReports = dirMain .. 'reports/' local show = gma.show.getvar('SHOWFILE') or 'show' local stamp = (os and os.date and safe(os.date, '%Y%m%d_%H%M%S')) or tostring(math.floor(gma.gettime() or 0)) local dateStr = (os and os.date and safe(os.date, '%Y-%m-%d %H:%M')) or stamp local safeShow = show:gsub('[^%w%-_]', '_') local fileXML = dirOut .. CONFIG.file_prefix .. '_' .. safeShow .. '_' .. stamp .. '.xml' local fileLog = dirOut .. CONFIG.file_prefix .. '_debug.txt' L('Fixture Label Export', VERSION, 'MA', gma.show.getvar('version'), 'Host', gma.show.getvar('hostsubtype')) L('Show:', show, 'Ausgabe:', fileXML) local pBar = gma.gui.progress.start('Fixture-Etiketten: Patch lesen') local list, source = {}, 'tree' local layersH = findLayers() if layersH then L('Layer-Baum gefunden: name=' .. tostring(safe(G.name, layersH)), 'class=' .. tostring(safe(G.class, layersH))) list = readTree(layersH) else L('Layer-Baum NICHT gefunden') end -- fehlende Adressen / Footprint über DMX-Liste local missing = 0 for _, fx in ipairs(list) do if not fx.uni then missing = missing + 1 end end L('Fixtures aus Baum:', #list, 'ohne Patch:', missing) if CONFIG.dmx_fallback and (#list == 0 or missing > 0) then gma.gui.progress.settext(pBar, 'DMX-Liste auswerten') local s, e = askUniverses() if s then local map, order = readDmxList(dirReports, s, e) if map then if #list == 0 then source = 'dmxlist' for _, key in ipairs(order) do local d = map[key] list[#list + 1] = {fid = d.fid, cid = d.cid, name = d.name, ftype = '', layer = '', lindex = 1, uni = math.floor((d.abs - 1) / 512) + 1, addr = (d.abs - 1) % 512 + 1, footprint = d.count} end else source = 'tree+dmxlist' for _, fx in ipairs(list) do local base = fx.fid and fx.fid:match('^(%d+)') local cbase = fx.cid and fx.cid:match('^(%d+)') local d = (base and map['F' .. base]) or (cbase and map['C' .. cbase]) if d then fx.footprint = d.count if not fx.uni then fx.uni, fx.addr = math.floor((d.abs - 1) / 512) + 1, (d.abs - 1) % 512 + 1 end end end end end end end gma.gui.progress.stop(pBar) local ok = false if #list > 0 then ok = writeXML(fileXML, list, {maver = gma.show.getvar('version') or '', show = show, date = dateStr, source = source}) end if CONFIG.debug_log and not ok then local f = io.open(fileLog, 'w') if f then f:write(table.concat(logLines, '\n'), '\n'); f:close() end end if ok then gma.feedback('Fixture-Etiketten: ' .. #list .. ' Fixtures exportiert -> ' .. fileXML) msgbox('Fixture-Etiketten', #list .. ' Fixtures exportiert.\n' .. fileXML) if CONFIG.open_folder_onpc and string.lower(gma.show.getvar('hostsubtype') or '') == 'onpc' then pcall(os.execute, 'explorer "' .. dirOut:gsub('/', '\\') .. '"') end else gma.feedback('Fixture-Etiketten: Export fehlgeschlagen, siehe ' .. fileLog) msgbox('Fixture-Etiketten', 'Keine Fixtures gefunden oder Datei nicht schreibbar.\nDebug-Log: ' .. fileLog) end end