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:
2026-09-08 00:59:36 +12:00
co-authored by Cursor
parent c92094cc26
commit 5eb623d6af
17 changed files with 7457 additions and 1195 deletions
+294
View File
@@ -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()