├── .DS_Store ├── .gitignore ├── .project ├── .pydevproject ├── .vscode ├── launch.json └── settings.json ├── Makefile ├── README.md ├── bin ├── .DS_Store ├── .swp ├── QLC.py ├── config.yml ├── expand_fixture_features.py ├── generate_fixtures.py ├── map_fixtures.yml ├── surface_map.yml └── sync_mitti.py ├── docs ├── Chase.png ├── Collection.png ├── Collections.png ├── Colors.png ├── CueList.png ├── ExpandFixtureFeatures.md ├── ExpandRGBLayouts.md ├── FixtureDefinition.png ├── FixtureDefinitions.png ├── FixtureScenes.png ├── ShowfileDesign.md └── XLSFixtureGroup-Spots.png ├── fixtures └── fixtures.yml ├── lib └── qlcplus_bin.py ├── requirements.txt ├── showfiles └── blank.qxw └── sourceme /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | *.pyc 3 | 4 | *.qxw 5 | *.xls 6 | .vscode/settings.json 7 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | qlcplus-tools 4 | 5 | 6 | 7 | 8 | 9 | org.python.pydev.PyDevBuilder 10 | 11 | 12 | 13 | 14 | 15 | org.python.pydev.pythonNature 16 | 17 | 18 | -------------------------------------------------------------------------------- /.pydevproject: -------------------------------------------------------------------------------- 1 | 2 | 3 | Default 4 | python interpreter 5 | 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "name": "Python: generate_fixtures.py", 10 | "type": "python", 11 | "request": "launch", 12 | "program": "bin/generate_fixtures.py", 13 | "console": "integratedTerminal", 14 | "args": [ "-c", "bin/config.yml", "bin/blank.qxw", "-d" , "-D", "-V"] 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/Users/branson/git/qlcplus-tools/venv/bin/python3" 3 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile to generate working environment for Ops 2 | 3 | # used to determine arch for downloads 4 | UNAME := $(shell uname | tr '[:upper:]' '[:lower:]') 5 | 6 | ACTIVATE_BIN := venv/bin/activate 7 | 8 | all: $(ACTIVATE_BIN) pip_requirements 9 | 10 | clean: 11 | $(RM) -r venv 12 | find . -name "*.pyc" -exec $(RM) -rf {} \; 13 | 14 | $(ACTIVATE_BIN): 15 | python3 -m venv venv 16 | chmod +x $@ 17 | 18 | pip_requirements: $(ACTIVATE_BIN) requirements.txt 19 | . venv/bin/activate; PYTHONWARNINGS='ignore:DEPRECATION' pip3 install -r requirements.txt 20 | 21 | freeze: requirements.txt 22 | pip3 freeze > requirements.txt 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qlcplus-tools 2 | 3 | Tools for managing and expanding QLC+ Files 4 | 5 | This repo is a collection of tools I use to manage QLC showfiles. I will work to 6 | add functionality as I go. Much of this is predicated on my use case.. which is: 7 | 8 | 1. configure fixtures and other DMX targets for the stage 9 | 1. create fixture groups based on show design 10 | 1. create sets of scenes by fixture features (tedious for every base combination) 11 | 1. create RGBMatrix and EFX as needed 12 | 1. combine scenes, matrix, EFX in collections as cue points 13 | 1. assemble collections into chaser by points in the show with timing and fading 14 | 1. cue the show 15 | 16 | You can see more details in [ShowfileDesign.md](./docs/ShowfileDesign.md) 17 | 18 | ## Naming Conventions 19 | This is predicated on what's been easy for me to teach to students and easy to find when assembling scenes into collections for a show 20 | 21 | - each FixtureBasedScene Name uses the full path in the storage structure to allow clean identification of the scene 22 | - each Collection is named based on thier use case 23 | - for cuepoints in the show I use {song}-{section}.{cue}-{name} to allow easy identification 24 | 25 | 26 | # Getting Started 27 | 28 | This tooling is setup to run in a venv locally and cleanly, to get things setup 29 | 30 | ``` 31 | git clone git@github.com:sandinak/qlcplus-tools.git 32 | . sourceme 33 | make 34 | ``` 35 | 36 | - All the programs have a --help option 37 | - All the programs are designed to be non-destructive .. they should not touch any existing configuration 38 | 39 | ## How to use these tools: 40 | 41 | 1. Create a new show file with fixtures mapped to universes as usual 42 | 2. Create FixtureGroups of lights you'd like to address simultaniously, these can be of the same fixture type or different types 43 | 3. Save the file 44 | 4. Run the programs against the file. There is an --output option, however the programs are designed to be non-destructive and will only create things. 45 | 5. Read the file back into QLC+ 46 | 6. Profit! 47 | 48 | ## NOTES 49 | 50 | - So you can run these programs as many times as you'd like .. they're idempotent 51 | - If you add/change/update FixtureGroups .. you can just re-run the programs on the files. 52 | - As noted .. these are non-destructive.. so if you remove FixtureGroups .. this will NOT remove the corresponding scenes for those Groups 53 | 54 | # Programs 55 | 56 | ## expand_fixture_features 57 | 58 | This tool has two major functions 59 | 60 | - [Expand Fixture Features](./docs/ExpandFixtureFeatures.md) - generate scenes for fixture features. 61 | - [Expand RGB Matrix Layouts](./docs/ExpandRGBLayouts.md) - export/import .xls files with layouts 62 | 63 | ## sync_mitti 64 | I use the Mitti Video tool to display basic video on screens as part of our show. Given the number of potential cues in Mitti that must match up to the cues in QLC; I wanted a way to syncronise these two so when a 'cue' is placed in QLC for a collection, it will be consistently activated on Mitti when the cue is activated on QLC. 65 | 66 | So it creates global and cue functions that do not require values, each as Script entries using the sendosc command. 67 | 68 | ## generate_fixtures 69 | 70 | This was a one-off program that generates large sets of fixtures. It's mostly deprecated and here as a framework if needed again. 71 | 72 | -------------------------------------------------------------------------------- /bin/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/bin/.DS_Store -------------------------------------------------------------------------------- /bin/.swp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/bin/.swp -------------------------------------------------------------------------------- /bin/QLC.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | ''' 4 | ============================================================================ 5 | Title: manage qlc showfile 6 | Description: 7 | QLC Libraries 8 | ============================================================================ 9 | NOTES/TODO: 10 | - need to refine the searches to be attribute aware instead of defining them discretely. 11 | - need to setup add/delete for things (Functions, Heads, etc) 12 | - allow import of colors for creation? 13 | - handle 'light' colors for heads that dont' have a white LED 14 | - add 'preset' position creation for all fixtures. 15 | ''' 16 | 17 | import logging 18 | import xml.etree.ElementTree as ET 19 | from xml.dom import XML_NAMESPACE, minidom 20 | import sys 21 | import copy 22 | import pprint 23 | import urllib.parse 24 | import re 25 | import os 26 | import xlsxwriter 27 | import pandas as pd 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | # colors 32 | # http://www.webriti.com/wp-content/uploads/2012/01/rgb-color-wheel-lg.jpg 33 | # colors for white. 34 | # TODO: Make a set w/o white called rgb 35 | FIXTURE_PATHS = [ 36 | '/Applications/QLC+.app/Contents/Resources/Fixtures', 37 | '~/Library/Application Support/QLC+/Fixtures' 38 | ] 39 | rgbw = { #shortname R G B W 40 | 'Red': { 'n':'R', 'rgbw': [255, 0, 0, 0], }, 41 | 'Green': { 'n':'G', 'rgbw': [ 0, 255, 0, 0], }, 42 | 'Blue': { 'n':'B', 'rgbw': [ 0, 0, 255, 0], }, 43 | 'Yellow': { 'n':'Y', 'rgbw': [255, 255, 0, 0], }, 44 | 'Cyan': { 'n':'C', 'rgbw': [ 0, 255, 255, 0], }, 45 | 'Magenta': { 'n':'M', 'rgbw': [255, 0, 255, 0], }, 46 | 'Violet': { 'n':'V', 'rgbw': [128, 0, 255, 0], }, 47 | 'All': { 'n':'A', 'rgbw': [255, 255, 255, 255], }, 48 | 'Rasp': { 'n':'Ra', 'rgbw': [255, 0, 128, 0], }, 49 | 'Orange': { 'n':'Or', 'rgbw': [255, 128, 0, 0], }, 50 | 'Ocean': { 'n':'Oc', 'rgbw': [ 0, 128, 128, 0], }, 51 | 'Turqoise': { 'n':'T', 'rgbw': [ 0, 255, 128, 0], }, 52 | 'Purple': { 'n':'P', 'rgbw': [128, 0, 255, 0], }, 53 | 'Pink': { 'n':'Pk', 'rgbw': [255, 0, 128, 0], }, 54 | 'White': { 'n':'W', 'rgbw': [ 0, 0, 0, 255], }, 55 | 'Flesh': { 'n':'F', 'rgbw': [245, 204, 176, 0], }, 56 | 'IntOnly': { 'n':'IO', 'rgbw': [ 0, 0, 0, 0], }, 57 | 58 | # with white 59 | 'Red+W': { 'n':'RW', 'rgbw': [255, 0, 0, 255], }, 60 | 'Green+W': { 'n':'GW', 'rgbw': [0, 255, 0, 255], }, 61 | 'Blue+W': { 'n':'BW', 'rgbw': [0, 0, 255, 255], }, 62 | 'Yellow+W': { 'n':'YW', 'rgbw': [255, 255, 0, 255], }, 63 | 'Cyan+W': { 'n':'CW', 'rgbw': [0, 255, 255, 255], }, 64 | 'Red+W': { 'n':'MW', 'rgbw': [255, 0, 255, 255], }, 65 | 'Purple+W': { 'n':'PW', 'rgbw': [128, 0, 255, 255], }, 66 | 'Rasp+W': { 'n':'RaW', 'rgbw': [255, 0, 128, 255], }, 67 | 'Orange+W': { 'n':'OrW', 'rgbw': [255, 128, 0, 255], }, 68 | 'Ocean+W': { 'n':'OcW', 'rgbw': [ 0, 128, 128, 255], }, 69 | 'Turqoise+W': { 'n':'TW', 'rgbw': [ 0, 255, 128, 255], }, 70 | 'Purple+W': { 'n':'PW', 'rgbw': [128, 0, 255, 255], }, 71 | 'Pink+W': { 'n':'PkW', 'rgbw': [255, 0, 128, 255], }, 72 | 'Flesh+W': { 'n':'FW', 'rgbw': [245, 204, 176, 255], }, 73 | } 74 | # because names are such random things 75 | RGB_ALTERNATES = { 76 | 'Cyan': 'Light Blue' 77 | } 78 | 79 | SPOT_ON_CHANNELS = [ 80 | 'master dimmer', 81 | 'spot-master dimmer', 82 | 'strobe/shutter', 83 | 'shutter', 84 | 'dimmer', 85 | 'intensity', 86 | 'dim/strobe' 87 | ] 88 | 89 | # NOTE: this is case sensitive. 90 | SPOT_COLORWHEEL_NAMES = [ 91 | 'Colour', 92 | 'Color', 93 | 'Color wheel', 94 | 'Color Wheel', 95 | 'Spot-Color Wheel', 96 | ] 97 | 98 | 99 | MOVEMENT_CHANNEL_NAMES = [ 100 | 'Pan', 101 | 'Level', 102 | 'Horizontal', 103 | 'Tilt', 104 | 'Vertical' 105 | ] 106 | 107 | # there's really only 1 here as we don't know the relative 108 | # horizontal positions, nor the total vertical deflection; 109 | # however having the preset to start with will help minimize 110 | # movements from this position .. rather than starting with 0,0 111 | # TODO: eventually it'd be good to pre-compute some positions by the 112 | # head PAN Max and Tilt Max .. however we dont' know which way 113 | # the heads turn or starting position .. so V is more reliable 114 | # than H... be nice to incporporate some of the work from 115 | # dmx_followspot which describes position, height, etc. 116 | BASE_POSITIONS = { 117 | # H V DMX Values, not angle. 118 | 'Base': [ 128, 128], 119 | '0': [ 0, 0], 120 | } 121 | 122 | # regexes 123 | size_re = re.compile(r'X:(\d+) Y:(\d+)') 124 | fixture_re = re.compile(r'^(.*) H:(\d+)') # A:(\d+) U:(\d+)$') 125 | # fixture_re = re.compile(r'^(.*)\sH:(\d+)') 126 | 127 | 128 | def match(items,item): 129 | for i in items: 130 | if i == item: 131 | return i 132 | 133 | def _strip(elem): 134 | for elem in elem.iter(): 135 | if(elem.text): 136 | elem.text = str(elem.text).strip() 137 | if(elem.tail): 138 | elem.tail = elem.tail.strip() 139 | 140 | def intersection(lst1, lst2): 141 | return list(set(lst1) & set(lst2)) 142 | 143 | 144 | BLANK_WORKSPACE = ''' 145 | 146 | 147 | 148 | 149 | Q Light Controller Plus 150 | 4.12.3 151 | Branson Matheson 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | None 165 | Default 166 | Default 167 | None 168 | Default 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | EOF 181 | '''.strip() 182 | 183 | ENCODING = 'UTF-8' 184 | DOCTYPE=''' 185 | 186 | 187 | '''.strip() 188 | 189 | class QLC(): 190 | def __init__(self, file=None): 191 | ''' setup a working QLC space ''' 192 | self.fixture_definitions = FixtureDefinitions(FIXTURE_PATHS) 193 | self.workspace = Workspace(file) 194 | self.output_ips = dict() 195 | 196 | def expand_fixture_group_capabilities(self): 197 | for fg in self.workspace.engine.fixture_groups.items: 198 | Name = fg.name 199 | logging.info(f'processing fixture group: {Name}') 200 | if len(fg.heads.items) == 0: 201 | continue 202 | 203 | self.generate_color_scenes(fg) 204 | self.generate_capability_scenes(fg) 205 | 206 | def generate_universes(self, universes): 207 | us = self.workspace.engine.inputoutputmap.universes 208 | # these are mapped as shown 209 | for u in universes: 210 | id = u.get('id') 211 | this_u = us.find_by_id(id) 212 | if this_u: 213 | this_u.update(u) 214 | else: 215 | us.add(u) 216 | 217 | 218 | def export_fixture_groups(self, path): 219 | fgs = self.workspace.engine.fixture_groups 220 | sheets = [] 221 | workbook = xlsxwriter.Workbook(path) 222 | bold = workbook.add_format({'bold': True}) 223 | bold_c = workbook.add_format({'bold': True, 'align': 'center'}) 224 | for fg in fgs: 225 | worksheet = workbook.add_worksheet(fg.name) 226 | worksheet.write(0,0,f'Fixture Group: {fg.name}', bold) 227 | 228 | # generate the matrix for this sheet 229 | # m: y,x 230 | size = fg.size 231 | # NOTE: y and x are REVERSED coming from QLC, 232 | # so if this seems odd it's to handle that strange case. 233 | s_x = int(size.x or "0") + 1 234 | s_y = int(size.y or "0") + 1 235 | 236 | # test heads to make sure we have the right size 237 | for head in fg.heads: 238 | if int(head.y) > s_y: 239 | s_y = int(head.y) 240 | if int(head.x) > s_x: 241 | s_x = int(head.x) 242 | 243 | worksheet.write(1,0,f'X:{s_x} Y:{s_y}', bold) 244 | logger.info(f"initalizing table %s: {s_y},{s_x}" % fg.name) 245 | m = [] 246 | # setup the grid 247 | for y in range(0, s_y+2): 248 | worksheet.write(y+2, 0, y+1, bold_c) 249 | m.append(['' for _ in range(0,s_x+2)]) 250 | 251 | for x in range(0, s_x+1): 252 | worksheet.write(1, x+1, x+1, bold_c ) 253 | for y in range(0, s_y+1): 254 | worksheet.write(x, y, '', ) 255 | 256 | max_w = 0 257 | for head in fg.heads: 258 | # TODO do we need the universe and such or just the name here. 259 | # fname = "%s: u:%d a: %d" % (head.fixture.name, head.fixture.universe, head.fixture.channels) 260 | logger.debug(f"looking for fixture id {head.fixture}") 261 | fixture = self.workspace.engine.fixtures.find_by_id(head.fixture) 262 | if fixture: 263 | fname = fixture.name 264 | logger.info(f"storing fixture {fname} ({head.id}) at {head.y},{head.x} in {s_y},{s_x}") 265 | # qlc plus displays relative to 1 vs 0 266 | h_id = int(head.id) + 1 267 | f_addr = int(fixture.address) + 1 268 | f_uni = int(fixture.universe) + 1 269 | m_name =f"{fname} H:{h_id}" 270 | if len(m_name) > max_w: 271 | max_w = len(m_name) 272 | m[int(head.y)][int(head.x)] = f"{m_name}" # \n{m_addr}" 273 | 274 | # set widths 275 | for c in range(0, s_x+1): 276 | worksheet.set_column(c, c, max_w+1) 277 | 278 | worksheet.add_table(2, 1, 2+s_y, 1+s_x, { 'header_row': False, 'data': m}) 279 | workbook.close() 280 | 281 | 282 | def extract_fixture_from_cell(self, data): 283 | if isinstance(data, str): 284 | data = data.replace('\n',' ') 285 | r = fixture_re.search(data) 286 | if not r: 287 | logger.error(f"unlable to match fixture data\n===\n{data}\n===") 288 | sys.exit(1) 289 | name, head = r.groups() 290 | f = self.workspace.engine.fixtures.find_by_name(name) 291 | if not f: 292 | logger.error(f"unable to match fixture to {name}") 293 | sys.exit(1) 294 | return f, head 295 | return False, None 296 | 297 | 298 | def extract_size_from_cell(self,data): 299 | r = size_re.search(data) 300 | if not r: 301 | logger.error(f"unlable to match size data {data}") 302 | sys.exit(1) 303 | 304 | s_x, s_y = r.groups() 305 | return int(s_y), int(s_x) 306 | 307 | def extract_fg_from_sheet(self, sheet): 308 | s_x, s_y = self.extract_size_from_cell(sheet.iat[0,0]) 309 | logger.debug(f"extracting sheet size {s_x},{s_y}") 310 | heads = [] 311 | for x in range(0, s_x): 312 | for y in range(0, s_y): 313 | try: 314 | data = sheet.iat[x+1,y+1] 315 | except: 316 | continue 317 | logger.debug(f"extracting {x},{y}: {data}") 318 | f, head = self.extract_fixture_from_cell(data) 319 | if f: 320 | # generate head xml, remember reversed 321 | heads.append({ 322 | 'x': str(int(y)), 323 | 'y': str(int(x)), 324 | 'f_id': f.id, 325 | 'head': str(int(head) -1), 326 | }) 327 | return s_x, s_y, heads 328 | 329 | def import_fixture_groups(self, path): 330 | worksheet = pd.ExcelFile(path) 331 | # each sheet name represents a fixture group 332 | for sheet_name in worksheet.sheet_names: 333 | sheet = worksheet.parse(sheet_name) 334 | s_x, s_y, heads = self.extract_fg_from_sheet(sheet) 335 | fg = { 336 | 'name': sheet_name, 337 | 'size_x': s_x, 338 | 'size_y': s_y, 339 | 'heads': heads 340 | } 341 | self.update_fixture_group(fg) 342 | 343 | def update_fixture_group(self, fg): 344 | fgs = self.workspace.engine.fixture_groups 345 | Name = fg.get('name') 346 | efg = fgs.find_by_name(Name) 347 | # if it exists, we use that fgid and delete the old one 348 | if efg: 349 | ID = str(efg.id) 350 | fgs.root.remove(efg.root) 351 | else: 352 | # else we generate a new one 353 | ID = str(fgs.next_id()) 354 | 355 | this_fg = ET.SubElement(fgs.root, "FixtureGroup") 356 | logger.info(f'generating fg {Name} {ID}') 357 | this_name = ET.SubElement(this_fg, 'Name') 358 | this_name.text = Name 359 | this_fg.set('ID',str(ID)) 360 | this_size = ET.SubElement(this_fg, 'Size') 361 | this_size.set('X', str(fg.get('size_y'))) 362 | this_size.set('Y', str(fg.get('size_x'))) 363 | for head in fg.get('heads'): 364 | this_head = ET.SubElement(this_fg, 'Head') 365 | this_head.set('X',str(head.get('x'))) 366 | this_head.set('Y',str(head.get('y'))) 367 | this_head.set('Fixture',str(head.get('f_id'))) 368 | this_head.text = head.get('head') 369 | 370 | def generate_fixtures(self,fixtures): 371 | fs = self.workspace.engine.fixtures 372 | for fixture in fixtures: 373 | logger.debug('generatng fixture %s ' % pprint.pformat(fixture)) 374 | 375 | # find or create fixture 376 | ef = fs.find_by_name(fixture.get('name')) 377 | if not ef: 378 | this_f = ET.SubElement(fs.root, "Fixture") 379 | ID = str(fs.next_id()) 380 | ET.SubElement(this_f, "ID").text = ID 381 | else: 382 | this_f = ef.root 383 | ET.SubElement(this_f, "Name").text = fixture.get('name') 384 | 385 | # find fixture definition and set Manufacturer 386 | this_f_model = fixture.get('model') 387 | this_fd = self.fixture_definitions.find_by_model(this_f_model)[0] 388 | if not this_fd: 389 | raise Exception(f'Unable to find fixture definition for {this_f_model}') 390 | ET.SubElement(this_f, "Manufacturer").text = this_fd.manufacturer 391 | ET.SubElement(this_f, "Model").text = this_fd.model 392 | ET.SubElement(this_f, "Channels").text = str(len(this_fd.channels.items)) 393 | ET.SubElement(this_f, "Address").text = str(fixture.get('a')) 394 | 395 | # find mode, required 396 | if fixture.get('mode'): 397 | this_f_mode = fixture.get('mode') 398 | fd_mode = this_fd.modes.find_by_name(this_f_mode) 399 | if not Mode: 400 | raise Exception(f'Unable to find mode {this_f_mode} in fixture def: {this_f_model}') 401 | elif len(this_fd.modes.items) == 1: 402 | fd_mode = this_fd.modes.items[0] 403 | else: 404 | raise Exception(f'No mode defined for fixture definition {this_f_model}') 405 | ET.SubElement(this_f, "Mode").text = fd_mode.name 406 | 407 | # TODO eventually make this smart 408 | u = fixture.get('u') 409 | ET.SubElement(this_f, "Universe").text = u 410 | 411 | 412 | def _sort_fval(self,fval): 413 | i = iter(fval.split(',')) 414 | d = dict(zip(i, i)) 415 | l = [] 416 | for k in sorted(d, key=lambda item: int(item)): 417 | l.append(k) 418 | l.append(d[k]) 419 | r = ','.join(l) 420 | return r 421 | 422 | def generate_color_scenes(self, fg): 423 | ''' this is now fixture independent... which means that groups 424 | can have more than one fixture type and it will try still do the 425 | right thing finding the intersections of colors across all the 426 | fixtures ''' 427 | Type = 'Scene' 428 | Path = '/'.join(['Colors', fg.name]) 429 | for color_name, color_data in rgbw.items(): 430 | Name = '/'.join([Path, color_name]) 431 | # generate fval by head 432 | fvals = {} 433 | fixtures = {} 434 | for head in fg.heads: 435 | # get fixture, if we can't find it.. skip 436 | fixture = self.workspace.engine.fixtures.find_by_id(head.fixture_id) 437 | if not fixture: 438 | print(f" FAIL: no fixture found for {head.fixture_id}") 439 | if fixture and fixture.mode == None: 440 | print(f" FAIL: no fixture mode for {fixture.name}") 441 | continue 442 | fixtures[fixture.id] = True 443 | 444 | # we skip undefined fixtures or pixel fixtures 445 | fixture_definition = self.fixture_definitions.find_by_fixture(fixture) 446 | if fixture_definition == None or 'Pixels' in fixture_definition.type: 447 | continue 448 | fixture_mode = fixture_definition.modes.find_by_name(fixture.mode) 449 | if not fixture_mode: 450 | logger.info(f" SkIP: no fixture mode for {fixture.name}") 451 | continue 452 | 453 | if not fixture_mode.heads: 454 | logger.info(f" SkIP: no heads for {fixture.name} {fixture_mode.name}") 455 | continue 456 | 457 | if fixture_mode.heads.count and 'mode channels' in dir(fixture_mode.heads.find_by_id(head.id)): 458 | # if we have more than one head .. find the channels for that head 459 | mode_channels = fixture_mode.heads.find_by_id(head.id).mode_channels 460 | else: 461 | # if not use all the channels for the 462 | mode_channels = fixture_mode.mode_channels 463 | 464 | 465 | fval = [] 466 | if any('Red' in s for s in mode_channels.names): 467 | # this is an RGB Head 468 | red, green, blue, white = color_data.get('rgbw') 469 | for mode_channel in mode_channels: 470 | channel = mode_channel.channel 471 | channel_name = channel.name.lower() 472 | # if multi head .. wash must be in the name 473 | if ( 'intensity' in channel_name or 'dimmer' in channel_name): 474 | fval.append(f'{mode_channel.number},255') 475 | elif 'red' in channel_name: 476 | fval.append(f'{mode_channel.number},{red}') 477 | elif 'green' in channel_name: 478 | fval.append(f'{mode_channel.number},{green}') 479 | elif 'blue' in channel_name: 480 | fval.append(f'{mode_channel.number},{blue}') 481 | elif 'white' in channel_name: 482 | fval.append(f'{mode_channel.number},{white}') 483 | 484 | if color_channels := intersection(mode_channels.names, SPOT_COLORWHEEL_NAMES): 485 | # this has a color wheel head 486 | color_channel = fixture_definition.channels.find_by_name(color_channels[0]) 487 | # see if we can match to a predefined color 488 | if color_name in RGB_ALTERNATES: 489 | color_name = RGB_ALTERNATES[color_name] 490 | 491 | if capability := color_channel.capabilities.find_by_name(color_name): 492 | cval = capability.min 493 | for mode_channel in mode_channels: 494 | channel = mode_channel.channel 495 | channel_name = channel.name.lower() 496 | color_channel_name = color_channel.name.lower() 497 | # if multi-head .. 'spot' must be in the name 498 | if ( 'intensity' in channel_name or 'dimmer' in channel_name 499 | or channel_name in SPOT_ON_CHANNELS): 500 | fval.append(f'{mode_channel.number},255') 501 | elif channel_name == color_channel_name: 502 | fval.append(f'{mode_channel.number},{cval}') 503 | 504 | # assemble the fvals for this head 505 | if len(fval) > 0: 506 | if fixture.id in fvals: 507 | fvals[fixture.id] = self._sort_fval((fvals[fixture.id] + ',%s' % ','.join(fval))) 508 | else: 509 | fvals[fixture.id] = ','.join(fval) 510 | 511 | # create the scene if all heads in the group are accounted for 512 | if len(fixtures) == len(fvals): 513 | print(f" adding {Name}") 514 | self.function( 515 | Type = Type, 516 | Path = Path, 517 | Name = Name, 518 | FixtureVals = fvals) 519 | 520 | def generate_capability_scenes(self, fg): 521 | ''' take each expandable channel and make scenes for the capabilities 522 | NOTE: this one does NOT work across heads.. so is only gonna work when the 523 | heads are all the same. ''' 524 | 525 | # first lets determine if this FG has all the same definition and mode 526 | fixture_definition = None 527 | mode = None 528 | capabilities = {} 529 | moving_head = False 530 | fixtures = dict() 531 | for head in fg.heads: 532 | 533 | # get fixture, if we can't find it.. skip 534 | fixture = self.workspace.engine.fixtures.find_by_id(head.fixture_id) 535 | if not fixture: 536 | print(f" FAIL: no fixture found for {head.fixture_id}") 537 | if fixture and fixture.mode == None: 538 | print(f" FAIL: no fixture mode for {fixture.name}") 539 | continue 540 | fixtures[fixture.id] = True 541 | 542 | # we skip undefined fixtures or pixel fixtures 543 | fixture_definition = self.fixture_definitions.find_by_fixture(fixture) 544 | if fixture_definition == None or 'Pixels' in fixture_definition.type: 545 | continue 546 | mode = fixture_definition.modes.find_by_name(fixture.mode) 547 | if 'heads' not in dir(mode): 548 | continue 549 | if mode.heads.count and 'mode channels' in dir(mode.heads.find_by_id(head.id)): 550 | mode_channels = mode.heads.find_by_id(head.id).mode_channels 551 | else: 552 | mode_channels = fixture_definition.modes.find_by_name(fixture.mode).mode_channels 553 | 554 | # now get the capability channels to expand for this fixture mode. 555 | # we identify the right ones by the channel groups we support. 556 | for mode_channel in mode_channels: 557 | channel = mode_channel.channel 558 | # normalize the name so it doesn't become part of the path 559 | # for any channel with capabilities, we make scenes 560 | if channel.capabilities: 561 | channel_name = channel.name.replace('/','+') 562 | Path = '/'.join(['Capabilties', fg.name, channel_name]) 563 | for capability in channel.capabilities: 564 | if capability.name: 565 | fval=[] 566 | # set the normalized scene name and capability value 567 | Name = '/'.join([Path, capability.name.replace('/','+') ]) 568 | fval.append(f'{mode_channel.number},{capability.min}') 569 | if Name not in capabilities: 570 | logger.info(f'adding {Name} to capabilities') 571 | capabilities[Name] = { 'fvals': {} } 572 | capabilities[Name]['Path'] = Path 573 | capabilities[Name]['fvals'][fixture.id] = ','.join(fval) 574 | 575 | 576 | # if moving head, generate base positions for this fixture 577 | if fixture_definition.type == 'Moving Head': 578 | Path = '/'.join(['Positions', fg.name]) 579 | 580 | for name, pos in BASE_POSITIONS.items(): 581 | h,v = pos 582 | Name = '/'.join([Path, name ]) 583 | fval = [] 584 | for mode_channel in mode_channels: 585 | channel = mode_channel.channel 586 | if channel.group == 'Pan' or channel.name == 'Pan': 587 | fval.append(f'{mode_channel.number},{h}') 588 | elif channel.group == 'Tilt' or channel.name == 'Tilt': 589 | fval.append(f'{mode_channel.number},{v}') 590 | if fval: 591 | if Name not in capabilities: 592 | logger.info(f'adding movement {Name} to capabilities') 593 | capabilities[Name] = { 'fvals' : {} } 594 | capabilities[Name]['Path'] = Path 595 | logger.debug(f"adding {head.fixture} {fval} to {Name}") 596 | capabilities[Name]['fvals'][fixture.id] = ','.join(fval) 597 | 598 | # generate scenes 599 | for Name,c in capabilities.items(): 600 | self.function( 601 | Type = 'Scene', 602 | Name = Name, 603 | Path = c['Path'], 604 | FixtureVals = c['fvals']) 605 | 606 | # TODO: extrapolate this into the actual classes 607 | def function(self, **kwargs): 608 | functions = self.workspace.engine.functions 609 | 610 | Name = kwargs.get('Name') 611 | ID = kwargs.get('ID') 612 | Type = kwargs.get('Type') 613 | Path = kwargs.get('Path') 614 | 615 | logger.debug(f'generating function {Name} at {Path}') 616 | 617 | # go find if we can, ID is most significant 618 | func = None 619 | if ID: 620 | func = functions.find_by_id(ID) 621 | if not func and Name: 622 | func = functions.find_by_name(Name) 623 | 624 | # if not create root element 625 | if not func: 626 | this_func = ET.SubElement(functions.root, "Function") 627 | ID = str(functions.next_id()) 628 | this_func.set('ID', ID) 629 | else: 630 | ID = func.id 631 | this_func = func.root 632 | 633 | # set/update primatives 634 | this_func.set('Name', Name) 635 | this_func.set('Type', Type) 636 | 637 | # optional 638 | if Path: 639 | this_func.set('Path', Path) 640 | 641 | if Type == 'Script': 642 | self.f_script(this_func, **kwargs) 643 | elif Type == 'Scene': 644 | fvals = kwargs.get('FixtureVals') 645 | self.f_scene(this_func, **kwargs) 646 | 647 | def f_script(self, f, **kwargs): 648 | 649 | self.fattr_speed(f, **kwargs) 650 | default_direction = 'Forward' 651 | if direction := kwargs.get('Direction') or default_direction: 652 | d = f.find('{%s}Direction' % Workspace.xmlns) 653 | if d == None: 654 | d = ET.SubElement(f, 'Direction') 655 | d.text = direction 656 | 657 | default_runorder = 'Loop' 658 | if runorder := kwargs.get('RunOrder') or default_runorder: 659 | r = f.find('{%s}RunOrder' % Workspace.xmlns) 660 | if r == None: 661 | r = ET.SubElement(f, 'RunOrder') 662 | r.text = runorder 663 | 664 | if kwargs.get('Command'): 665 | c = f.find('{%s}Command' % Workspace.xmlns) 666 | if c == None: 667 | c = ET.SubElement(f, 'Command') 668 | command = urllib.parse.quote(kwargs.get('Command'), safe='') 669 | c.text = command 670 | 671 | # create a scene 672 | def f_scene(self, f, **kwargs): 673 | fvals = kwargs.get('FixtureVals') 674 | self.fattr_speed(f, **kwargs) 675 | 676 | # set fixture vals 677 | fvs = f.findall('{%s}FixtureVal' % Workspace.xmlns) 678 | existing_fv_ids = [] 679 | for fv in fvs: 680 | fid = fv.get('ID') 681 | existing_fv_ids.append(fid) 682 | # remove it if not part of the group, else 683 | # update value 684 | if not fid in fvals: 685 | f.remove(fv) 686 | else: 687 | fv.text = fvals[fid] 688 | 689 | # update it if it's not there 690 | for fid, cstr in fvals.items(): 691 | if fid not in existing_fv_ids: 692 | this_fv = ET.SubElement(f,'FixtureVal') 693 | this_fv.set('ID', fid) 694 | this_fv.text = cstr 695 | 696 | 697 | def fattr_speed(self,f,**kwargs): 698 | default_speed = { 699 | 'FadeIn': '0', 700 | 'FadeOout': '0', 701 | 'Duration': '0' 702 | } 703 | if speed := kwargs.get('Speed') or default_speed: 704 | s = f.find('{%s}Speed' % Workspace.xmlns) 705 | if s == None: 706 | s = ET.SubElement(f, 'Speed') 707 | for k, v in speed.items(): 708 | s.set(k, v) 709 | 710 | 711 | class Workspace(QLC): 712 | xmlns = 'http://www.qlcplus.org/Workspace' 713 | def __init__(self, file): 714 | ''' setup class ''' 715 | # expand common targets 716 | ET.register_namespace('', self.xmlns) 717 | 718 | self.file = file 719 | logger.debug(f'loading {file}') 720 | 721 | if self.file: 722 | self.tree = ET.parse(self.file) 723 | self.root = self.tree.getroot() 724 | else: 725 | self.root = ET.fromstring(BLANK_WORKSPACE) 726 | 727 | self.creator = Creator(self.root) 728 | self.engine = Engine(self.root) 729 | 730 | def _gen_text(self): 731 | _strip(self.root) 732 | rough_bytes = ET.tostring(self.root) #, 'utf-8') 733 | # fix DOCTYPE 734 | reparsed = minidom.parseString(rough_bytes) 735 | xml = reparsed.toprettyxml(indent=' ') 736 | xml = xml.replace('', DOCTYPE) 737 | return xml 738 | 739 | def dump(self): 740 | self.text = self._gen_text() 741 | print(self.text) 742 | 743 | def write(self, file=None): 744 | out = file or self.file 745 | self.text = self._gen_text() 746 | f = open(out, 'w') 747 | f.write(self.text) 748 | f.close() 749 | 750 | class Creator(Workspace): 751 | def __init__(self, root): 752 | self.root = root.find('{%s}%s' % (Workspace.xmlns, type(self).__name__)) 753 | self.name = self.root.find('{%s}Name' % Workspace.xmlns).text 754 | self.version = self.root.find('{%s}Version' % Workspace.xmlns).text 755 | self.author = self.root.find('{%s}Author' % Workspace.xmlns).text 756 | 757 | class Engine(Workspace): 758 | def __init__(self, root): 759 | self.root = root.find('{%s}%s' % (Workspace.xmlns, type(self).__name__)) 760 | if not self.root: 761 | self.root = ET.SubElement(root, type(self).__name__) 762 | self.inputoutputmap = InputOutputMap(self.root) 763 | self.fixtures = Fixtures(self.root) 764 | self.fixture_groups = FixtureGroups(self.root) 765 | self.functions = Functions(self.root) 766 | 767 | 768 | class InputOutputMap(Engine): 769 | def __init__(self, root): 770 | self.root = root.find('{%s}%s' % (Workspace.xmlns, type(self).__name__)) 771 | if not self.root: 772 | self.root = ET.SubElement(root, type(self).__name__) 773 | self.universes = Universes(self.root) 774 | 775 | class Universes(InputOutputMap): 776 | def __init__(self, root): 777 | self.root = root 778 | self.items = [] 779 | for item in root.findall('{%s}Universe' % Workspace.xmlns): 780 | self.items.append(Universe(item)) 781 | 782 | self._next_id = 0 783 | self.names = list(map(lambda x: x.name, self.items)) 784 | self.ids = list(map(lambda x: x.id, self.items)) 785 | for id in self.ids: 786 | if int(id) > self._next_id: 787 | self._next_id = int(id) 788 | 789 | # map addresses 790 | self.u_by_output_ip = dict() 791 | for u in self.items: 792 | if u.output and u.output.output_ip: 793 | self.u_by_output_ip[u.output.output_ip] = u 794 | 795 | def add(self, u): 796 | self.items.append(Universe(self.root, u)) 797 | 798 | 799 | def find_by_output(self, output_ip, output_uni=None): 800 | u = self.u_by_ouput_ip.get(output_ip) 801 | if u: 802 | for o_ip_u in self.u_by_output_ip[output_ip]: 803 | if o_ip_u.output_uni == output_uni: 804 | return o_ip_u 805 | 806 | 807 | def find_by_name(self, name): 808 | for i in self.items: 809 | if i.name == name: 810 | return i 811 | 812 | def find_by_id(self, fid): 813 | for i in self.items: 814 | if int(i.id) == int(fid): 815 | return i 816 | 817 | def next_id(self): 818 | self._next_id +=1 819 | return self._next_id 820 | 821 | def __iter__(self): 822 | return iter(self.items) 823 | 824 | class Universe(Universes): 825 | def __init__(self, root, u=None): 826 | self.channels = [None] * 512 827 | self.input = None 828 | self.output = None 829 | if u: 830 | self.output_ip = u.get('ipaddr') 831 | self.output_u = u.get('artnet_u') 832 | self.id = str(u.get('id')) 833 | self.name = u.get('name') 834 | logger.debug('generatng universe %s' % pprint.pformat(u)) 835 | self.root = ET.SubElement(root, "{%s}Universe" % Workspace.xmlns) 836 | self.root.set('ID', self.id) 837 | self.root.set('Name',self.name) 838 | if self.output_ip: 839 | self.output = Output(self.root, u) 840 | else: 841 | # read 842 | self.root = root 843 | self.name = root.get('Name') 844 | self.id = root.get('ID') 845 | if root.find('{%s}Input' % Workspace.xmlns): 846 | self.input = Input(self.root, u) 847 | if root.find('{%s}Output' % Workspace.xmlns): 848 | self.output = Output(self.root, u) 849 | 850 | def update(self, u): 851 | self.root.set('Name',u.get('name')) 852 | if u.get('ipaddr') and u.get('artnet_u') and self.output: 853 | self.output.update(u) 854 | 855 | def allocate(self, channel, f_id): 856 | self.channels[channel] = f_id 857 | 858 | 859 | class Input(Universe): 860 | def __init__(self,root, u=None): 861 | self.root = root.find('{%s}Input' % Workspace.xmlns) 862 | self.plugin = self.root.get('Plugin') 863 | self.line = self.root.get('Line') 864 | 865 | 866 | # TODO: this all assumes artnet, make agnostic 867 | class Output(Universe): 868 | def __init__(self, root, u=None): 869 | self.root = root.find('{%s}Output' % Workspace.xmlns) 870 | self.output_ip = None 871 | if not self.root and u: 872 | # generate 873 | self.root = ET.SubElement(root,"Output") 874 | self.update(u) 875 | self.output_ip = u.get('ipaddr') 876 | else: 877 | self.parameters = self.root.find('{%s}PluginParameters' % Workspace.xmlns) 878 | if self.parameters: 879 | self.output_u = self.parameters.get('outputUni') 880 | self.output_ip = self.parameters.get('outputIP') 881 | if 'ArtNet' in self.plugin: 882 | self.artnet_ip = self.output_ip 883 | self.plugin = self.root.get('Plugin') 884 | self.line = self.root.get('Line') 885 | 886 | def update(self, u): 887 | if u.get('ipaddr') and u.get('artnet_u') != None: 888 | self.root.set('Plugin', 'ArtNet') 889 | # TODO: map this by known IP? 890 | self.root.set('Line', u.get('Line') or "2") 891 | 892 | pps = self.root.findall('{%s}PluginParameters' % Workspace.xmlns) 893 | if len(pps) > 1: 894 | # delete em all 895 | for pp in pps: 896 | self.root.remove(pp) 897 | pps = [] 898 | if not pps: 899 | # create one 900 | print(ET.tostring(self.root)) 901 | print('oops') 902 | self.parameters = ET.SubElement(self.root, "{%s}PluginParameters" % Workspace.xmlns) 903 | self.parameters.set('outputIP', u.get('ipaddr')) 904 | self.parameters.set('outputUni', str(u.get('artnet_u'))) 905 | 906 | 907 | class Fixtures(Engine): 908 | def __init__(self, root): 909 | # no Fixtures sub 910 | self.root = root 911 | self.items = [] 912 | for item in self.root.findall('{%s}Fixture' % Workspace.xmlns): 913 | self.items.append(Fixture(item)) 914 | 915 | self.names = list(map(lambda x: x.name, self.items)) 916 | self.ids = list(map(lambda x: int(x.id), self.items)) 917 | self._next_id = 0 918 | for id in self.ids: 919 | if int(id) > self._next_id: 920 | self._next_id = int(id) 921 | 922 | 923 | def next_id(self): 924 | self._next_id += 1 925 | return self._next_id 926 | 927 | 928 | def find_by_name(self, name): 929 | for i in self.items: 930 | if i.name == name: 931 | return i 932 | 933 | def find_by_id(self, fid): 934 | for i in self.items: 935 | if i.id == fid: 936 | return i 937 | 938 | def __iter__(self): 939 | return iter(self.items) 940 | 941 | 942 | class Fixture(Fixtures): 943 | def __init__(self, root): 944 | self.root = root 945 | self.manufacturer = root.find('{%s}Manufacturer' % Workspace.xmlns).text 946 | self.model = root.find('{%s}Model' % Workspace.xmlns).text 947 | self.name = f'{self.manufacturer} - {self.model}' 948 | self.mode = root.find('{%s}Mode' % Workspace.xmlns).text 949 | self.id = root.find('{%s}ID' % Workspace.xmlns).text 950 | self.name = root.find('{%s}Name' % Workspace.xmlns).text 951 | self.universe = root.find('{%s}Universe' % Workspace.xmlns).text 952 | self.address = root.find('{%s}Address' % Workspace.xmlns).text 953 | self.channels = root.find('{%s}Channels' % Workspace.xmlns).text 954 | 955 | 956 | class FixtureGroups(Engine): 957 | def __init__(self, root): 958 | # no FixtureGrops sub 959 | self.root = root 960 | self.items = [] 961 | for item in root.findall('{%s}FixtureGroup' % Workspace.xmlns): 962 | self.items.append(FixtureGroup(item)) 963 | 964 | self.names = list(map(lambda x: x.name, self.items)) 965 | self.ids = list(map(lambda x: x.id, self.items)) 966 | self._next_id = 0 967 | for id in self.ids: 968 | if int(id) > self._next_id: 969 | self._next_id = int(id) 970 | 971 | def add(self, ref): 972 | self.items.append(FixtureGroup(self.root, ref)) 973 | 974 | def delete(self, fg): 975 | self.items.remove(fg) 976 | 977 | def next_id(self): 978 | self._next_id += 1 979 | return self._next_id 980 | 981 | def find_by_name(self, name): 982 | for i in self.items: 983 | if i.name == name: 984 | return i 985 | 986 | def find_by_id(self, fid): 987 | for i in self.items: 988 | if i.id == fid: 989 | return i 990 | 991 | def __iter__(self): 992 | return iter(self.items) 993 | 994 | 995 | class FixtureGroup(FixtureGroups): 996 | def __init__(self, root): 997 | self.root = root 998 | self.name = self.root.find('{%s}Name' % Workspace.xmlns).text 999 | self.id = root.get('ID') 1000 | self.size = Size(root) 1001 | self.heads = Heads(root) 1002 | 1003 | class ChannelsGroup(Engine): 1004 | def __init__(self, root, cg=None): 1005 | self.root = root 1006 | if cg != None: 1007 | self.root.set('ID', cg.get('id')) 1008 | self.root.set('Name', cg.get('name')) 1009 | self.root.set('Value', cg.get('value')) 1010 | self.root.set('InputUniverse', cg.get('InputUniverse')) 1011 | self.root.set('InputChannel', cg.get('InputChannel')) 1012 | self.id = root.get('ID') 1013 | self.name = root.get('Name') 1014 | self.value = root.get('Value') 1015 | self.input_universe = root.get('InputUniverse') 1016 | self.input_channel = root.get('InputChannel') 1017 | self.mapping = root.text 1018 | 1019 | def update(self, cg): 1020 | for k,v in data.items(): 1021 | self.root.set(k,v) 1022 | 1023 | 1024 | class Size(FixtureGroup): 1025 | def __init__(self, root, fg=None): 1026 | self.root = root 1027 | self.root = root.find('{%s}Size' % Workspace.xmlns) 1028 | self.x = self.root.get('X') 1029 | self.y = self.root.get('Y') 1030 | 1031 | 1032 | class Heads(FixtureGroup): 1033 | def __init__(self, root, fg=None): 1034 | self.root = root 1035 | self.items = [] 1036 | if fg != None: 1037 | for head in fg.get('heads'): 1038 | self.items.append(Head(root, head)) 1039 | else: 1040 | for item in root.findall('{%s}Head' % Workspace.xmlns): 1041 | self.items.append(Head(item)) 1042 | 1043 | def update(self, heads): 1044 | for head in heads: 1045 | e_head = heads.find_by_id(head.get('id')) 1046 | if e_head: 1047 | e_head.update(head) 1048 | else: 1049 | self.items.append(Head(self.root, head)) 1050 | 1051 | 1052 | class Head(Heads): 1053 | def __init__(self, root, head=None): 1054 | self.root = root 1055 | if head != None: 1056 | self.root.set('X', head.get('X')) 1057 | self.root.set('Y', head.get('Y')) 1058 | self.root.text = head.get('id') 1059 | self.root.set('Fixture', head.get('Fixture')) 1060 | self.x = root.get('X') 1061 | self.y = root.get('Y') 1062 | self.id = root.text 1063 | self.fixture = root.get('Fixture') 1064 | self.fixture_id = root.get('Fixture') 1065 | 1066 | def update(self, head): 1067 | for k,v in data.items(): 1068 | self.root.set(k,v) 1069 | 1070 | class Functions(Engine): 1071 | def __init__(self, root): 1072 | # no Functions sub group 1073 | self.root = root 1074 | self.items = [] 1075 | for item in root.findall('{%s}Function' % Workspace.xmlns): 1076 | self.items.append(Function(item)) 1077 | 1078 | self.names = list(map(lambda x: x.name, self.items)) 1079 | 1080 | self.last_id = 0 1081 | for item in self.items: 1082 | if int(item.id) > self.last_id: 1083 | self.last_id = int(item.id) 1084 | 1085 | def find_by_name(self, name): 1086 | for i in self.items: 1087 | if i.name == name: 1088 | return i 1089 | 1090 | def find_by_path(self, path): 1091 | for i in self.items: 1092 | if i.path == path: 1093 | return i 1094 | 1095 | def find_by_id(self, id): 1096 | for i in self.items: 1097 | if i.id == id: 1098 | return i 1099 | 1100 | def next_id(self): 1101 | self.last_id += 1 1102 | return self.last_id 1103 | 1104 | def __iter__(self): 1105 | return iter(self.items) 1106 | 1107 | class Function(Functions): 1108 | def __init__(self, root): 1109 | self.root = root 1110 | self.id = root.get('ID') 1111 | self.type = root.get('Type') 1112 | self.name = root.get('Name') 1113 | self.path = root.get('Path') 1114 | self.speed = Speed(root) 1115 | if 'Scene' in self.type: 1116 | self.config = Scene(root) 1117 | 1118 | class Scene(Function): 1119 | def __init__(self, root): 1120 | self.fixture_vals = FixtureVals(root) 1121 | self.speed = Speed(root) 1122 | 1123 | class FixtureVals(Scene): 1124 | def __init__(self,root): 1125 | self.root = root 1126 | self.items = [] 1127 | for item in root.findall('{%s}FixtureVal' % Workspace.xmlns): 1128 | self.items.append(FixtureVal(item)) 1129 | 1130 | def __iter__(self): 1131 | return iter(self.items) 1132 | 1133 | class FixtureVal(FixtureVals): 1134 | def __init__(self, root): 1135 | self.id = root.get('ID') 1136 | self.data = root.text 1137 | 1138 | class Speed(Function): 1139 | def __init__(self, root): 1140 | self.root = root.find('{%s}Speed' % Workspace.xmlns) 1141 | if self.root: 1142 | self.fadein = self.root.get('FadeIn') 1143 | self.fadeout = self.root.get('FadeOut') 1144 | self.duration = self.root.get('Duration') 1145 | else: 1146 | self.fadein = '0' 1147 | self.fadeout = '0' 1148 | self.duration = '0' 1149 | 1150 | class FixtureDefinitions(QLC): 1151 | def __init__(self, paths): 1152 | self.paths = paths 1153 | self.items = dict() 1154 | self.fd_by_model = dict() 1155 | for path in paths: 1156 | self.read_fixture_dir(path) 1157 | 1158 | def read_fixture_dir(self,path): 1159 | realpath = os.path.expanduser(path) 1160 | entries = os.scandir(realpath) 1161 | for entry in entries: 1162 | t_path = f'{realpath}/{entry.name}' 1163 | if os.path.isdir(entry): 1164 | self.read_fixture_dir(t_path) 1165 | continue 1166 | elif entry.name.startswith('.'): 1167 | continue 1168 | elif '.qxf' in entry.name: 1169 | try: 1170 | fd = FixtureDefinition(entry) 1171 | if manufacturer := fd.manufacturer: 1172 | model = fd.model 1173 | else: 1174 | continue 1175 | # list by manufacturer and model 1176 | if not manufacturer in self.items: 1177 | self.items[manufacturer] = {} 1178 | self.items[manufacturer][model] = fd 1179 | # list by model 1180 | if not model in self.fd_by_model: 1181 | self.fd_by_model[model] = [] 1182 | self.fd_by_model[model].append(fd) 1183 | except Exception as e: 1184 | logger.error(f'Error loading {entry.name}: {e}') 1185 | continue 1186 | 1187 | def find_by_manufacturer_model(self, manufacturer, model): 1188 | if ( manufacturer in self.items and model in self.items[manufacturer] ): 1189 | return self.items[manufacturer][model] 1190 | 1191 | def find_by_fixture(self, fixture): 1192 | if fixture: 1193 | return self.find_by_manufacturer_model(fixture.manufacturer, fixture.model) 1194 | 1195 | def find_by_model(self, model): 1196 | return self.fd_by_model.get(model) 1197 | 1198 | def __iter__(self): 1199 | return iter(self.items) 1200 | 1201 | class FixtureDefinition(FixtureDefinitions): 1202 | # for all fixture definitions.. 1203 | xmlns = 'http://www.qlcplus.org/FixtureDefinition' 1204 | 1205 | def __init__(self, path): 1206 | ''' read the fixture xml into an dict indexed by manuf and model''' 1207 | # read/parse the file 1208 | ET.register_namespace('', FixtureDefinition.xmlns) 1209 | 1210 | try: 1211 | tree = ET.parse(path) 1212 | except Exception: 1213 | self.manufacturer = None 1214 | return None 1215 | self.root = tree.getroot() 1216 | 1217 | # set accessors 1218 | self.manufacturer = self.root.find('{%s}Manufacturer' % FixtureDefinition.xmlns).text 1219 | self.model = self.root.find('{%s}Model' % FixtureDefinition.xmlns).text 1220 | self.type = self.root.find('{%s}Type' % FixtureDefinition.xmlns).text 1221 | 1222 | logger.info(f'loading {self.manufacturer} {self.model}') 1223 | 1224 | self.channels = Channels(self.root) 1225 | self.modes = Modes(self.root, self.channels) 1226 | self.physical = Physical(self.root) 1227 | 1228 | class Physical(FixtureDefinition): 1229 | ''' extract channels and return a list ''' 1230 | def __init__(self, root): 1231 | self.item = root.findall('{%s}Physical' % FixtureDefinition.xmlns) 1232 | # get layout 1233 | if self.item: 1234 | layout = self.item[0].find('{%s}Layout' % FixtureDefinition.xmlns) 1235 | if layout: 1236 | self.layout.width = layout.get('Width') 1237 | self.layout.height = layout.get('Height') 1238 | 1239 | # get dimensions 1240 | dimensions = self.item[0].find('{%s}Dimensions' % FixtureDefinition.xmlns) 1241 | if dimensions: 1242 | self.dimensions.width = dimensions.get('Width') 1243 | self.dimensions.height = dimensions.get('Height') 1244 | self.dimensions.depth = dimensions.get('Depth') 1245 | self.dimensions.weight = dimensions.get('weight') 1246 | 1247 | 1248 | class Channels(FixtureDefinition): 1249 | ''' extract channels and return a list ''' 1250 | def __init__(self, root): 1251 | self.items = [] 1252 | for item in root.findall('{%s}Channel' % FixtureDefinition.xmlns): 1253 | self.items.append(Channel(item)) 1254 | 1255 | self.names = list(map(lambda x: x.name, self.items)) 1256 | 1257 | def find_by_name(self, name): 1258 | for i in self.items: 1259 | if i.name == name: 1260 | return i 1261 | 1262 | def find(self, name): 1263 | return self.find_by_name(name) 1264 | 1265 | def __iter__(self): 1266 | return iter(self.items) 1267 | 1268 | class Channel(Channels): 1269 | def __init__(self, root): 1270 | self.root = root 1271 | # create accessors 1272 | self.name = root.get('Name') 1273 | self.capabilities = Capabilities(root) 1274 | group = root.find('{%s}Group' % FixtureDefinition.xmlns) 1275 | if group != None: 1276 | self.group = group.text 1277 | else: 1278 | self.group = None 1279 | 1280 | class Capabilities(Channel): 1281 | def __init__(self, root): 1282 | self.items = [] 1283 | for item in root.findall('{%s}Capability' % FixtureDefinition.xmlns): 1284 | self.items.append(Capability(item)) 1285 | self.names = list(map(lambda x: x.name, self.items)) 1286 | 1287 | def find_by_name(self, name): 1288 | for i in self.items: 1289 | if i.name == name: 1290 | return i 1291 | 1292 | def find(self, name): 1293 | self.find_by_name(name) 1294 | 1295 | def __iter__(self): 1296 | return iter(self.items) 1297 | 1298 | class Capability(Capabilities): 1299 | def __init__(self, root): 1300 | self.root = root 1301 | self.name = root.text 1302 | self.min = root.get('Min') 1303 | self.max = root.get('Max') 1304 | 1305 | class Modes(Channel): 1306 | def __init__(self, root, channels): 1307 | self.items = [] 1308 | for item in root.findall('{%s}Mode' % FixtureDefinition.xmlns): 1309 | try: 1310 | self.items.append(Mode(item, channels)) 1311 | except Exception as e: 1312 | raise Exception(f'Error loading Mode {item.get("Name")}: {e}') 1313 | self.names = list(map(lambda x: x.name, self.items)) 1314 | 1315 | 1316 | def find_by_name(self, name): 1317 | for i in self.items: 1318 | if i.name == name: 1319 | return i 1320 | 1321 | def find(self, name): 1322 | return self.find_by_name(name) 1323 | 1324 | def __iter__(self): 1325 | return iter(self.items) 1326 | 1327 | class Mode(Modes): 1328 | def __init__(self, root, channels): 1329 | self.root = root 1330 | self.name = root.get('Name') 1331 | self.mode_channels = ModeChannels(root, channels) 1332 | self.channels = self.mode_channels.channels 1333 | self.heads = ModeHeads(root, self.mode_channels) 1334 | 1335 | class ModeChannels(Mode): 1336 | def __init__(self, root, channels): 1337 | self.root = root 1338 | self.items = [] 1339 | self.channels = [] 1340 | try: 1341 | for item in root.findall('{%s}Channel' % FixtureDefinition.xmlns): 1342 | mc = ModeChannel(item, channels) 1343 | self.items.append(mc) 1344 | self.channels.append(mc.channel) 1345 | self.names = list(map(lambda x: x.name, self.items)) 1346 | except Exception as e: 1347 | print('foo') 1348 | raise Exception(f'Error loading {self.name}: {e}') 1349 | 1350 | def find_by_name(self, name): 1351 | for i in self.items: 1352 | if i.name == name: 1353 | return i 1354 | 1355 | def find_by_number(self, number): 1356 | for i in self.items: 1357 | if i.number == number: 1358 | return i 1359 | 1360 | def find(self, name): 1361 | return self.find_by_name(name) 1362 | 1363 | def __iter__(self): 1364 | return iter(self.items) 1365 | 1366 | class ModeChannel(ModeChannels): 1367 | def __init__(self, root, channels): 1368 | self.root = root 1369 | self.name = root.text 1370 | self.number = root.get('Number') 1371 | self.channel = channels.find_by_name(self.name) 1372 | 1373 | class ModeHeads(Mode): 1374 | 1375 | def __init__(self, root, mode_channels): 1376 | self.root = root 1377 | self.items = [] 1378 | self.count = 0 1379 | for item in root.findall('{%s}Head' % FixtureDefinition.xmlns): 1380 | self.count += 1 1381 | mh = ModeHead(item, mode_channels, self.count) 1382 | self.items.append(mh) 1383 | 1384 | def find_by_id(self, id): 1385 | if int(id) < len(self.items): 1386 | return self.items[int(id)] 1387 | 1388 | class ModeHead(Mode): 1389 | def __init__(self, root, mode_channels, count): 1390 | self.root = root 1391 | self.id = count 1392 | self.mode_channels = ModeHeadChannels(root, mode_channels) 1393 | 1394 | class ModeHeadChannels(ModeHead): 1395 | def __init__(self, root, mode_channels): 1396 | self.root = root 1397 | self.items = [] 1398 | for hc in root.findall('{%s}Channel' % FixtureDefinition.xmlns): 1399 | self.items.append(ModeHeadChannel(hc, mode_channels)) 1400 | self.names = list(map(lambda x: x.channel.name, self.items)) 1401 | 1402 | class ModeHeadChannel(ModeHeadChannels): 1403 | def __init__(self, root, mode_channels): 1404 | self.root = root 1405 | self.number = self.root.text 1406 | self.mode_channel = mode_channels.find_by_number(self.number) 1407 | if not self.mode_channel: 1408 | raise Exception(f'No mode channel found for Head Channel {self.number}') 1409 | self.channel = self.mode_channel.channel 1410 | -------------------------------------------------------------------------------- /bin/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | universes: 3 | - name: Direct 4 | id: 0 5 | - name: DS-1-12 6 | id: 1 7 | ipaddr: 172.1.1.64 8 | artnet_u: 0 9 | - name: DS-1-34 10 | id: 2 11 | ipaddr: 172.1.1.64 12 | artnet_u: 1 13 | - name: DS-1-56 14 | id: 3 15 | ipaddr: 172.1.1.64 16 | artnet_u: 2 17 | - name: DS-1-78 18 | id: 4 19 | ipaddr: 172.1.1.64 20 | artnet_u: 3 21 | 22 | - name: DS-2-12 23 | id: 5 24 | ipaddr: 172.1.1.65 25 | artnet_u: 0 26 | - name: DS-2-34 27 | id: 6 28 | ipaddr: 172.1.1.65 29 | artnet_u: 1 30 | - name: DS-2-56 31 | id: 7 32 | ipaddr: 172.1.1.65 33 | artnet_u: 2 34 | - name: DS-2-78 35 | id: 8 36 | ipaddr: 172.1.1.65 37 | artnet_u: 3 38 | 39 | - name: DS-3-12 40 | id: 9 41 | ipaddr: 172.1.1.66 42 | artnet_u: 0 43 | - name: DS-3-34 44 | id: 10 45 | ipaddr: 172.1.1.66 46 | artnet_u: 1 47 | - name: DS-3-56 48 | id: 11 49 | ipaddr: 172.1.1.66 50 | artnet_u: 2 51 | - name: DS-3-78 52 | id: 12 53 | ipaddr: 172.1.1.66 54 | artnet_u: 3 55 | 56 | - name: DS-4-12 57 | id: 13 58 | ipaddr: 172.1.1.67 59 | artnet_u: 0 60 | - name: DS-4-34 61 | id: 14 62 | ipaddr: 172.1.1.67 63 | artnet_u: 1 64 | - name: DS-4-56 65 | id: 15 66 | ipaddr: 172.1.1.67 67 | artnet_u: 2 68 | - name: DS-4-78 69 | id: 16 70 | ipaddr: 172.1.1.67 71 | artnet_u: 3 72 | 73 | fixtures: 74 | - name: ds-1-1 75 | model: Step Row 64 Heads 76 | u: 1 77 | a: 0 78 | - name: ds-1-2 79 | model: Step Row 64 Heads 80 | u: 1 81 | a: 192 82 | - name: ds-1-3 83 | model: Step Row 64 Heads 84 | u: 2 85 | a: 0 86 | - name: ds-1-4 87 | model: Step Row 64 Heads 88 | u: 2 89 | a: 192 90 | - name: ds-1-5 91 | model: Step Row 64 Heads 92 | u: 3 93 | a: 0 94 | - name: ds-1-6 95 | model: Step Row 64 Heads 96 | u: 3 97 | a: 192 98 | - name: ds-1-7 99 | model: Step Row 64 Heads 100 | u: 4 101 | a: 0 102 | - name: ds-1-8 103 | model: Step Row 64 Heads 104 | u: 4 105 | a: 192 106 | 107 | - name: ds-2-1 108 | model: Step Row 64 Heads 109 | u: 5 110 | a: 0 111 | - name: ds-2-2 112 | model: Step Row 64 Heads 113 | u: 5 114 | a: 192 115 | - name: ds-2-3 116 | model: Step Row 64 Heads 117 | u: 6 118 | a: 0 119 | - name: ds-2-4 120 | model: Step Row 64 Heads 121 | u: 6 122 | a: 192 123 | - name: ds-2-5 124 | model: Step Row 64 Heads 125 | u: 7 126 | a: 0 127 | - name: ds-2-6 128 | model: Step Row 64 Heads 129 | u: 7 130 | a: 192 131 | - name: ds-2-7 132 | model: Step Row 64 Heads 133 | u: 8 134 | a: 0 135 | - name: ds-2-8 136 | model: Step Row 64 Heads 137 | u: 8 138 | a: 192 139 | 140 | - name: ds-3-1 141 | model: Step Row 64 Heads 142 | u: 9 143 | a: 0 144 | - name: ds-3-2 145 | model: Step Row 64 Heads 146 | u: 9 147 | a: 192 148 | - name: ds-3-3 149 | model: Step Row 64 Heads 150 | u: 10 151 | a: 0 152 | - name: ds-3-4 153 | model: Step Row 64 Heads 154 | u: 10 155 | a: 192 156 | - name: ds-3-5 157 | model: Step Row 64 Heads 158 | u: 11 159 | a: 0 160 | - name: ds-3-6 161 | model: Step Row 64 Heads 162 | u: 11 163 | a: 192 164 | - name: ds-3-7 165 | model: Step Row 64 Heads 166 | u: 12 167 | a: 0 168 | - name: ds-3-8 169 | model: Step Row 64 Heads 170 | u: 12 171 | a: 192 172 | 173 | - name: ds-4-1 174 | model: Step Row 64 Heads 175 | u: 13 176 | a: 0 177 | - name: ds-4-2 178 | model: Step Row 64 Heads 179 | u: 13 180 | a: 192 181 | - name: ds-4-3 182 | model: Step Row 64 Heads 183 | u: 14 184 | a: 0 185 | - name: ds-4-4 186 | model: Step Row 64 Heads 187 | u: 14 188 | a: 192 189 | - name: ds-4-5 190 | model: Step Row 64 Heads 191 | u: 15 192 | a: 0 193 | - name: ds-4-6 194 | model: Step Row 64 Heads 195 | u: 15 196 | a: 192 197 | - name: ds-4-7 198 | model: Step Row 64 Heads 199 | u: 16 200 | a: 0 201 | - name: ds-4-8 202 | model: Step Row 64 Heads 203 | u: 16 204 | a: 192 205 | 206 | fixture_groups: 207 | - type: step 208 | name: ds-1 209 | rows: 8 210 | cols: 64 211 | fixtures: 212 | - name: ds-1-1 213 | - name: ds-1-2 214 | - name: ds-1-3 215 | - name: ds-1-4 216 | - name: ds-1-5 217 | - name: ds-1-6 218 | - name: ds-1-7 219 | - name: ds-1-8 220 | 221 | # # - type: step 222 | # # name: us-2 223 | # # rows: 8 224 | # # cols: 64 225 | # # fixtures: step_row 226 | # # # ip_address: 172.18.1.202 227 | 228 | # # - type: step 229 | # # name: us-3 230 | # # rows: 8 231 | # # cols: 64 232 | # # fixtures: step_row 233 | # # # ip_address: 172.18.1.203 234 | 235 | # # - type: step 236 | # # name: us-4 237 | # # rows: 8 238 | # # cols: 64 239 | # # fixtures: step_row 240 | # # ip_address: 172.18.1.204 241 | 242 | # # - name: us 243 | # # type: matrix 244 | # # rows: 8 245 | # # cols: 256 246 | # # fixtures: 247 | # # - us-1 248 | # # - us-2 249 | # # - us-3 250 | # # - us-4 251 | 252 | 253 | 254 | 255 | 256 | -------------------------------------------------------------------------------- /bin/expand_fixture_features.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | ''' 4 | ============================================================================ 5 | Title: expand_fixture_features 6 | Description: 7 | Tool to pull osc config from Mitti and push script entries for QLC+ 8 | ============================================================================ 9 | ''' 10 | # -*- coding: utf-8 -*- 11 | 12 | import argparse 13 | import sys 14 | from os import path 15 | import logging 16 | 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | from QLC import QLC 21 | 22 | DESC = ''' 23 | This tool will: 24 | - ingest a showfile 25 | - ingest known fixtures based on normal QLC pathing 26 | - expand fixture capabilities by fixture group into scenes 27 | - edit in place or (over) write a new file 28 | ''' 29 | 30 | def setup_logging(args): 31 | """ setup logging """ 32 | global debug, verbose 33 | llevel = logging.ERROR 34 | if args.debug: 35 | llevel = logging.DEBUG 36 | print("logging debug") 37 | elif args.verbose: 38 | llevel = logging.INFO 39 | print("logging verbose") 40 | 41 | if args.logfile: 42 | if not os.access(args.logfile, os.W_OK): 43 | print("ERROR: Unable to write to %s") 44 | sys.exit(1) 45 | logging.basicConfig(filename=args.logfile, level=llevel) 46 | else: 47 | logging.basicConfig(level=llevel) 48 | 49 | def get_args(): 50 | ''' read arguments from C/L ''' 51 | parser = argparse.ArgumentParser( 52 | description=DESC, 53 | formatter_class=argparse.RawTextHelpFormatter) 54 | parser.set_defaults(help=False) 55 | parser.set_defaults(overwrite=False) 56 | parser.set_defaults(inplace=False) 57 | parser.set_defaults(import_fixture_groups=False) 58 | parser.set_defaults(export_fixture_groups=False) 59 | parser.set_defaults(expand_fixtures=False) 60 | parser.set_defaults(dump=False) 61 | parser.set_defaults(verbose=False) 62 | parser.set_defaults(debug=False) 63 | parser.add_argument("-V", "--verbose", 64 | help='enable verbose output', 65 | action="store_true") 66 | parser.add_argument("-D", "--debug", 67 | help='enable debugging output', 68 | action="store_true") 69 | parser.add_argument("--logfile", help="Send output to a file.") 70 | parser.add_argument("-d", "--dump", 71 | help='Dump modifications to STDOUT', 72 | action="store_true") 73 | 74 | xls = parser.add_argument_group("XLS Import/Export") 75 | xls.add_argument("--import-fixture-groups", "-I", help="Import Fixture Groups from XLSX file", action='store_true') 76 | xls.add_argument("--export-fixture-groups", "-E", help="Export Fixture Groups to XLSX file", action='store_true') 77 | xls.add_argument("--xls-file", "-x", help="Export Fixture Groups to XLSX file") 78 | 79 | fmgmt = parser.add_argument_group("File IN/Out") 80 | fmgmt.add_argument("-o", "--outputfile", 81 | type=str, default=None, 82 | help="Write to output file") 83 | fmgmt.add_argument("-F", "--overwrite", action="store_true", 84 | help="overwrite existing output file") 85 | fmgmt.add_argument("-i", "--inplace", 86 | help='Overwrite the showfile in place.', 87 | action="store_true") 88 | parser.add_argument("-X", "--expand-fixtures", help="Expand Fixture Features into Scenes", action='store_true') 89 | 90 | parser.add_argument('showfile', type=str, 91 | help='Showfile to extend') 92 | 93 | args = parser.parse_args() 94 | if args.help or not args.showfile: 95 | parser.print_help() 96 | sys.exit(0) 97 | 98 | if args.inplace and args.outputfile: 99 | print("can only write output file or in-place not both.") 100 | sys.exit(1) 101 | 102 | if ( args.export_fixture_groups or args.import_fixture_groups) and not args.xls_file: 103 | print("xls file required to import or export fixture groups.") 104 | sys.exit(1) 105 | 106 | return args 107 | 108 | 109 | def main(): 110 | # load a showfile 111 | args = get_args() 112 | setup_logging(args) 113 | 114 | # load the file and expand 115 | q = QLC(args.showfile) 116 | 117 | if args.export_fixture_groups and args.xls_file: 118 | q.export_fixture_groups(args.xls_file) 119 | 120 | elif args.import_fixture_groups and args.xls_file: 121 | if (not (args.inplace or args.outputfile)): 122 | print("importing fixture groups requires output option.") 123 | sys.exit(1) 124 | q.import_fixture_groups(args.xls_file) 125 | if args.expand_fixtures: 126 | q.expand_fixture_group_capabilities() 127 | 128 | # handle output 129 | if args.dump: 130 | q.workspace.dump() 131 | 132 | if args.inplace: 133 | if not args.overwrite: 134 | yn = input(f'Overwrite {args.showfile} [yN] ?') 135 | if 'n' in yn.lower(): 136 | sys.exit(0) 137 | q.workspace.write(args.showfile) 138 | 139 | elif args.outputfile: 140 | logger.debug(f'writing {args.outputfile}...') 141 | if path.exists(args.outputfile): 142 | if not args.overwrite: 143 | yn = input(f'Overwrite {args.outputfile} [yN] ?') 144 | if 'n' in yn.lower(): 145 | sys.exit(0) 146 | q.workspace.write(args.outputfile) 147 | 148 | 149 | if __name__ == '__main__': 150 | main() 151 | -------------------------------------------------------------------------------- /bin/generate_fixtures.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | ''' 4 | ============================================================================ 5 | Title: generate fixtures based on definitions 6 | Description: 7 | Pulls in config from yaml and generates fixtures and layouts. 8 | ============================================================================ 9 | ''' 10 | # -*- coding: utf-8 -*- 11 | 12 | import argparse 13 | import sys 14 | import yaml 15 | import logging 16 | import pprint 17 | import os 18 | 19 | from QLC import QLC 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | DESC = ''' 24 | This tool will: 25 | - ingest a yaml config 26 | - ingest a showfile 27 | - ingest known fixtures based on normal QLC pathing 28 | - generate/update fixture definitions based on config 29 | - generate/update fixtures based on config 30 | - generate/update fixture groups based on config 31 | - edit in place or (over) write a new showfile 32 | ''' 33 | 34 | def setup_logging(args): 35 | """ setup logging """ 36 | global debug, verbose 37 | llevel = logging.ERROR 38 | if args.debug: 39 | llevel = logging.DEBUG 40 | print("logging debug") 41 | elif args.verbose: 42 | llevel = logging.INFO 43 | print("logging verbose") 44 | 45 | if args.logfile: 46 | if not os.access(args.logfile, os.W_OK): 47 | print("ERROR: Unable to write to %s") 48 | sys.exit(1) 49 | logging.basicConfig(filename=args.logfile, level=llevel) 50 | else: 51 | logging.basicConfig(level=llevel) 52 | 53 | def get_args(): 54 | ''' read arguments from C/L ''' 55 | parser = argparse.ArgumentParser( 56 | description=DESC, 57 | formatter_class=argparse.RawTextHelpFormatter) 58 | parser.set_defaults(help=False) 59 | parser.set_defaults(overwrite=False) 60 | parser.set_defaults(inplace=False) 61 | parser.set_defaults(dump=False) 62 | parser.set_defaults(verbose=False) 63 | parser.set_defaults(debug=False) 64 | parser.add_argument("-V", "--verbose", 65 | help='enable verbose output', 66 | action="store_true") 67 | parser.add_argument("-D", "--debug", 68 | help='enable debugging output', 69 | action="store_true") 70 | parser.add_argument("--logfile", help="Send output to a file.") 71 | parser.add_argument("-i", "--inplace", 72 | help='Overwrite the showfile in place.', 73 | action="store_true") 74 | parser.add_argument("-d", "--dump", 75 | help='Dump modifications to STDOUT', 76 | action="store_true") 77 | parser.add_argument("-c", "--config_file", 78 | type=str, default=None, 79 | help="config file with definitions") 80 | parser.add_argument("-o", "--output_file", 81 | type=str, default=None, 82 | help="Write to output file") 83 | parser.add_argument("-F", "--overwrite", action="store_true", 84 | help="overwrite existing output file") 85 | parser.add_argument('showfile', type=str, 86 | help='Showfile to extend') 87 | 88 | args = parser.parse_args() 89 | if args.help or not args.showfile: 90 | parser.print_help() 91 | sys.exit(0) 92 | 93 | return args 94 | 95 | 96 | def main(): 97 | # load a showfile 98 | args = get_args() 99 | setup_logging(args) 100 | 101 | # load the config 102 | with open(args.config_file) as file: 103 | cfg = yaml.load(file,Loader=yaml.FullLoader) 104 | 105 | # load the file and expand 106 | q = QLC(args.showfile) 107 | 108 | universes = cfg.get('universes', []) 109 | if universes: 110 | logger.info('generating %d universes' % len(universes)) 111 | q.generate_universes(universes) 112 | q.workspace.dump() 113 | 114 | fixtures = cfg.get('fixtures', []) 115 | if fixtures: 116 | logger.info('generating %d fixtures' % len(fixtures)) 117 | q.generate_fixtures(cfg.get('fixtures')) 118 | 119 | fixture_groups = cfg.get('fixture_groups', []) 120 | if fixture_groups: 121 | logger.info('generating %d fixture_groups' % len(fixture_groups)) 122 | q.generate_fixture_groups(fixture_groups) 123 | 124 | # q.expand_fixture_group_capabilities() 125 | 126 | # handle output 127 | if args.dump: 128 | logger.info('dumping workspace') 129 | q.workspace.dump() 130 | 131 | elif not args.output_file: 132 | if not args.inplace: 133 | yn = input(f'Overwrite {args.showfile} [yN] ?') 134 | if not 'y' in yn: 135 | sys.exit(0) 136 | 137 | q.workspace.write(args.showfile) 138 | 139 | else: 140 | if os.path.exists(args.output_file): 141 | if not args.overwrite: 142 | yn = input(f'Overwrite {args.output_file} [yN] ?') 143 | if 'y' in yn.lower(): 144 | q.workspace.write(args.outputfle) 145 | else: 146 | q.workspace.write(args.outputfle) 147 | 148 | 149 | if __name__ == '__main__': 150 | main() 151 | -------------------------------------------------------------------------------- /bin/map_fixtures.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # default network for ArtNet in CIDR 4 | artnet_network: 172.19.0.0/24 5 | 6 | # NOTE: These are ordered 7 | universes: 8 | - name: Truss 9 | ip: 172.19.0.100 10 | - name: Wireless 11 | ip: 172.19.0.101 12 | 13 | fixtures: 14 | - name: DS1-1 15 | definition: Step Row 64 Heads 16 | mode: All 17 | address: 0 18 | ip: 172.19.0.201 19 | artnet_u: 1 20 | channel: 1 21 | 22 | - name: DS1-2 23 | model: Step Row 64 Heads 24 | mode: All 25 | address: 192 26 | ip: 172.19.0.201 27 | arnet_u: 1 28 | channel: 192 29 | 30 | - name: DS1-3 31 | model: Step Row 64 Heads 32 | mode: All 33 | universe: 3 34 | address: 0 35 | ip: 172.19.0.201 36 | arnet_u: 2 37 | 38 | - name: DS1-4 39 | model: Step Row 64 Heads 40 | mode: All 41 | universe: 3 42 | address: 192 43 | channels: 192 44 | 45 | - name: DS1-5 46 | model: Step Row 64 Heads 47 | mode: All 48 | universe: 4 49 | address: 0 50 | channels: 192 51 | 52 | - name: DS1-6 53 | model: Step Row 64 Heads 54 | mode: All 55 | universe: 4 56 | address: 192 57 | channels: 192 58 | 59 | - name: DS1-7 60 | model: Step Row 64 Heads 61 | mode: All 62 | universe: 5 63 | address: 0 64 | channels: 192 65 | 66 | - name: DS1-8 67 | model: Step Row 64 Heads 68 | mode: All 69 | universe: 5 70 | address: 192 71 | channels: 192 72 | 73 | 74 | fixture_groups: 75 | - name: All Steps 76 | x: 256 77 | y: 24 78 | fixtures: 79 | - name: ds1-1 80 | x: 0 81 | y: 82 | 83 | -------------------------------------------------------------------------------- /bin/surface_map.yml: -------------------------------------------------------------------------------- 1 | --- 2 | surface_maps: 3 | APC40 mkII: 4 | Intensity: Slider 1 5 | Red: Slider 2 6 | Green: Slider 3 7 | Blue: Slider 4 8 | White: Slider 5 9 | Amber: Slider 6 10 | UV: Slider 7 11 | Strobe: Slider 8 12 | Speed: Slider 9 13 | Pan: 14 | 15 | 16 | -------------------------------------------------------------------------------- /bin/sync_mitti.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | ''' 4 | ============================================================================ 5 | Title: sync_osc 6 | Description: 7 | Tool to pull osc config from Mitti and push script entries for QLC+ 8 | ============================================================================ 9 | ''' 10 | # -*- coding: utf-8 -*- 11 | 12 | import argparse 13 | import urllib.request 14 | import sys 15 | from os import path 16 | from urllib.parse import urlparse 17 | from html.parser import HTMLParser 18 | from QLC import QLC 19 | 20 | SENDOSC = '/usr/local/bin/sendosc' 21 | 22 | # TODO: 23 | EXPORTED_FUNCTIONS = [ 24 | 'audioOff', 25 | 'audioOn', 26 | 'fullscreenOff', 27 | 'fullscreenOn', 28 | 'goto10', 29 | 'goto20', 30 | 'goto30', 31 | 'jump', 32 | 'loopOn', 33 | 'loopOff', 34 | 'panic', 35 | 'pause', 36 | 'play', 37 | 'rewind', 38 | 'toggleFullscreen', 39 | 'toggleLoop', 40 | 'togglePlay', 41 | 'toggleTransitionOnPlay', 42 | 'transitiionOnPlayOff', 43 | 'transitiionOnPlayOn', 44 | ] 45 | 46 | # default looking at localhost. 47 | URL='http://127.0.0.1:51000' 48 | 49 | DESC = ''' 50 | This tool will: 51 | - ingest a showfile 52 | - connect to a Mitti OSC server 53 | - expand each Mitti Cues into a callable 'script' scene 54 | - edit in place or (over) write a new file 55 | ''' 56 | 57 | def _match(list, item): 58 | for i in list: 59 | if item == i: 60 | return item 61 | return None 62 | 63 | 64 | class OSC_HTMLParser(HTMLParser): 65 | ''' We rip all the headings out as those are the valid 66 | commands. 67 | NOTE: interestingly cue's start at 1 not zero .. so to keep things 68 | consistent .. I am using a dict of lists ''' 69 | def __init__(self): 70 | HTMLParser.__init__(self) 71 | 72 | def read(self, data): 73 | ''' read the input and return a list of cues ''' 74 | # clear output and reset parsers state 75 | self.cues = {} 76 | self.functions = [] 77 | self.last_cf = '' 78 | self.text = {} 79 | self.reset() 80 | 81 | # parse the data 82 | self.feed(data) 83 | return self.functions, self.cues, self.text 84 | 85 | def handle_starttag(self, tag, attrs): 86 | ''' all the cue's are in Tags ''' 87 | if tag == 'strong': 88 | self.valid_data = 1 89 | 90 | def handle_endtag(self, tag): 91 | self.valid_data = 0 92 | 93 | def handle_data(self, data): 94 | ''' split the cues' out .. eventually we will want 95 | to merge this with config so we can default some 96 | of the actions that take values, right now we only 97 | operate on functions that require no value ''' 98 | if self.valid_data: 99 | # this is weird because some functions have subconfig 100 | # NOTE: leading / means idx 0 is None 101 | pdata = data.split('/') 102 | 103 | # handle per-cue functions 104 | if len(pdata) >= 4: 105 | cue_id = pdata[2] 106 | function = pdata[3] 107 | self.last_cf = '%s/%s' % (cue_id,function) 108 | if not self.cues.get(cue_id): 109 | self.cues[cue_id] = [] 110 | self.cues[cue_id].append(function) 111 | 112 | # handle global functions 113 | elif len(pdata) == 3: 114 | function = pdata[2] 115 | self.last_cf = '%s' % (function) 116 | self.functions.append(function) 117 | else: 118 | if self.last_cf not in self.text: 119 | info = data.split('.')[0] 120 | self.text[self.last_cf] = info 121 | 122 | def sync_mitti(args, q, url): 123 | 124 | # break out URL 125 | u = urlparse(args.url) 126 | 127 | # Grab current OSC config 128 | try: 129 | with urllib.request.urlopen(args.url) as response: 130 | html = response.read().decode('utf-8') 131 | except Exception as e: 132 | print('unable to connect to %s: %s' % ( url, str(e))) 133 | sys.exit(1) 134 | 135 | if not html: 136 | print('no data from %s:' % url ) 137 | sys.exit(1) 138 | osc = OSC_HTMLParser() 139 | g_functions, cues, text = osc.read(html) 140 | 141 | # create scripts for all functions. 142 | # NOTE: these are all using NAME as significant as we can't predict ID 143 | # NOTE: have asked Mitti Developer to add name to the OSC server. 144 | # build the command 145 | syscmd = [ 146 | 'systemcommand:%s' % args.sendosc, 147 | 'arg:%s' % u.hostname, 148 | 'arg:%d' % u.port or 51000 ] 149 | cmd = ' '.join( syscmd ) 150 | 151 | # create the script scenes 152 | for cue, functions in cues.items(): 153 | for function in functions: 154 | this_cmd = cmd 155 | cf = '%s/%s' % (cue, function) 156 | if _match(EXPORTED_FUNCTIONS, function): 157 | if cf in text: 158 | this_cmd = '//Mitti command: %s %s\n' % (cf, text.get(cf)) + cmd 159 | q.function( 160 | Type = 'Script', 161 | Path = '/Mitti/%s' % cue, 162 | Name = '/Mitti/%s' % cf, 163 | Command = this_cmd + ' arg:/mitti/%s/%s' % (cue, function) 164 | ) 165 | 166 | for function in g_functions: 167 | if _match(EXPORTED_FUNCTIONS, function): 168 | this_cmd = cmd 169 | if function in text: 170 | this_cmd = '//Mitti command: %s %s\n' % (function, text.get(function)) + cmd 171 | q.function( 172 | Type = 'Script', 173 | Path = '/Mitti', 174 | Name = '/Mitti/%s' % (function), 175 | Command = this_cmd + ' arg:/mitti/%s' % function 176 | ) 177 | 178 | 179 | def get_args(): 180 | ''' read arguments from C/L ''' 181 | parser = argparse.ArgumentParser( 182 | description=DESC, 183 | formatter_class=argparse.RawTextHelpFormatter) 184 | parser.set_defaults(help=False) 185 | parser.set_defaults(overwrite=False) 186 | parser.set_defaults(inplace=False) 187 | parser.set_defaults(dump=False) 188 | parser.add_argument("-i", "--inplace", 189 | help='Overwrite the showfile in place.', 190 | action="store_true") 191 | parser.add_argument("-d", "--dump", 192 | help='Dump modifications to STDOUT', 193 | action="store_true") 194 | parser.add_argument("-o", "--outputfile", 195 | type=str, default=None, 196 | help="Write to output file") 197 | parser.add_argument("-s", "--sendosc", 198 | type=str, default=SENDOSC, 199 | help="Path to the sendosc executable.") 200 | parser.add_argument("-u", "--url", 201 | type=str, default=URL, 202 | help="URL for the Mitti OSC Service") 203 | parser.add_argument("-F", "--overwrite", action="store_true", 204 | help="overwrite existing output file") 205 | parser.add_argument('showfile', type=str, 206 | help='Showfile to extend') 207 | 208 | args = parser.parse_args() 209 | if args.help or not args.showfile: 210 | parser.print_help() 211 | sys.exit(0) 212 | 213 | return args 214 | 215 | def main(): 216 | # load a showfile 217 | args = get_args() 218 | 219 | if not args.outputfile and not args.inplace: 220 | print('ERR: must specify -i or an outputfile.') 221 | sys.exit(1) 222 | 223 | if not path.exists(args.sendosc): 224 | print('WARN: sendosc not found at %s\n please install from: https://github.com/yoggy/sendosc or brew ') 225 | 226 | # load a showfile 227 | q = QLC(file='../showfiles/blank.qxw') 228 | sync_mitti(args, q, URL) 229 | 230 | # handle output 231 | if args.dump: 232 | q.workspace.dump() 233 | 234 | elif args.inplace: 235 | # this assumes overwrite 236 | q.workspace.write(args.showfile) 237 | 238 | elif args.outputfile: 239 | if path.exists(args.outputfile): 240 | if not args.overwrite: 241 | yn = input(f'Overwrite {args.outputfile} [yN] ?') 242 | if 'y' in yn.lower(): 243 | q.workspace.write(args.outputfle) 244 | else: 245 | q.workspace.write(args.outputfle) 246 | 247 | if __name__ == '__main__': 248 | main() 249 | 250 | -------------------------------------------------------------------------------- /docs/Chase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/docs/Chase.png -------------------------------------------------------------------------------- /docs/Collection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/docs/Collection.png -------------------------------------------------------------------------------- /docs/Collections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/docs/Collections.png -------------------------------------------------------------------------------- /docs/Colors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/docs/Colors.png -------------------------------------------------------------------------------- /docs/CueList.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/docs/CueList.png -------------------------------------------------------------------------------- /docs/ExpandFixtureFeatures.md: -------------------------------------------------------------------------------- 1 | # Expand Fixture Features 2 | 3 | The tool ingests a showfile and configuration; and then writes a new showfile ( or in place ) with the configuration data expanded into scene folders based on the fixture groups defined. It's designed to be fixture agnostic, so you can mix "color wheel" heads and "RGB(W)" heads in your Fixture Groups and still get a color scene for that group that works for both sets. 4 | 5 | ## Usage 6 | 7 | 1. setup your show file as per [this document](Showfile_Design.md) with Fixture Groups that would be used in scenes in the show 8 | 1. setup this tool and make sure it's ready to operate using the help 9 | ``` 10 | git clone git@github.com:sandinak/qlcplus-tools.git 11 | make 12 | . sourceme 13 | ./bin/expand_fixture_features.py --help 14 | ``` 15 | 1. Run the tool against your file to generate the scenes 16 | ``` 17 | ./bin/expand_fixture_features.py -d -v -o newfile.qxw showfile.qxw 18 | ``` 19 | 1. run QLC+ on the file and review the new scenes. 20 | 21 | ## Advanced 22 | 23 | - if you want other colors/intensities than the defaults.. you can edit the QLC.py file to add/extend the color pallet. I am considering making this an external config file if there's interest 24 | - once you're comfortable using the tool, you can just edit the showfile in place. The tool is designed to be NON-Destructive against existing config in the file; and only add new config if needed ( eg .. it's idempotent ) 25 | 26 | 27 | -------------------------------------------------------------------------------- /docs/ExpandRGBLayouts.md: -------------------------------------------------------------------------------- 1 | # Expand RGB Layouts 2 | 3 | This part of the tool exports the fixture group layouts into a spreadsheet to more easily edit the locations of the assets in the group to the design you want. 4 | 5 | ## Export 6 | 7 | ``` 8 | ./bin/expand_fixture_features.py -o examples/showfile-after.qxw ./examples/showfile-before.qxw -E -x ./examples/showfile.xls 9 | ``` 10 | 11 | This will write out a file you can then open with your favorite spreadsheet editor, I like LibreOffice. In the file you'll find tabs that represent each Fixture Group with cells that match the instances of the fixture at that position 12 | 13 | 14 | 15 | ### Existing Fixture Groups 16 | You can edit the layout of fixtures here by: 17 | 18 | - moving the cells to the locations you want, just maintain the format of the cell name 19 | - adding more rows and columns, just make sure you change the X and Y settings at A2 20 | 21 | 22 | ### New FixtureGroups 23 | 24 | You can create new groups by: 25 | 26 | 1. duplicating an existing tab and naming the group in the tab name 27 | 1. changing the name in the header on the new page 28 | 1. updating the X: Y: settings in A2 29 | 1. copy/pasting from this or other Tabs to create the layout you want 30 | 31 | ## Import 32 | ``` 33 | ./bin/expand_fixture_features.py ./examples/showfile-before.qxw -I -x ./examples/showfile.xls -o ./examples/showfile-after.qxw 34 | ``` 35 | 36 | This will create the new file with a merge of the existing file and the new Fixture Groups and Layouts. 37 | 38 | -------------------------------------------------------------------------------- /docs/FixtureDefinition.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/docs/FixtureDefinition.png -------------------------------------------------------------------------------- /docs/FixtureDefinitions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/docs/FixtureDefinitions.png -------------------------------------------------------------------------------- /docs/FixtureScenes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/docs/FixtureScenes.png -------------------------------------------------------------------------------- /docs/ShowfileDesign.md: -------------------------------------------------------------------------------- 1 | # Showfile Design 2 | 3 | The premise of operation is that instead of making large monolithic scenes in the tool that require point editing to adapt or update, we assemble the show via combinations of settings. In this way.. making a change to a visual scene is as simple as changing the groupings of scenes. This also allows for easier global changes in the system. 4 | 5 | We use "folders" in the system to organize the layout of the data, and the tool will express the entire "path" to an object in the name so that when viewing the information in the system; it's clear what's being selected at that point. 6 | 7 | ## Fixture Groups 8 | 9 | We use this as the basic grouping of elements on the stage. Typically this is fixutres of same location or type or combinations; which are used together during scenes to light the show. In QLC+ are base set of groups created automatically when generating RGBMatrix features, however any seriues of fixtures anywhere on the stage can be grouped as desired. 10 | 11 | Fixture Definitions 12 | Fixture Definition 13 | 14 | If you're gonna be using these fixture sets in a chase or RGB Matrix, remember to lay them out correctly relative to their position on the stage. 15 | 16 | ## Fixture Based Scenes (FBS) 17 | We leverage this capability to assign "feature based scenes" to the groups so that when assembling cue-points in "chase" you can easily change the behavior of the fixtures by replacing the sets. This also means where you're using a common feature based scene, such as a position for a head; if you need to alter that for the entire show you can make the change in 1 place and it will apply to all instances of use of the scene. 18 | 19 | For the desigin of the software, we assume the sets needed are Capabilities (gobo, etc..), Colors, and Positions. Specials I use as a catch all for effects or other that are DMX driven such as Relays, Fog/Haze, etc. 20 | 21 | Fixture Scenes 22 | 23 | To create these scenes, we define some basic settings in the tool, load the existing showfile, and the tool will then automatically generate the fixture based scenes as needed. 24 | 25 | ### Organization 26 | 27 | Sets of capablities for each fixture group are organized in to scenes that can then be used in collections to assemble the view 28 | 29 | Colors 30 | 31 | The path is preserved in the name to avoid collisions and allow easy reference in when viewing an entire collection. 32 | 33 | 34 | ## Collections 35 | We leverage collections to assemble the FBS into a "view" of the stage. Collections can include scenes, matrixes, EFX, chases, sequences, scripts and other objects. We leverage this capability to take a single collection and assemble a set of FBSs and such to establish a view of the stage. 36 | 37 | Collection 38 | 39 | In this example it's the "preset" for the show so we have all the lights on and pointed in an expected configuration so that technicians can visually verify everything is working before the show starts. 40 | 41 | ### Organization 42 | 43 | We organize collections by their use-case and timing in the show. I use a cue naming format that allows easy expansion/extenssion {song}-{part}-{cue}; and song 0 is before-curtain. 44 | 45 | Collections 46 | 47 | 48 | ## Chase 49 | 50 | The Show is then setup to be run via cue'ing using a "Chase". Each cue is added with timings so that it can then be used as needed. To do this a hold time of ∞ is generally used so that the show can be cue'd via the virtual Console Cue List. Fade in/out timings can be set here as well .. and/or using a defined holdtime and duration can "auto-cue" where it makes sense. 51 | 52 | Chase 53 | 54 | 55 | ## Cuelist 56 | 57 | Once you've assembled the parts, A Cuelist is used in the Virtual Console to allow the show to be cued. Once added to the Virutal console, in the configuration we define a "Next Cue" button ( we use M ), and a "Previous Cue" button to allow the operator to move the show through time. 58 | 59 | Cuelist 60 | 61 | -------------------------------------------------------------------------------- /docs/XLSFixtureGroup-Spots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sandinak/qlcplus-tools/1850a22452fb0326902e3babaec89f6efbb8afee/docs/XLSFixtureGroup-Spots.png -------------------------------------------------------------------------------- /fixtures/fixtures.yml: -------------------------------------------------------------------------------- 1 | --- 2 | output_dir: ~/Library/Application Support/QLC+/Fixtures/ 3 | 4 | default: 5 | Creator: 6 | Name: Q Light Controller Plus 7 | Version: 4.12.2 8 | Author: Branson Matheson 9 | 10 | defs: 11 | - Manufacturer: PHS Chorus 12 | Model: Step Row 64 Heads 13 | Type: LED Bar (Pixels) 14 | Colors: RGB 15 | Heads: 64 16 | Physical: 17 | Bulb: 18 | Type: LED 19 | Lumens: 10 20 | ColourTemperature: 0 21 | Dimensions: 22 | Weight: 80 23 | Width: 10000 24 | Height: 203 25 | Depth: 228 26 | Lens: 27 | Name: Other 28 | DegreesMin: 180 29 | DegreesMax: 180 30 | Focus: 31 | Type: Fixed 32 | PanMax: 0 33 | TiltMax: 1 34 | Layout: 35 | Width: 64 36 | Height: 1 37 | Technical: 38 | PowerConsumption: 0 39 | DmxConnector: Other 40 | 41 | # - Manufacturer: PHS Chorus 42 | # Model: Step Row 128 Heads 43 | # Type: LED Bar (Pixels) 44 | # Colors: RGB 45 | # Heads: 128 46 | # Physical: 47 | # Bulb: 48 | # Type: LED 49 | # Lumens: 10 50 | # ColourTemperature: 0 51 | # Dimensions: 52 | # Weight: 80 53 | # Width: 10000 54 | # Height: 203 55 | # Depth: 228 56 | # Lens: 57 | # Name: Other 58 | # DegreesMin: 180 59 | # DegreesMax: 180 60 | # Focus: 61 | # Type: Fixed 62 | # PanMax: 0 63 | # TiltMax: 1 64 | # Layout: 65 | # Width: 64 66 | # Height: 1 67 | # Technical: 68 | # PowerConsumption: 0 69 | # DmxConnector: Other 70 | # 71 | -------------------------------------------------------------------------------- /lib/qlcplus_bin.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | ''' 4 | ============================================================================ 5 | Title: discover qlc version 6 | Description: 7 | create fixtures from basic config 8 | ============================================================================ 9 | ''' 10 | 11 | import platform 12 | import os 13 | 14 | BINPATH = { 15 | 'Darwin': '/Applications/QLC+.app/Contents/MacOS/qlcplus', 16 | } 17 | 18 | class qlc_bin: 19 | def __init__(self, **kwargs) 20 | 21 | ''' setup class ''' 22 | self.__dict__.update(kwargs) 23 | system = platform.system() 24 | 25 | _bin = self._find_bin() 26 | help = os.system(_bin + ' --help') 27 | 28 | version = self._parse_help(help) 29 | 30 | 31 | def _find_bin(self): 32 | if 33 | 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyaml 2 | XlsxWriter 3 | pandas 4 | openpyxl 5 | -------------------------------------------------------------------------------- /showfiles/blank.qxw: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Q Light Controller Plus 6 | 4.12.3 7 | Branson Matheson 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | None 21 | Default 22 | Default 23 | None 24 | Default 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /sourceme: -------------------------------------------------------------------------------- 1 | 2 | # source to setup shell and environment 3 | 4 | export GIT_NAME=$(basename ${PWD}) 5 | echo "activating $GIT_NAME" 6 | 7 | # activate virtualenv 8 | . venv/bin/activate 9 | 10 | # set prompt 11 | COLOR_PROMPT=${GIT_NAME} 12 | 13 | # extend path 14 | export PATH="${PWD}/bin:${PATH}" 15 | 16 | # add local dir for libs 17 | export PYTHONPATH="${PATH}/lib:${PYTHONPATH}" 18 | 19 | 20 | --------------------------------------------------------------------------------