Organize the lighting project and ship the combined XCHC show.
Group workspaces, generators, stage map, and tests into clear folders, and add the unified cans+movers show plus a simple shared/individual RGBW control board. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Executable
+294
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Art-Net tester for Small Eye 7 Mover channels 5 / 6 / 7.
|
||||
|
||||
Pan/tilt (1–4) already look right. This script lights the centre LED, then
|
||||
isolates:
|
||||
|
||||
CH5 pan/tilt speed — head should move fast at 0, crawl at 255
|
||||
CH6 zoom — beam should grow/shrink
|
||||
CH7 lens rotate — front glass spins only in 128–188 or 194–255,
|
||||
and often only when zoom is fully in (narrow)
|
||||
|
||||
Quit with Ctrl+C (sends blackout). Stop QLC+ output first so the two
|
||||
sources do not fight.
|
||||
|
||||
Example:
|
||||
python3 test_small_eye.py --ip 192.168.1.228 --address 1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
ARTNET_PORT = 6454
|
||||
HEADER = b"Art-Net\x00"
|
||||
OP_DMX = 0x5000
|
||||
PROT_VER = 14
|
||||
|
||||
# 1-based fixture channels (43ch pixel mode)
|
||||
CH_PAN = 1
|
||||
CH_TILT = 3
|
||||
CH_SPEED = 5
|
||||
CH_ZOOM = 6
|
||||
CH_ROTATE = 7
|
||||
CH_FOCUS = 8
|
||||
CH_DIMMER = 9
|
||||
CH_STROBE = 10
|
||||
CH_RED = 11
|
||||
CH_GREEN = 12
|
||||
CH_BLUE = 13
|
||||
CH_WHITE = 14
|
||||
FIXTURE_CHANNELS = 43
|
||||
|
||||
|
||||
def build_artdmx(channels: bytes, universe: int = 0, sequence: int = 1) -> bytes:
|
||||
data = bytearray(channels)
|
||||
n = len(data)
|
||||
if n < 2:
|
||||
data.extend(b"\x00" * (2 - n))
|
||||
n = 2
|
||||
if n & 1:
|
||||
data.append(0)
|
||||
n += 1
|
||||
if n > 512:
|
||||
data = data[:512]
|
||||
n = 512
|
||||
pkt = bytearray(18 + n)
|
||||
pkt[0:8] = HEADER
|
||||
pkt[8:10] = bytes((OP_DMX & 0xFF, OP_DMX >> 8))
|
||||
pkt[10] = 0
|
||||
pkt[11] = PROT_VER
|
||||
pkt[12] = sequence & 0xFF
|
||||
pkt[13] = 0
|
||||
pkt[14] = universe & 0xFF
|
||||
pkt[15] = 0
|
||||
pkt[16] = (n >> 8) & 0xFF
|
||||
pkt[17] = n & 0xFF
|
||||
pkt[18 : 18 + n] = data
|
||||
return bytes(pkt)
|
||||
|
||||
|
||||
class ArtNetSender:
|
||||
def __init__(self, dest: str, universe: int = 0, hz: float = 40.0):
|
||||
self.dest = dest
|
||||
self.universe = universe
|
||||
self.period = 1.0 / hz
|
||||
self.frame = bytearray(512)
|
||||
self.sequence = 1
|
||||
self._lock = threading.Lock()
|
||||
self._running = True
|
||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _loop(self):
|
||||
while self._running:
|
||||
with self._lock:
|
||||
pkt = build_artdmx(self.frame, self.universe, self.sequence)
|
||||
self.sequence = 1 if self.sequence >= 255 else self.sequence + 1
|
||||
try:
|
||||
self.sock.sendto(pkt, (self.dest, ARTNET_PORT))
|
||||
except OSError as exc:
|
||||
print(f"Art-Net send failed: {exc}", file=sys.stderr)
|
||||
time.sleep(self.period)
|
||||
|
||||
def set_slot(self, dmx_addr: int, value: int):
|
||||
"""dmx_addr is 1-based DMX."""
|
||||
with self._lock:
|
||||
self.frame[dmx_addr - 1] = max(0, min(255, int(value)))
|
||||
|
||||
def blackout(self):
|
||||
with self._lock:
|
||||
self.frame[:] = b"\x00" * 512
|
||||
|
||||
def close(self):
|
||||
self.blackout()
|
||||
time.sleep(self.period * 3)
|
||||
self._running = False
|
||||
self._thread.join(timeout=1.0)
|
||||
self.sock.close()
|
||||
|
||||
|
||||
class SmallEye:
|
||||
def __init__(self, sender: ArtNetSender, address: int):
|
||||
self.artnet = sender
|
||||
self.address = address
|
||||
|
||||
def set(self, channel: int, value: int):
|
||||
self.artnet.set_slot(self.address + channel - 1, value)
|
||||
|
||||
def park_light(self, pan=128, tilt=80, dimmer=255, white=255, rgb=40):
|
||||
"""Still, visible beam. Strobe open, pixels off except centre RGBW."""
|
||||
for ch in range(1, FIXTURE_CHANNELS + 1):
|
||||
self.set(ch, 0)
|
||||
self.set(CH_PAN, pan)
|
||||
self.set(CH_TILT, tilt)
|
||||
self.set(CH_SPEED, 0)
|
||||
self.set(CH_ZOOM, 0)
|
||||
self.set(CH_ROTATE, 0)
|
||||
self.set(CH_FOCUS, 128)
|
||||
self.set(CH_DIMMER, dimmer)
|
||||
self.set(CH_STROBE, 0)
|
||||
self.set(CH_RED, rgb)
|
||||
self.set(CH_GREEN, rgb)
|
||||
self.set(CH_BLUE, rgb)
|
||||
self.set(CH_WHITE, white)
|
||||
|
||||
|
||||
def say(msg: str):
|
||||
print(f"\n=== {msg} ===", flush=True)
|
||||
|
||||
|
||||
def hold(seconds: float, label: str = ""):
|
||||
if label:
|
||||
print(f" {label}", flush=True)
|
||||
end = time.monotonic() + seconds
|
||||
while time.monotonic() < end:
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def sweep(fixture: SmallEye, channel: int, start: int, stop: int, seconds: float):
|
||||
steps = max(1, int(seconds * 40))
|
||||
for i in range(steps + 1):
|
||||
value = int(start + (stop - start) * i / steps)
|
||||
fixture.set(channel, value)
|
||||
time.sleep(seconds / steps)
|
||||
|
||||
|
||||
def wait_enter(enabled: bool, prompt: str):
|
||||
if enabled:
|
||||
input(f" {prompt} [Enter] ")
|
||||
|
||||
|
||||
def test_speed(fixture: SmallEye, hold_s: float, pause: bool):
|
||||
say("CH5 pan/tilt SPEED")
|
||||
print("Watch the HEAD (yoke), not the lens.")
|
||||
print("CH5=0 should be fast, CH5=255 should crawl.")
|
||||
fixture.park_light()
|
||||
wait_enter(pause, "Ready to wiggle pan")
|
||||
for speed in (0, 80, 160, 255):
|
||||
fixture.set(CH_SPEED, speed)
|
||||
print(f" CH5 = {speed:3d} sweeping pan 40 → 220")
|
||||
sweep(fixture, CH_PAN, 40, 220, hold_s)
|
||||
sweep(fixture, CH_PAN, 220, 40, hold_s)
|
||||
fixture.set(CH_PAN, 128)
|
||||
fixture.set(CH_SPEED, 0)
|
||||
|
||||
|
||||
def test_zoom(fixture: SmallEye, hold_s: float, pause: bool):
|
||||
say("CH6 ZOOM")
|
||||
print("Watch the SPOT size on the wall. Should grow then shrink.")
|
||||
fixture.park_light()
|
||||
fixture.set(CH_ROTATE, 0)
|
||||
wait_enter(pause, "Ready for zoom sweep")
|
||||
print(" CH6 0 → 255")
|
||||
sweep(fixture, CH_ZOOM, 0, 255, hold_s * 2)
|
||||
hold(1.5, "CH6 = 255 (hold)")
|
||||
print(" CH6 255 → 0")
|
||||
sweep(fixture, CH_ZOOM, 255, 0, hold_s * 2)
|
||||
hold(1.5, "CH6 = 0 (hold, narrow)")
|
||||
|
||||
|
||||
def test_rotate(fixture: SmallEye, hold_s: float, pause: bool):
|
||||
say("CH7 LENS ROTATE")
|
||||
print("Watch the FRONT GLASS. Index 0–127 does not spin.")
|
||||
print("Spin is 128–188 (CCW) and 194–255 (CW). 189–193 is stop.")
|
||||
print("If nothing moves, zoom is probably too wide — trying both extremes.")
|
||||
fixture.park_light()
|
||||
wait_enter(pause, "Ready for rotate")
|
||||
|
||||
points = [
|
||||
(0, "index 0°"),
|
||||
(64, "index mid"),
|
||||
(127, "index 60°"),
|
||||
(160, "SPIN CCW"),
|
||||
(191, "stop band"),
|
||||
(220, "SPIN CW"),
|
||||
(255, "SPIN CW fast"),
|
||||
(0, "back to index 0"),
|
||||
]
|
||||
for zoom in (0, 255):
|
||||
fixture.set(CH_ZOOM, zoom)
|
||||
fixture.set(CH_ROTATE, 0)
|
||||
print(f"\n zoom CH6 = {zoom}")
|
||||
hold(1.0)
|
||||
for value, label in points:
|
||||
fixture.set(CH_ROTATE, value)
|
||||
hold(hold_s, f"CH7 = {value:3d} {label}")
|
||||
fixture.set(CH_ROTATE, 0)
|
||||
fixture.set(CH_ZOOM, 0)
|
||||
|
||||
|
||||
def test_hunt(fixture: SmallEye, hold_s: float, pause: bool):
|
||||
say("HUNT: put spin values on CH5, then CH6, then CH7")
|
||||
print("Whichever channel actually spins the glass is lens rotate.")
|
||||
fixture.park_light()
|
||||
wait_enter(pause, "Ready to hunt rotate")
|
||||
for candidate in (CH_SPEED, CH_ZOOM, CH_ROTATE):
|
||||
fixture.park_light()
|
||||
# Narrow zoom on the other two optics channels
|
||||
if candidate != CH_ZOOM:
|
||||
fixture.set(CH_ZOOM, 0)
|
||||
print(f"\n putting spin (160, then 220) on CH{candidate}")
|
||||
fixture.set(candidate, 160)
|
||||
hold(hold_s, f"CH{candidate} = 160 CCW")
|
||||
fixture.set(candidate, 220)
|
||||
hold(hold_s, f"CH{candidate} = 220 CW")
|
||||
fixture.set(candidate, 0)
|
||||
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--ip", default="192.168.1.228", help="Art-Net destination (QLC uses 192.168.1.228)")
|
||||
p.add_argument("--universe", type=int, default=0, help="Art-Net universe (default 0)")
|
||||
p.add_argument("--address", type=int, default=1, help="Fixture DMX start address, 1-based")
|
||||
p.add_argument("--hold", type=float, default=3.0, help="Seconds to hold each rotate/speed step")
|
||||
p.add_argument("--pause", action="store_true", help="Wait for Enter before each test")
|
||||
p.add_argument(
|
||||
"--only",
|
||||
choices=("speed", "zoom", "rotate", "hunt", "all"),
|
||||
default="all",
|
||||
help="Run one test (default all)",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
if not 1 <= args.address <= 512:
|
||||
sys.exit("address must be 1–512")
|
||||
|
||||
print(
|
||||
f"Art-Net → {args.ip} universe {args.universe} "
|
||||
f"fixture start {args.address} (43ch)"
|
||||
)
|
||||
print("Stop QLC+ Art-Net output first.")
|
||||
|
||||
sender = ArtNetSender(args.ip, universe=args.universe)
|
||||
fixture = SmallEye(sender, args.address)
|
||||
try:
|
||||
fixture.park_light()
|
||||
hold(2.0, "centre LED on, head still")
|
||||
tests = {
|
||||
"speed": lambda: test_speed(fixture, args.hold, args.pause),
|
||||
"zoom": lambda: test_zoom(fixture, args.hold, args.pause),
|
||||
"rotate": lambda: test_rotate(fixture, args.hold, args.pause),
|
||||
"hunt": lambda: test_hunt(fixture, args.hold, args.pause),
|
||||
}
|
||||
order = ("speed", "zoom", "rotate", "hunt") if args.only == "all" else (args.only,)
|
||||
for name in order:
|
||||
tests[name]()
|
||||
say("done — blackout")
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopped")
|
||||
finally:
|
||||
sender.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user