#!/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()