Files
qlcplus/tests/test_small_eye.py
jimmyandCursor 5eb623d6af 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>
2026-09-08 00:59:36 +12:00

295 lines
9.1 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()